From 061fdc205a9c61859d27ae77e3a13ddf8a01d09f Mon Sep 17 00:00:00 2001 From: James McDonald Date: Mon, 2 Mar 2026 15:19:32 +0100 Subject: [PATCH] Import --- .gitignore | 1 + README.md | 245 +++++++++++++++++++++++++++++++++++ cert.go | 259 +++++++++++++++++++++++++++++++++++++ cmd_credential.go | 316 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 24 ++++ go.sum | 97 ++++++++++++++ handler.go | 233 ++++++++++++++++++++++++++++++++++ htpasswd.go | 111 ++++++++++++++++ kerberos.go | 81 ++++++++++++ ldap.go | 276 ++++++++++++++++++++++++++++++++++++++++ main.go | 232 ++++++++++++++++++++++++++++++++++ ward.service | 34 +++++ 12 files changed, 1909 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cert.go create mode 100644 cmd_credential.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handler.go create mode 100644 htpasswd.go create mode 100644 kerberos.go create mode 100644 ldap.go create mode 100644 main.go create mode 100644 ward.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e85e079 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ward diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dc63f9 --- /dev/null +++ b/README.md @@ -0,0 +1,245 @@ +# ward — setup and wiring guide + +ward runs on the k3s master as root, issues short-lived client certificates, +and lets users fetch a ready-to-use kubeconfig (or act as a kubectl exec credential +plugin) without anyone having to touch the k3s CA by hand. + +--- + +## 1. Build and install + +```bash +# On the k3s master (or cross-compile and copy) +go build -o /usr/local/bin/ward . +``` + +The same binary is both the **server** and the **client-side exec plugin** — +subcommand dispatch happens on the first argument. + +--- + +## 2. Server setup + +### Minimal (LDAP, auto-discovered via DNS SRV) + +```ini +ExecStart= +ExecStart=/usr/local/bin/ward \ + --k3s-server=https://k3s.example.com:6443 \ + --addr=:8443 \ + --ldap +``` + +### With an explicit LDAP URI (no DNS SRV required) + +``` +/etc/systemd/system/ward.service.d/local.conf +``` + +```ini +[Service] +ExecStart= +ExecStart=/usr/local/bin/ward \ + --k3s-server=https://k3s.example.com:6443 \ + --addr=:8443 \ + --ldap-uri=ldaps://ldap.example.com +``` + +```bash +systemctl daemon-reload +systemctl enable --now ward +``` + +### With Kerberos SPNEGO + +```ini +ExecStart= +ExecStart=/usr/local/bin/ward \ + --k3s-server=https://k3s.example.com:6443 \ + --addr=:8443 \ + --kerberos \ + --keytab=/etc/krb5.keytab \ + --spn=HTTP/k3s.example.com +``` + +#### Add the service principal to the KDC + +```bash +# On the KDC (MIT Kerberos) +kadmin.local -q "addprinc -randkey HTTP/k3s.example.com@EXAMPLE.COM" +kadmin.local -q "ktadd -k /etc/krb5.keytab HTTP/k3s.example.com@EXAMPLE.COM" + +# Verify +klist -k /etc/krb5.keytab +``` + +For Samba AD / Heimdal, the equivalent is: + +```bash +samba-tool spn add HTTP/k3s.example.com ward_service_account +net ads keytab add HTTP/k3s.example.com -U Administrator +``` + +### With htpasswd (break-glass / service accounts) + +```ini +ExecStart= +ExecStart=/usr/local/bin/ward \ + --k3s-server=https://k3s.example.com:6443 \ + --addr=:8443 \ + --kerberos \ + --htpasswd=/etc/ward/htpasswd +``` + +```bash +# Create the file and add a user (bcrypt is required for new entries) +mkdir -p /etc/ward +htpasswd -B -c /etc/ward/htpasswd alice + +# Add more users +htpasswd -B /etc/ward/htpasswd bob + +# Reload without restarting (SIGHUP) +systemctl reload ward +``` + +--- + +## 3. Get the bootstrap kubeconfig + +The `/bootstrap` endpoint returns a ready-to-use kubeconfig with no embedded +credentials — just the cluster endpoint, server CA, and the exec plugin stanza +that tells kubectl to call `ward credential` on demand. No authentication +required to fetch it. + +```bash +curl -s https://k3s.example.com:8443/bootstrap > ~/.kube/config +``` + +The username in the kubeconfig is resolved in priority order: + +1. `?user=` query parameter — explicit override +2. Credentials in the request (Basic or Kerberos) — personalised on the fly +3. The placeholder `YOUR_USERNAME_HERE` — for anonymous fetches + +```bash +# Personalised via query param (useful for scripting) +curl -s https://k3s.example.com:8443/bootstrap?user=alice > ~/.kube/config + +# Personalised by authenticating while fetching +curl -su alice https://k3s.example.com:8443/bootstrap > ~/.kube/config +``` + +Distribute this URL to users alongside the `ward` binary. That's the entire +onboarding process. + +--- + +## 4. Client setup + +Each user needs: + +1. The `ward` binary in their `$PATH` +2. The bootstrap kubeconfig + +```bash +# Install the binary (copy from the server, or build locally) +sudo cp ward /usr/local/bin/ward + +# Install the kubeconfig +cp bootstrap-kubeconfig.yaml ~/.kube/config # or merge manually +``` + +That's it. First use: + +```bash +# Kerberos path — if you have a valid ticket, it just works: +kinit alice@EXAMPLE.COM +kubectl get nodes + +# Basic auth path — prompted on first use, then cached for 24 h: +kubectl get nodes +# Password for alice: ▌ +``` + +Credentials are cached in `~/.cache/ward/` and reused until 5 minutes before +expiry. kubectl re-invokes the plugin automatically at that point — no manual +`kubectl login` step required. + +--- + +## 5. RBAC setup + +The issued certificate has: +- **CN** = username (LDAP `sAMAccountName` / `uid`, or Kerberos principal primary) +- **O** = LDAP group CNs (maps to Kubernetes RBAC groups) + +Bind roles as usual: + +```bash +# Grant a specific user cluster-admin +kubectl create clusterrolebinding alice-admin \ + --clusterrole=cluster-admin \ + --user=alice + +# Grant an LDAP group view access +kubectl create clusterrolebinding k8s-readers \ + --clusterrole=view \ + --group=k8s-users +``` + +--- + +## 6. Debugging + +### Server-side + +```bash +# Enable verbose logging +WARD_DEBUG=1 systemctl restart ward +journalctl -u ward -f + +# Or pass the flag directly +ward --debug --k3s-server=... --addr=:8443 +``` + +Server debug output shows: +- LDAP connection attempts (host, TLS, bind DN) +- Auth method selected per request (Kerberos / Basic) +- Group lookup results +- Cert issuance details + +### Client-side + +```bash +# Inline for a single command +WARD_DEBUG=1 kubectl get nodes + +# Or permanently in the kubeconfig exec env block: +# env: +# - name: WARD_DEBUG +# value: "1" +``` + +Client debug output (`stderr`, passed through by kubectl) shows: +- Which Kerberos credential cache was found and the principal in it +- Cache hit/miss and time remaining on cached cert +- HTTP request URL and response status +- Fallback to Basic auth and why + +### Bypass the cache + +```bash +ward credential --server=https://k3s.example.com:8443 --no-cache --debug +``` + +### Test without kubectl + +```bash +# Kerberos +kinit alice +ward credential --server=https://k3s.example.com:8443 --debug | python3 -m json.tool + +# Basic auth +ward credential --server=https://k3s.example.com:8443 --no-kerberos --debug +``` diff --git a/cert.go b/cert.go new file mode 100644 index 0000000..d59a400 --- /dev/null +++ b/cert.go @@ -0,0 +1,259 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "os" + "text/template" + "time" +) + +// CertConfig holds paths and settings for certificate and kubeconfig generation. +type CertConfig struct { + ServerURL string // written as the cluster.server in the returned kubeconfig + ServerCACert string // path — embedded in the kubeconfig so kubectl can verify the API server + ClientCACert string // path — signs the per-user client certificates + ClientCAKey string // path + Duration time.Duration // validity period for generated client certs + ClusterName string // name used for the cluster/context in generated kubeconfigs +} + +// KubeconfigGenerator loads the k3s CAs once and issues per-user kubeconfigs on demand. +type KubeconfigGenerator struct { + cfg *CertConfig + caCert *x509.Certificate + caKey crypto.PrivateKey + serverCA string // base64-encoded PEM of the server CA, ready to paste into kubeconfig +} + +// NewKubeconfigGenerator reads the k3s CA files and prepares the generator. +func NewKubeconfigGenerator(cfg *CertConfig) (*KubeconfigGenerator, error) { + caCert, caKey, err := loadCA(cfg.ClientCACert, cfg.ClientCAKey) + if err != nil { + return nil, fmt.Errorf("loading client CA: %w", err) + } + + serverCAPEM, err := os.ReadFile(cfg.ServerCACert) + if err != nil { + return nil, fmt.Errorf("reading server CA %s: %w", cfg.ServerCACert, err) + } + + return &KubeconfigGenerator{ + cfg: cfg, + caCert: caCert, + caKey: caKey, + serverCA: base64.StdEncoding.EncodeToString(serverCAPEM), + }, nil +} + +// Credential holds the raw PEM blobs and expiry for a generated client certificate. +// Used both for kubeconfig generation and the /credential exec-plugin endpoint. +type Credential struct { + CertPEM []byte + KeyPEM []byte + Expiry time.Time +} + +// GenerateCredential signs a fresh client certificate and returns the raw PEM data. +// Use this when you need the cert material directly (e.g. the exec credential plugin). +func (g *KubeconfigGenerator) GenerateCredential(username string, groups []string) (*Credential, error) { + certPEM, keyPEM, err := g.signClientCert(username, groups) + if err != nil { + return nil, fmt.Errorf("signing cert for %s: %w", username, err) + } + block, _ := pem.Decode(certPEM) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing signed cert: %w", err) + } + return &Credential{CertPEM: certPEM, KeyPEM: keyPEM, Expiry: cert.NotAfter}, nil +} + +// Generate produces a kubeconfig with a freshly signed client certificate for username. +// groups are embedded as the certificate's Organisation field, which Kubernetes reads +// as RBAC group memberships. +func (g *KubeconfigGenerator) Generate(username string, groups []string) ([]byte, error) { + cred, err := g.GenerateCredential(username, groups) + if err != nil { + return nil, err + } + return renderKubeconfig( + g.cfg.ServerURL, + g.serverCA, + g.cfg.ClusterName, + username, + base64.StdEncoding.EncodeToString(cred.CertPEM), + base64.StdEncoding.EncodeToString(cred.KeyPEM), + ) +} + +// signClientCert issues an ECDSA P-256 client certificate signed by the k3s client CA. +func (g *KubeconfigGenerator) signClientCert(username string, groups []string) (certPEM, keyPEM []byte, err error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generating key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("generating serial number: %w", err) + } + + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: username, + Organization: groups, // Kubernetes maps these to RBAC groups + }, + NotBefore: now.Add(-5 * time.Minute), // tolerate minor clock skew + NotAfter: now.Add(g.cfg.Duration), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, g.caCert, priv.Public(), g.caKey) + if err != nil { + return nil, nil, fmt.Errorf("signing certificate: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + privDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("marshaling private key: %w", err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER}) + + return certPEM, keyPEM, nil +} + +// loadCA reads a PEM certificate and its corresponding private key from disk. +// It handles EC, RSA, and PKCS#8 key formats. +func loadCA(certFile, keyFile string) (*x509.Certificate, crypto.PrivateKey, error) { + certPEM, err := os.ReadFile(certFile) + if err != nil { + return nil, nil, fmt.Errorf("reading %s: %w", certFile, err) + } + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, nil, fmt.Errorf("no PEM block in %s", certFile) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("parsing certificate in %s: %w", certFile, err) + } + + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + return nil, nil, fmt.Errorf("reading %s: %w", keyFile, err) + } + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, nil, fmt.Errorf("no PEM block in %s", keyFile) + } + + var key crypto.PrivateKey + switch keyBlock.Type { + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(keyBlock.Bytes) + case "RSA PRIVATE KEY": + key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + case "PRIVATE KEY": + key, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + default: + return nil, nil, fmt.Errorf("unsupported key type %q in %s", keyBlock.Type, keyFile) + } + if err != nil { + return nil, nil, fmt.Errorf("parsing key in %s: %w", keyFile, err) + } + return cert, key, nil +} + +// GenerateBootstrap returns a kubeconfig that contains no user credentials — +// just the cluster endpoint, server CA, and an exec plugin stanza that will +// invoke "ward credential --server=" on demand. +// Distribute this file to users instead of manually constructing it. +func (g *KubeconfigGenerator) GenerateBootstrap(wardURL, username string) ([]byte, error) { + var buf bytes.Buffer + err := bootstrapTmpl.Execute(&buf, map[string]string{ + "Server": g.cfg.ServerURL, + "ServerCA": g.serverCA, + "WardURL": wardURL, + "Username": username, + "Cluster": g.cfg.ClusterName, + }) + return buf.Bytes(), err +} + +var bootstrapTmpl = template.Must(template.New("bootstrap").Parse( + `apiVersion: v1 +kind: Config +preferences: {} +clusters: +- cluster: + certificate-authority-data: {{.ServerCA}} + server: {{.Server}} + name: {{.Cluster}} +contexts: +- context: + cluster: {{.Cluster}} + user: {{.Username}} + name: {{.Cluster}} +current-context: {{.Cluster}} +users: +- name: {{.Username}} + user: + exec: + apiVersion: client.authentication.k8s.io/v1 + command: ward + args: + - credential + - --server={{.WardURL}} + interactiveMode: IfAvailable + provideClusterInfo: false +`)) + +var kubeconfigTmpl = template.Must(template.New("kubeconfig").Parse( + `apiVersion: v1 +kind: Config +preferences: {} +clusters: +- cluster: + certificate-authority-data: {{.ServerCA}} + server: {{.Server}} + name: {{.Cluster}} +contexts: +- context: + cluster: {{.Cluster}} + user: {{.Username}} + name: {{.Cluster}} +current-context: {{.Cluster}} +users: +- name: {{.Username}} + user: + client-certificate-data: {{.ClientCert}} + client-key-data: {{.ClientKey}} +`)) + +func renderKubeconfig(server, serverCA, cluster, username, clientCert, clientKey string) ([]byte, error) { + var buf bytes.Buffer + err := kubeconfigTmpl.Execute(&buf, map[string]string{ + "Server": server, + "ServerCA": serverCA, + "Cluster": cluster, + "Username": username, + "ClientCert": clientCert, + "ClientKey": clientKey, + }) + return buf.Bytes(), err +} diff --git a/cmd_credential.go b/cmd_credential.go new file mode 100644 index 0000000..6e1b8ae --- /dev/null +++ b/cmd_credential.go @@ -0,0 +1,316 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "golang.org/x/term" + + krb5client "github.com/jcmturner/gokrb5/v8/client" + "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/credentials" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +// runCredential implements the "ward credential" subcommand, which acts as a +// kubectl exec credential plugin. It fetches an ExecCredential JSON from the +// ward server and prints it to stdout for kubectl to consume. +// +// Authentication priority: +// 1. Kerberos SPNEGO using the active credential cache (from kinit) +// 2. Basic auth — prompts for password, or reads $WARD_PASSWORD +// +// Credentials are cached in ~/.cache/ward/ and reused until 5 minutes +// before expiry, so kubectl invocations are fast after the first call. +// +// Debug output goes to stderr (kubectl surfaces this to the terminal): +// +// WARD_DEBUG=1 kubectl get nodes +func runCredential(args []string) int { + fs := flag.NewFlagSet("credential", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + server := fs.String("server", "", "ward server URL (required)") + username := fs.String("username", "", "username for Basic auth fallback (default: $USER)") + noKerberos := fs.Bool("no-kerberos", false, "skip Kerberos; always use Basic auth") + noCache := fs.Bool("no-cache", false, "bypass local cache; always fetch a fresh credential") + debug := fs.Bool("debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr (also: $WARD_DEBUG=1)") + + if err := fs.Parse(args); err != nil { + return 1 + } + if *server == "" { + fmt.Fprintln(os.Stderr, "ward credential: --server is required") + fs.PrintDefaults() + return 1 + } + + logf := func(format string, a ...interface{}) { + if *debug { + fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...) + } + } + + // ── Cache ───────────────────────────────────────────────────────────────── + if !*noCache { + if ec, ok := credReadCache(*server, logf); ok { + return credPrint(ec) + } + } + + // ── Fetch from ward ─────────────────────────────────────────────────── + ec, err := credFetch(*server, *username, *noKerberos, logf) + if err != nil { + fmt.Fprintf(os.Stderr, "ward credential: %v\n", err) + return 1 + } + + if !*noCache { + credWriteCache(*server, ec, logf) + } + return credPrint(ec) +} + +func credFetch(server, username string, noKerberos bool, logf func(string, ...interface{})) (*ExecCredential, error) { + url := strings.TrimRight(server, "/") + "/credential" + + // ── Kerberos SPNEGO ─────────────────────────────────────────────────────── + if !noKerberos { + body, err := credFetchKerberos(url, logf) + if err != nil { + logf("Kerberos: %v — falling back to Basic auth", err) + fmt.Fprintf(os.Stderr, "ward: Kerberos failed (%v); falling back to Basic auth\nhint: run 'kinit' to avoid the password prompt\n", err) + } else { + return credParse(body) + } + } + + // ── Basic auth ──────────────────────────────────────────────────────────── + if username == "" { + username = os.Getenv("USER") + } + password := os.Getenv("WARD_PASSWORD") + if password == "" { + var err error + password, err = credPromptPassword(username) + if err != nil { + return nil, err + } + } else { + logf("Basic: using password from $WARD_PASSWORD") + } + + body, err := credFetchBasic(url, username, password, logf) + if err != nil { + return nil, err + } + return credParse(body) +} + +func credFetchKerberos(url string, logf func(string, ...interface{})) ([]byte, error) { + krb5cfgPath := krb5ConfigPath() + logf("Kerberos: loading config from %s", krb5cfgPath) + krb5cfg, err := config.Load(krb5cfgPath) + if err != nil { + return nil, fmt.Errorf("loading krb5 config: %w", err) + } + + ccPath := ccachePath() + logf("Kerberos: loading credential cache from %s", ccPath) + ccache, err := credentials.LoadCCache(ccPath) + if err != nil { + return nil, fmt.Errorf("no credential cache (%w) — run 'kinit'", err) + } + + cl, err := krb5client.NewFromCCache(ccache, krb5cfg, krb5client.DisablePAFXFAST(true)) + if err != nil { + return nil, fmt.Errorf("creating Kerberos client: %w", err) + } + defer cl.Destroy() + + logf("Kerberos: principal=%s@%s", cl.Credentials.UserName(), cl.Credentials.Domain()) + + spnegoClient := spnego.NewClient(cl, nil, "") + logf("HTTP: GET %s (Negotiate)", url) + resp, err := spnegoClient.Get(url) + if err != nil { + return nil, fmt.Errorf("HTTP request: %w", err) + } + defer resp.Body.Close() + logf("HTTP: %s", resp.Status) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("server rejected Kerberos token (check SPN and keytab)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %s", resp.Status) + } + logf("HTTP: received %d bytes", len(body)) + return body, nil +} + +func credFetchBasic(url, username, password string, logf func(string, ...interface{})) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + req.SetBasicAuth(username, password) + logf("HTTP: GET %s (Basic, user=%s)", url, username) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request: %w", err) + } + defer resp.Body.Close() + logf("HTTP: %s", resp.Status) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("authentication failed (wrong username or password)") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + logf("HTTP: received %d bytes", len(body)) + return body, nil +} + +func credParse(body []byte) (*ExecCredential, error) { + var ec ExecCredential + if err := json.Unmarshal(body, &ec); err != nil { + return nil, fmt.Errorf("parsing server response: %w", err) + } + if ec.Status == nil { + return nil, fmt.Errorf("server returned ExecCredential with no status field") + } + return &ec, nil +} + +func credPrint(ec *ExecCredential) int { + if err := json.NewEncoder(os.Stdout).Encode(ec); err != nil { + fmt.Fprintf(os.Stderr, "ward credential: writing output: %v\n", err) + return 1 + } + return 0 +} + +// ── Local credential cache ───────────────────────────────────────────────────── +// Caches ExecCredential JSON in ~/.cache/ward/.json. +// The cache is consulted before contacting ward; a hit avoids a round-trip +// and, for Kerberos, avoids acquiring a service ticket on every kubectl call. + +func credCacheDir() string { + if d := os.Getenv("XDG_CACHE_HOME"); d != "" { + return filepath.Join(d, "ward") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "ward") +} + +func credCacheFile(serverURL string) string { + h := sha256.Sum256([]byte(serverURL)) + return filepath.Join(credCacheDir(), fmt.Sprintf("%x.json", h)) +} + +func credReadCache(serverURL string, logf func(string, ...interface{})) (*ExecCredential, bool) { + path := credCacheFile(serverURL) + logf("cache: checking %s", path) + + data, err := os.ReadFile(path) + if err != nil { + logf("cache: miss (%v)", err) + return nil, false + } + var ec ExecCredential + if err := json.Unmarshal(data, &ec); err != nil { + logf("cache: corrupt, ignoring (%v)", err) + return nil, false + } + if ec.Status == nil || ec.Status.ExpirationTimestamp == "" { + logf("cache: no expiry stored, refreshing") + return nil, false + } + expiry, err := time.Parse(time.RFC3339, ec.Status.ExpirationTimestamp) + if err != nil { + logf("cache: unparseable expiry %q, refreshing", ec.Status.ExpirationTimestamp) + return nil, false + } + remaining := time.Until(expiry) + if remaining < 5*time.Minute { + logf("cache: expiring soon (%v remaining), refreshing", remaining.Truncate(time.Second)) + return nil, false + } + logf("cache: hit — cert for expires %s (%v remaining)", + expiry.Format(time.RFC3339), remaining.Truncate(time.Second)) + return &ec, true +} + +func credWriteCache(serverURL string, ec *ExecCredential, logf func(string, ...interface{})) { + dir := credCacheDir() + if err := os.MkdirAll(dir, 0700); err != nil { + logf("cache: failed to create dir: %v", err) + return + } + data, err := json.Marshal(ec) + if err != nil { + logf("cache: marshal failed: %v", err) + return + } + path := credCacheFile(serverURL) + if err := os.WriteFile(path, data, 0600); err != nil { + logf("cache: write failed: %v", err) + return + } + logf("cache: saved to %s", path) +} + +// ── Kerberos helpers ─────────────────────────────────────────────────────────── + +func krb5ConfigPath() string { + if v := os.Getenv("KRB5_CONFIG"); v != "" { + return v + } + return "/etc/krb5.conf" +} + +// ccachePath returns the path to the active Kerberos credential cache. +// Respects $KRB5CCNAME; strips the "FILE:" prefix if present. +// Non-file ccache types (API:, KEYRING:, DIR:) are not supported by gokrb5 +// and will produce an error when LoadCCache is called. +func ccachePath() string { + if v := os.Getenv("KRB5CCNAME"); v != "" { + return strings.TrimPrefix(v, "FILE:") + } + return fmt.Sprintf("/tmp/krb5cc_%d", os.Getuid()) +} + +// ── Password prompt ──────────────────────────────────────────────────────────── + +func credPromptPassword(username string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", fmt.Errorf( + "stdin is not a terminal and $WARD_PASSWORD is not set\n" + + "hint: run 'kinit' for Kerberos auth, or set $WARD_PASSWORD for non-interactive use") + } + fmt.Fprintf(os.Stderr, "Password for %s: ", username) + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // newline after the hidden input + if err != nil { + return "", fmt.Errorf("reading password: %w", err) + } + return string(pw), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f8b1fd5 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module ward + +go 1.22 + +require ( + github.com/go-ldap/ldap/v3 v3.4.8 + github.com/jcmturner/goidentity/v6 v6.0.1 + github.com/jcmturner/gokrb5/v8 v8.4.4 + golang.org/x/crypto v0.21.0 + golang.org/x/term v0.18.0 +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6446afd --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= +github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..fa01990 --- /dev/null +++ b/handler.go @@ -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= 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) +} diff --git a/htpasswd.go b/htpasswd.go new file mode 100644 index 0000000..22be76c --- /dev/null +++ b/htpasswd.go @@ -0,0 +1,111 @@ +package main + +import ( + "bufio" + "crypto/sha1" + "crypto/subtle" + "encoding/base64" + "fmt" + "os" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" +) + +// HtpasswdAuth authenticates users against an Apache-compatible htpasswd file. +// +// Supported hash formats: +// - bcrypt ($2y$, $2a$, $2b$) — recommended; generate with: htpasswd -B -c file user +// - SHA-1 ({SHA}...) — legacy only; use bcrypt for new entries +// +// The file is parsed at startup and can be reloaded at runtime by calling Reload() +// (wired to SIGHUP in main). Lines beginning with '#' and blank lines are ignored. +type HtpasswdAuth struct { + path string + mu sync.RWMutex + entries map[string]string // username → hash +} + +// NewHtpasswdAuth reads path and returns a ready HtpasswdAuth. +func NewHtpasswdAuth(path string) (*HtpasswdAuth, error) { + h := &HtpasswdAuth{path: path} + if err := h.Reload(); err != nil { + return nil, err + } + return h, nil +} + +// Reload re-reads the htpasswd file from disk, atomically swapping the +// in-memory table. Safe to call from a signal handler goroutine. +func (h *HtpasswdAuth) Reload() error { + f, err := os.Open(h.path) + if err != nil { + return fmt.Errorf("opening htpasswd %s: %w", h.path, err) + } + defer f.Close() + + entries := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + user, hash, ok := strings.Cut(line, ":") + if !ok { + continue + } + entries[user] = hash + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading htpasswd: %w", err) + } + + h.mu.Lock() + h.entries = entries + h.mu.Unlock() + return nil +} + +// Len returns the number of users currently loaded. +func (h *HtpasswdAuth) Len() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.entries) +} + +// Authenticate returns nil if username/password match an entry in the file. +func (h *HtpasswdAuth) Authenticate(username, password string) error { + h.mu.RLock() + hash, ok := h.entries[username] + h.mu.RUnlock() + + if !ok { + return fmt.Errorf("invalid credentials") + } + return verifyHTPasswd(hash, password) +} + +func verifyHTPasswd(hash, password string) error { + switch { + case strings.HasPrefix(hash, "$2y$"), + strings.HasPrefix(hash, "$2a$"), + strings.HasPrefix(hash, "$2b$"): + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return fmt.Errorf("invalid credentials") + } + return nil + + case strings.HasPrefix(hash, "{SHA}"): + sum := sha1.Sum([]byte(password)) + expected := "{SHA}" + base64.StdEncoding.EncodeToString(sum[:]) + if subtle.ConstantTimeCompare([]byte(hash), []byte(expected)) == 1 { + return nil + } + return fmt.Errorf("invalid credentials") + + default: + return fmt.Errorf("unsupported htpasswd hash format (use bcrypt: htpasswd -B file user)") + } +} diff --git a/kerberos.go b/kerberos.go new file mode 100644 index 0000000..ab690cd --- /dev/null +++ b/kerberos.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/http/httptest" + "strings" + + goidentity "github.com/jcmturner/goidentity/v6" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/service" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +// KerberosAuth validates Kerberos SPNEGO tokens using a service keytab. +// +// The client must obtain a service ticket for the SPN (default: HTTP/@REALM) +// and present it via the standard "Authorization: Negotiate " header. +// Any GSSAPI-aware client works: curl --negotiate, kinit + curl, python-requests-gssapi, etc. +type KerberosAuth struct { + kt *keytab.Keytab + spn string // e.g. "HTTP/k3s.example.com" — empty = auto-select from ticket SName +} + +// NewKerberosAuth loads the keytab from keytabPath. +// spn is written into the service settings so gokrb5 validates the correct principal; +// pass an empty string to accept any principal present in the keytab. +func NewKerberosAuth(keytabPath, spn string) (*KerberosAuth, error) { + kt, err := keytab.Load(keytabPath) + if err != nil { + return nil, fmt.Errorf("loading keytab %s: %w", keytabPath, err) + } + return &KerberosAuth{kt: kt, spn: spn}, nil +} + +// Authenticate validates the SPNEGO token from the request's Authorization header. +// On success it returns the authenticated username (principal primary, without @REALM). +// On failure it sets a WWW-Authenticate: Negotiate header on w (for mutual-auth +// continuation tokens) and returns an error; the caller is responsible for the 401. +func (k *KerberosAuth) Authenticate(w http.ResponseWriter, r *http.Request) (string, error) { + if !strings.HasPrefix(r.Header.Get("Authorization"), "Negotiate ") { + return "", fmt.Errorf("no Negotiate token in request") + } + + // We use gokrb5's SPNEGO middleware but call it synchronously via a + // closure — ServeHTTP is not goroutine-concurrent so the captured + // variables are safe to read after the call returns. + var ( + authed bool + username string + ) + inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + id := goidentity.FromHTTPRequestContext(r) + if id != nil && id.Authenticated() { + authed = true + username = id.UserName() + } + }) + + var opts []func(*service.Settings) + if k.spn != "" { + opts = append(opts, service.KeytabPrincipal(k.spn)) + } + opts = append(opts, service.DecodePAC(false)) + opts = append(opts, service.Logger(log.Default())) + + // Route through a recorder so we can capture the WWW-Authenticate + // continuation token (mutual authentication) without writing it yet. + rec := httptest.NewRecorder() + spnego.SPNEGOKRB5Authenticate(inner, k.kt, opts...).ServeHTTP(rec, r) + + if !authed { + // Forward any Negotiate continuation token from the middleware. + if v := rec.Header().Get("WWW-Authenticate"); v != "" { + w.Header().Set("WWW-Authenticate", v) + } + return "", fmt.Errorf("kerberos authentication failed") + } + return username, nil +} diff --git a/ldap.go b/ldap.go new file mode 100644 index 0000000..3dc3d22 --- /dev/null +++ b/ldap.go @@ -0,0 +1,276 @@ +package main + +import ( + "crypto/tls" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "github.com/go-ldap/ldap/v3" +) + +// LDAPAuth authenticates users against an LDAP directory discovered via DNS SRV. +type LDAPAuth struct { + domain string + host string + port int + useTLS bool // true = LDAPS (TLS from the start); false = STARTTLS on port 389 + bindDN string // empty = anonymous bind + bindPassword string +} + +// NewLDAPAuth discovers the LDAP server for domain via the standard _ldap._tcp +// DNS SRV record. The connection mode is derived from the advertised port: +// port 389 uses STARTTLS; anything else (typically 636) uses LDAPS. +// _ldaps._tcp is not an IANA-registered SRV type and is not consulted. +// bindDN and bindPassword are used for the search bind; both empty = anonymous. +func NewLDAPAuth(domain, bindDN, bindPassword string) (*LDAPAuth, error) { + host, port, err := discoverLDAP(domain) + if err != nil { + return nil, err + } + return &LDAPAuth{ + domain: domain, + host: host, + port: port, + useTLS: port != 389, + bindDN: bindDN, + bindPassword: bindPassword, + }, nil +} + +// NewLDAPAuthFromURI creates an LDAPAuth from an explicit URI instead of DNS +// SRV discovery. Supported schemes: ldap:// (STARTTLS) and ldaps:// (TLS). +// Port defaults to 389 for ldap:// and 636 for ldaps:// if not specified. +// domain is still required for base-DN derivation and UPN construction. +// bindDN and bindPassword are used for the search bind; both empty = anonymous. +func NewLDAPAuthFromURI(rawURI, domain, bindDN, bindPassword string) (*LDAPAuth, error) { + u, err := url.Parse(rawURI) + if err != nil { + return nil, fmt.Errorf("invalid LDAP URI %q: %w", rawURI, err) + } + var useTLS bool + var defaultPort int + switch strings.ToLower(u.Scheme) { + case "ldap": + useTLS = false + defaultPort = 389 + case "ldaps": + useTLS = true + defaultPort = 636 + default: + return nil, fmt.Errorf("unsupported LDAP URI scheme %q (want ldap:// or ldaps://)", u.Scheme) + } + host := u.Hostname() + port := defaultPort + if ps := u.Port(); ps != "" { + port, err = strconv.Atoi(ps) + if err != nil { + return nil, fmt.Errorf("invalid port in LDAP URI %q: %w", rawURI, err) + } + } + return &LDAPAuth{ + domain: domain, + host: host, + port: port, + useTLS: useTLS, + bindDN: bindDN, + bindPassword: bindPassword, + }, nil +} + +func discoverLDAP(domain string) (host string, port int, err error) { + _, addrs, err := net.LookupSRV("ldap", "tcp", domain) + if err != nil || len(addrs) == 0 { + return "", 0, fmt.Errorf("no _ldap._tcp SRV records found for %s", domain) + } + return strings.TrimSuffix(addrs[0].Target, "."), int(addrs[0].Port), nil +} + +func (a *LDAPAuth) connect() (*ldap.Conn, error) { + addr := fmt.Sprintf("%s:%d", a.host, a.port) + tlsConfig := &tls.Config{ServerName: a.host} + if a.useTLS { + dbg.Printf("LDAP: dialing TLS %s", addr) + return ldap.DialTLS("tcp", addr, tlsConfig) + } + dbg.Printf("LDAP: dialing %s + STARTTLS", addr) + conn, err := ldap.Dial("tcp", addr) + if err != nil { + return nil, err + } + if err := conn.StartTLS(tlsConfig); err != nil { + conn.Close() + return nil, fmt.Errorf("STARTTLS: %w", err) + } + return conn, nil +} + +// searchBind binds with the configured service account, or anonymously if no +// bind DN is configured. +func (a *LDAPAuth) searchBind(conn *ldap.Conn) error { + if a.bindDN == "" { + return conn.UnauthenticatedBind("") + } + return conn.Bind(a.bindDN, a.bindPassword) +} + +// findUserDN searches for the user entry and returns its full DN. +func (a *LDAPAuth) findUserDN(conn *ldap.Conn, username string) (string, error) { + search := ldap.NewSearchRequest( + domainToBaseDN(a.domain), + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 1, 10, false, + fmt.Sprintf("(|(sAMAccountName=%s)(uid=%s))", + ldap.EscapeFilter(username), + ldap.EscapeFilter(username)), + []string{"dn"}, + nil, + ) + result, err := conn.Search(search) + if err != nil || len(result.Entries) == 0 { + return "", fmt.Errorf("user not found") + } + return result.Entries[0].DN, nil +} + +// Authenticate verifies username/password against LDAP and returns the user's +// group CNs (used as Kubernetes RBAC groups via the certificate's Organisation field). +// Returns a generic error on bad credentials to avoid user-enumeration. +// +// Flow: search bind → find user DN → user bind (verify password) → search bind → group lookup. +func (a *LDAPAuth) Authenticate(username, password string) (groups []string, err error) { + if username == "" || password == "" { + return nil, fmt.Errorf("username and password required") + } + + conn, err := a.connect() + if err != nil { + return nil, fmt.Errorf("LDAP connect: %w", err) + } + defer conn.Close() + + // Bind as service account (or anonymously) to locate the user's DN. + if err := a.searchBind(conn); err != nil { + return nil, fmt.Errorf("LDAP search bind failed: %w", err) + } + + dbg.Printf("LDAP: searching for user %q", username) + userDN, err := a.findUserDN(conn, username) + if err != nil { + return nil, fmt.Errorf("invalid credentials") + } + + // Verify the password by binding as the user. + dbg.Printf("LDAP: binding as %s", userDN) + if err := conn.Bind(userDN, password); err != nil { + return nil, fmt.Errorf("invalid credentials") + } + dbg.Printf("LDAP: bind OK for %s", userDN) + + // Re-bind as service account for group lookup — the user may lack read access. + if err := a.searchBind(conn); err != nil { + dbg.Printf("LDAP: re-bind for group lookup failed: %v — skipping groups", err) + return nil, nil // auth succeeded; group lookup is best-effort + } + + groups = a.lookupGroups(conn, username, userDN) + dbg.Printf("LDAP: groups for %s: %v", username, groups) + return groups, nil +} + +// lookupGroups searches for group memberships using two strategies so it works +// with both Active Directory (memberOf attribute) and POSIX/OpenLDAP layouts +// (groupOfNames / posixGroup with member or memberUid attributes). +func (a *LDAPAuth) lookupGroups(conn *ldap.Conn, username, userDN string) []string { + baseDN := domainToBaseDN(a.domain) + var groups []string + + // AD-style: memberOf attribute on the user's own entry. + memberOfSearch := ldap.NewSearchRequest( + userDN, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, + 1, 10, false, + "(objectClass=*)", + []string{"memberOf"}, + nil, + ) + if result, err := conn.Search(memberOfSearch); err == nil && len(result.Entries) > 0 { + for _, groupDN := range result.Entries[0].GetAttributeValues("memberOf") { + if cn := cnFromDN(groupDN); cn != "" { + groups = append(groups, cn) + } + } + } + + // POSIX/OpenLDAP style: search for groups that list this user as a member. + groupSearch := ldap.NewSearchRequest( + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, 10, false, + fmt.Sprintf("(|(member=%s)(memberUid=%s))", + ldap.EscapeFilter(userDN), + ldap.EscapeFilter(username)), + []string{"cn"}, + nil, + ) + if groupResult, err := conn.Search(groupSearch); err == nil { + for _, entry := range groupResult.Entries { + if cn := entry.GetAttributeValue("cn"); cn != "" && !containsStr(groups, cn) { + groups = append(groups, cn) + } + } + } + + return groups +} + +// LookupGroups searches for the user's groups using the configured bind credentials +// (or anonymously). Used after Kerberos authentication to populate Kubernetes RBAC +// group memberships. +func (a *LDAPAuth) LookupGroups(username string) []string { + conn, err := a.connect() + if err != nil { + return nil + } + defer conn.Close() + if err := a.searchBind(conn); err != nil { + return nil + } + userDN, err := a.findUserDN(conn, username) + if err != nil { + return nil + } + return a.lookupGroups(conn, username, userDN) +} + +// domainToBaseDN converts "example.com" to "dc=example,dc=com". +func domainToBaseDN(domain string) string { + parts := strings.Split(domain, ".") + dcs := make([]string, len(parts)) + for i, p := range parts { + dcs[i] = "dc=" + p + } + return strings.Join(dcs, ",") +} + +// cnFromDN extracts the CN value from the first CN= component of an LDAP DN. +func cnFromDN(dn string) string { + for _, part := range strings.Split(dn, ",") { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(part)), "cn=") { + return strings.TrimSpace(part)[3:] + } + } + return "" +} + +func containsStr(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..67974c2 --- /dev/null +++ b/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +// dbg is a package-level debug logger, active only when --debug is passed. +// Defaults to discarding output; main() points it at stderr when --debug is set. +var dbg = log.New(io.Discard, "[ward] ", 0) + +func main() { + // ── Subcommand dispatch ─────────────────────────────────────────────────── + // "ward credential ..." acts as a kubectl exec credential plugin. + // Handle it before parsing server flags so flag.Parse() doesn't choke on + // credential-specific flags. + if len(os.Args) > 1 && os.Args[1] == "credential" { + os.Exit(runCredential(os.Args[2:])) + } + + // ── Server flags ────────────────────────────────────────────────────────── + fqdn := detectFQDN() + domain := domainPart(fqdn) + realm := strings.ToUpper(domain) + + var ( + addr = flag.String("addr", ":8443", "Listen address") + tlsCert = flag.String("tls-cert", fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", fqdn), "TLS certificate file (Let's Encrypt fullchain)") + tlsKey = flag.String("tls-key", fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", fqdn), "TLS private key file") + k3sServer = flag.String("k3s-server", "", "k3s API server URL written into returned kubeconfigs (required, e.g. https://k3s.example.com:6443)") + serverCACert = flag.String("server-ca-cert", "/var/lib/rancher/k3s/server/tls/server-ca.crt", "k3s server CA certificate (embedded in returned kubeconfig)") + clientCACert = flag.String("client-ca-cert", "/var/lib/rancher/k3s/server/tls/client-ca.crt", "k3s client CA certificate (signs user certs)") + clientCAKey = flag.String("client-ca-key", "/var/lib/rancher/k3s/server/tls/client-ca.key", "k3s client CA key") + certDuration = flag.Duration("cert-duration", 24*time.Hour, "Validity period of generated client certificates") + clusterName = flag.String("cluster-name", firstLabel(domain), "Cluster/context name written into generated kubeconfigs") + + // LDAP (opt-in; auto-discovered via DNS SRV unless --ldap-uri is given) + ldapDomain = flag.String("domain", domain, "Domain for LDAP SRV discovery and Kerberos realm derivation") + ldapOn = flag.Bool("ldap", false, "Enable LDAP authentication (auto-discovered via DNS SRV)") + ldapURI = flag.String("ldap-uri", "", "LDAP server URI, e.g. ldaps://ldap.example.com (implies --ldap; overrides DNS SRV)") + ldapBindDN = flag.String("ldap-bind-dn", os.Getenv("WARD_LDAP_BIND_DN"), "LDAP bind DN for search (default: anonymous; env: WARD_LDAP_BIND_DN)") + ldapBindPassword = flag.String("ldap-bind-password", os.Getenv("WARD_LDAP_BIND_PASSWORD"), "LDAP bind password (env: WARD_LDAP_BIND_PASSWORD; caution: visible in ps output)") + + // Kerberos SPNEGO (opt-in) + kerberosOn = flag.Bool("kerberos", false, "Enable Kerberos SPNEGO authentication (Authorization: Negotiate)") + keytabPath = flag.String("keytab", "/etc/krb5.keytab", "Kerberos service keytab path") + spn = flag.String("spn", "HTTP/"+fqdn, fmt.Sprintf("Kerberos service principal name (SPN)\n\t\t\t(default realm %s — create with: kadmin: addprinc -randkey HTTP/%s@%s)", realm, fqdn, realm)) + + // htpasswd (opt-in) + htpasswdPath = flag.String("htpasswd", "", "Path to an Apache-compatible htpasswd file (bcrypt recommended: htpasswd -B -c file user)") + + // Debug logging + debugFlag = flag.Bool("debug", os.Getenv("WARD_DEBUG") != "", "Enable verbose debug logging (also: $WARD_DEBUG=1)") + ) + flag.Parse() + + if *debugFlag { + dbg = log.New(os.Stderr, "[ward] ", log.Ltime) + dbg.Print("debug logging enabled") + } + + if *k3sServer == "" { + log.Fatal("--k3s-server is required (e.g. https://k3s.example.com:6443)") + } + + // ── LDAP ────────────────────────────────────────────────────────────────── + var ldapAuth *LDAPAuth + if *ldapURI != "" || *ldapOn { + var la *LDAPAuth + var err error + if *ldapURI != "" { + la, err = NewLDAPAuthFromURI(*ldapURI, *ldapDomain, *ldapBindDN, *ldapBindPassword) + } else { + la, err = NewLDAPAuth(*ldapDomain, *ldapBindDN, *ldapBindPassword) + } + if err != nil { + log.Fatalf("LDAP: %v", err) + } + ldapAuth = la + anon := *ldapBindDN == "" + log.Printf("LDAP: %s:%d (TLS=%v) domain=%s anon=%v", la.host, la.port, la.useTLS, *ldapDomain, anon) + } + + if ldapAuth == nil && !*kerberosOn && *htpasswdPath == "" { + log.Fatal("no authentication providers configured: use at least one of --ldap, --ldap-uri, --kerberos, or --htpasswd") + } + + // ── Kerberos ────────────────────────────────────────────────────────────── + var krbAuth *KerberosAuth + if *kerberosOn { + ka, err := NewKerberosAuth(*keytabPath, *spn) + if err != nil { + log.Fatalf("Kerberos: %v", err) + } + krbAuth = ka + log.Printf("Kerberos: keytab=%s SPN=%s realm=%s", *keytabPath, *spn, realm) + } + + // ── htpasswd ────────────────────────────────────────────────────────────── + var htpasswdAuth *HtpasswdAuth + if *htpasswdPath != "" { + ha, err := NewHtpasswdAuth(*htpasswdPath) + if err != nil { + log.Fatalf("htpasswd: %v", err) + } + htpasswdAuth = ha + log.Printf("htpasswd: %s (%d entries)", *htpasswdPath, ha.Len()) + + // SIGHUP reloads the file — no restart needed to add/remove users. + sighup := make(chan os.Signal, 1) + signal.Notify(sighup, syscall.SIGHUP) + go func() { + for range sighup { + if err := htpasswdAuth.Reload(); err != nil { + log.Printf("SIGHUP: htpasswd reload failed: %v", err) + } else { + log.Printf("SIGHUP: htpasswd reloaded (%d entries)", htpasswdAuth.Len()) + } + } + }() + } + + // ── Handler ─────────────────────────────────────────────────────────────── + h, err := NewHandler(ldapAuth, krbAuth, htpasswdAuth, &CertConfig{ + ServerURL: *k3sServer, + ServerCACert: *serverCACert, + ClientCACert: *clientCACert, + ClientCAKey: *clientCAKey, + Duration: *certDuration, + ClusterName: *clusterName, + }) + if err != nil { + log.Fatalf("handler: %v", err) + } + + // ── TLS ─────────────────────────────────────────────────────────────────── + // Verify the cert is readable at startup; fail loudly rather than on first connection. + if _, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey); err != nil { + log.Fatalf("TLS: loading certificate: %v", err) + } + // Reload from disk on every handshake — Let's Encrypt renewals are picked up + // automatically without restarting the service. + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(*tlsCert, *tlsKey) + if err != nil { + return nil, fmt.Errorf("reloading TLS cert: %w", err) + } + return &cert, nil + }, + } + + ln, err := tls.Listen("tcp", *addr, tlsConfig) + if err != nil { + log.Fatalf("listen %s: %v", *addr, err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/kubeconfig", h.ServeHTTP) + mux.HandleFunc("/credential", h.ServeCredential) + mux.HandleFunc("/bootstrap", h.ServeBootstrap) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "ward — Kubernetes credential gateway\n\n"+ + " GET /bootstrap kubeconfig with exec plugin pre-wired (no auth required)\n"+ + " GET /credential ExecCredential JSON for kubectl exec plugin\n"+ + " GET /kubeconfig kubeconfig with embedded client certificate\n") + }) + + srv := &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + log.Printf("ward listening on %s (cert-duration=%s)", *addr, *certDuration) + log.Fatal(srv.Serve(ln)) +} + +// detectFQDN returns the fully-qualified domain name of the local host, +// falling back to the short hostname if DNS resolution fails. +func detectFQDN() string { + hostname, err := os.Hostname() + if err != nil { + return "localhost" + } + if strings.Contains(hostname, ".") { + return hostname + } + addrs, err := net.LookupHost(hostname) + if err != nil || len(addrs) == 0 { + return hostname + } + names, err := net.LookupAddr(addrs[0]) + if err != nil || len(names) == 0 { + return hostname + } + return strings.TrimSuffix(names[0], ".") +} + +// domainPart strips the first label from a FQDN. +// "host.example.com" → "example.com" +func domainPart(fqdn string) string { + if idx := strings.IndexByte(fqdn, '.'); idx >= 0 { + return fqdn[idx+1:] + } + return fqdn +} + +// firstLabel returns the first dot-separated label of a domain name. +// "example.com" → "example" +func firstLabel(domain string) string { + if idx := strings.IndexByte(domain, '.'); idx >= 0 { + return domain[:idx] + } + return domain +} diff --git a/ward.service b/ward.service new file mode 100644 index 0000000..51bb866 --- /dev/null +++ b/ward.service @@ -0,0 +1,34 @@ +[Unit] +Description=ward — Kubernetes credential gateway (LDAP/Kerberos/htpasswd) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# Adjust or override in /etc/systemd/system/ward.service.d/local.conf +ExecStart=/usr/local/bin/ward \ + --k3s-server=https://YOUR_K3S_HOSTNAME:6443 \ + --addr=:8443 + +# SIGHUP reloads the htpasswd file without restarting. +ExecReload=/bin/kill -HUP $MAINPID + +Restart=on-failure +RestartSec=5s + +# Must run as root to read: +# /var/lib/rancher/k3s/server/tls/client-ca.key +# /etc/letsencrypt/live/*/privkey.pem +# /etc/krb5.keytab (if using Kerberos) +# Tighten by granting group read access to those files instead. +User=root + +NoNewPrivileges=yes +ProtectHome=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes + +[Install] +WantedBy=multi-user.target