Move server to a 'serve' command
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
3
go.mod
3
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
|
||||
)
|
||||
|
||||
9
go.sum
9
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=
|
||||
|
||||
384
main.go
384
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,
|
||||
|
||||
Reference in New Issue
Block a user