diff --git a/cmd_credential.go b/cmd_credential.go index 6e1b8ae..5576e2f 100644 --- a/cmd_credential.go +++ b/cmd_credential.go @@ -3,7 +3,6 @@ package main import ( "crypto/sha256" "encoding/json" - "flag" "fmt" "io" "net/http" @@ -20,64 +19,6 @@ import ( "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" @@ -200,12 +141,8 @@ func credParse(body []byte) (*ExecCredential, error) { 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 +func credPrint(ec *ExecCredential) error { + return json.NewEncoder(os.Stdout).Encode(ec) } // ── Local credential cache ───────────────────────────────────────────────────── diff --git a/go.mod b/go.mod index 95c5ae8..1af549b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 + github.com/spf13/cobra v1.10.2 golang.org/x/crypto v0.21.0 golang.org/x/term v0.18.0 ) @@ -15,10 +16,12 @@ require ( 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/inconshreveable/mousetrap v1.1.0 // 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 + github.com/spf13/pflag v1.0.9 // 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 index 6446afd..0523aee 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= @@ -18,6 +19,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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= @@ -32,6 +35,11 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= @@ -41,6 +49,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO 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= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= diff --git a/main.go b/main.go index 67974c2..cdb723d 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "crypto/tls" - "flag" "fmt" "io" "log" @@ -13,183 +12,278 @@ import ( "strings" "syscall" "time" + + "github.com/spf13/cobra" ) // 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. +// Defaults to discarding output; serve command 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:])) + root := &cobra.Command{ + Use: "ward", + Short: "Kubernetes credential gateway", + SilenceUsage: true, + SilenceErrors: true, } - // ── Server flags ────────────────────────────────────────────────────────── + root.AddCommand(newServeCmd()) + root.AddCommand(newCredentialCmd()) + + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func newServeCmd() *cobra.Command { 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") + addr string + tlsCert string + tlsKey string + k3sServer string + serverCACert string + clientCACert string + clientCAKey string + certDuration time.Duration + clusterName string - // 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)") + ldapDomain string + ldapOn bool + ldapURI string + ldapBindDN string + ldapBindPassword string - // 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)) + kerberosOn bool + keytabPath string + spn string - // 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)") + htpasswdPath string + debugFlag bool ) - flag.Parse() - if *debugFlag { - dbg = log.New(os.Stderr, "[ward] ", log.Ltime) - dbg.Print("debug logging enabled") - } + cmd := &cobra.Command{ + Use: "serve", + Short: "Run the ward authentication server", + RunE: func(cmd *cobra.Command, args []string) error { + 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)") - } + if k3sServer == "" { + return fmt.Errorf("--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) + // ── LDAP ────────────────────────────────────────────────────────────────── + var ldapAuth *LDAPAuth + if ldapURI != "" || ldapOn { + var la *LDAPAuth + var err error + if ldapURI != "" { + la, err = NewLDAPAuthFromURI(ldapURI, ldapDomain, ldapBindDN, ldapBindPassword) } else { - log.Printf("SIGHUP: htpasswd reloaded (%d entries)", htpasswdAuth.Len()) + la, err = NewLDAPAuth(ldapDomain, ldapBindDN, ldapBindPassword) } + if err != nil { + return fmt.Errorf("LDAP: %w", err) + } + ldapAuth = la + anon := ldapBindDN == "" + log.Printf("LDAP: %s:%d (TLS=%v) domain=%s anon=%v", la.host, la.port, la.useTLS, ldapDomain, anon) } - }() - } - // ── 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) - } + if ldapAuth == nil && !kerberosOn && htpasswdPath == "" { + return fmt.Errorf("no authentication providers configured: use at least one of --ldap, --ldap-uri, --kerberos, or --htpasswd") + } - // ── 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) + // ── Kerberos ────────────────────────────────────────────────────────────── + var krbAuth *KerberosAuth + if kerberosOn { + ka, err := NewKerberosAuth(keytabPath, spn) + if err != nil { + return fmt.Errorf("Kerberos: %w", 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 { + return fmt.Errorf("htpasswd: %w", err) + } + htpasswdAuth = ha + log.Printf("htpasswd: %s (%d entries)", htpasswdPath, ha.Len()) + + 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 { - return nil, fmt.Errorf("reloading TLS cert: %w", err) + return fmt.Errorf("handler: %w", err) } - return &cert, nil + + // ── TLS ─────────────────────────────────────────────────────────────────── + if _, err := tls.LoadX509KeyPair(tlsCert, tlsKey); err != nil { + return fmt.Errorf("TLS: loading certificate: %w", err) + } + 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 { + return fmt.Errorf("listen %s: %w", 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) + return srv.Serve(ln) }, } - ln, err := tls.Listen("tcp", *addr, tlsConfig) - if err != nil { - log.Fatalf("listen %s: %v", *addr, err) + cmd.Flags().StringVar(&addr, "addr", ":8443", "Listen address") + cmd.Flags().StringVar(&tlsCert, "tls-cert", fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", fqdn), "TLS certificate file (Let's Encrypt fullchain)") + cmd.Flags().StringVar(&tlsKey, "tls-key", fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", fqdn), "TLS private key file") + cmd.Flags().StringVar(&k3sServer, "k3s-server", "", "k3s API server URL written into returned kubeconfigs (required, e.g. https://k3s.example.com:6443)") + cmd.Flags().StringVar(&serverCACert, "server-ca-cert", "/var/lib/rancher/k3s/server/tls/server-ca.crt", "k3s server CA certificate (embedded in returned kubeconfig)") + cmd.Flags().StringVar(&clientCACert, "client-ca-cert", "/var/lib/rancher/k3s/server/tls/client-ca.crt", "k3s client CA certificate (signs user certs)") + cmd.Flags().StringVar(&clientCAKey, "client-ca-key", "/var/lib/rancher/k3s/server/tls/client-ca.key", "k3s client CA key") + cmd.Flags().DurationVar(&certDuration, "cert-duration", 24*time.Hour, "Validity period of generated client certificates") + cmd.Flags().StringVar(&clusterName, "cluster-name", firstLabel(domain), "Cluster/context name written into generated kubeconfigs") + + cmd.Flags().StringVar(&ldapDomain, "domain", domain, "Domain for LDAP SRV discovery and Kerberos realm derivation") + cmd.Flags().BoolVar(&ldapOn, "ldap", false, "Enable LDAP authentication (auto-discovered via DNS SRV)") + cmd.Flags().StringVar(&ldapURI, "ldap-uri", "", "LDAP server URI, e.g. ldaps://ldap.example.com (implies --ldap; overrides DNS SRV)") + cmd.Flags().StringVar(&ldapBindDN, "ldap-bind-dn", os.Getenv("WARD_LDAP_BIND_DN"), "LDAP bind DN for search (default: anonymous; env: WARD_LDAP_BIND_DN)") + cmd.Flags().StringVar(&ldapBindPassword, "ldap-bind-password", os.Getenv("WARD_LDAP_BIND_PASSWORD"), "LDAP bind password (env: WARD_LDAP_BIND_PASSWORD; caution: visible in ps output)") + + cmd.Flags().BoolVar(&kerberosOn, "kerberos", false, "Enable Kerberos SPNEGO authentication (Authorization: Negotiate)") + cmd.Flags().StringVar(&keytabPath, "keytab", "/etc/krb5.keytab", "Kerberos service keytab path") + cmd.Flags().StringVar(&spn, "spn", "HTTP/"+fqdn, fmt.Sprintf("Kerberos service principal name (SPN) (default realm %s — create with: kadmin: addprinc -randkey HTTP/%s@%s)", realm, fqdn, realm)) + + cmd.Flags().StringVar(&htpasswdPath, "htpasswd", "", "Path to an Apache-compatible htpasswd file (bcrypt recommended: htpasswd -B -c file user)") + + cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "Enable verbose debug logging (also: $WARD_DEBUG=1)") + + return cmd +} + +func newCredentialCmd() *cobra.Command { + var ( + server string + username string + noKerberos bool + noCache bool + debugFlag bool + ) + + cmd := &cobra.Command{ + Use: "credential", + Short: "kubectl exec credential plugin — fetch an ExecCredential from ward", + Long: `Acts as a kubectl exec credential plugin. 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`, + RunE: func(cmd *cobra.Command, args []string) error { + if server == "" { + return fmt.Errorf("--server is required") + } + + logf := func(format string, a ...interface{}) { + if debugFlag { + fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...) + } + } + + if !noCache { + if ec, ok := credReadCache(server, logf); ok { + return credPrint(ec) + } + } + + ec, err := credFetch(server, username, noKerberos, logf) + if err != nil { + return err + } + + if !noCache { + credWriteCache(server, ec, logf) + } + return credPrint(ec) + }, } - 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") - }) + cmd.Flags().StringVar(&server, "server", "", "ward server URL (required)") + cmd.Flags().StringVar(&username, "username", "", "username for Basic auth fallback (default: $USER)") + cmd.Flags().BoolVar(&noKerberos, "no-kerberos", false, "skip Kerberos; always use Basic auth") + cmd.Flags().BoolVar(&noCache, "no-cache", false, "bypass local cache; always fetch a fresh credential") + cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr (also: $WARD_DEBUG=1)") - 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)) + return cmd } // detectFQDN returns the fully-qualified domain name of the local host,