commit 061fdc205a9c61859d27ae77e3a13ddf8a01d09f Author: James McDonald Date: Mon Mar 2 15:19:32 2026 +0100 Import 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