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 }