This commit is contained in:
240
cmd/client.go
Normal file
240
cmd/client.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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"
|
||||
|
||||
"git.shee.sh/james/ward/pkg/handler"
|
||||
)
|
||||
|
||||
func credFetch(server string, noKerberos bool, logf func(string, ...any)) (*handler.ExecCredential, error) {
|
||||
url := strings.TrimRight(server, "/") + "/credential"
|
||||
|
||||
// ── Kerberos SPNEGO ───────────────────────────────────────────────────────
|
||||
if !noKerberos {
|
||||
body, err := credFetchKerberos(url, logf)
|
||||
if err != nil {
|
||||
logf("Kerberos: %v", err)
|
||||
} else {
|
||||
return credParse(body)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no valid credential — run 'ward login --server %s'", server)
|
||||
}
|
||||
|
||||
func credFetchKerberos(url string, logf func(string, ...any)) ([]byte, error) {
|
||||
krb5cfgPath := krb5ConfigPath()
|
||||
logf("Kerberos: loading config from %s", krb5cfgPath)
|
||||
var krb5cfg *config.Config
|
||||
if _, statErr := os.Stat(krb5cfgPath); os.IsNotExist(statErr) && os.Getenv("KRB5_CONFIG") == "" {
|
||||
logf("Kerberos: %s not found, using default config (KDC discovery via DNS)", krb5cfgPath)
|
||||
krb5cfg = config.New()
|
||||
krb5cfg.LibDefaults.DNSLookupKDC = true
|
||||
} else {
|
||||
var err error
|
||||
krb5cfg, err = config.Load(krb5cfgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading krb5 config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ccPath, err := ccachePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logf("Kerberos: loading credential cache from %s", ccPath)
|
||||
ccache, err := credentials.LoadCCache(ccPath)
|
||||
if err != nil {
|
||||
hint := "run 'kinit'"
|
||||
if runtime.GOOS == "darwin" && os.Getenv("KRB5CCNAME") == "" {
|
||||
hint = fmt.Sprintf("on macOS, run: kinit -c /tmp/krb5cc_%d", os.Getuid())
|
||||
}
|
||||
return nil, fmt.Errorf("no credential cache (%w) — %s", err, hint)
|
||||
}
|
||||
|
||||
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, ...any)) ([]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) (*handler.ExecCredential, error) {
|
||||
var ec handler.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 *handler.ExecCredential) error {
|
||||
return json.NewEncoder(os.Stdout).Encode(ec)
|
||||
}
|
||||
|
||||
// ── Local credential cache ─────────────────────────────────────────────────────
|
||||
// Caches ExecCredential JSON in ~/.cache/ward/<sha256(serverURL)>.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, ...any)) (*handler.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 handler.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 *handler.ExecCredential, logf func(string, ...any)) {
|
||||
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 {
|
||||
return cmp.Or(os.Getenv("KRB5_CONFIG"), "/etc/krb5.conf")
|
||||
}
|
||||
|
||||
// ccachePath returns the path to the active Kerberos credential cache, or an
|
||||
// error if $KRB5CCNAME names a non-file cache type that gokrb5 cannot read.
|
||||
//
|
||||
// On macOS, kinit defaults to API: caches. Work around it with:
|
||||
//
|
||||
// kinit -c /tmp/krb5cc_$(id -u)
|
||||
func ccachePath() (string, error) {
|
||||
if v := os.Getenv("KRB5CCNAME"); v != "" {
|
||||
for _, prefix := range []string{"API:", "KEYRING:", "DIR:", "KCM:"} {
|
||||
if strings.HasPrefix(v, prefix) {
|
||||
return "", fmt.Errorf(
|
||||
"credential cache type %s is not supported (gokrb5 requires a file-based cache)\n"+
|
||||
"hint: re-run kinit with: kinit -c /tmp/krb5cc_%d",
|
||||
prefix, os.Getuid())
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(v, "FILE:"), nil
|
||||
}
|
||||
return fmt.Sprintf("/tmp/krb5cc_%d", os.Getuid()), nil
|
||||
}
|
||||
68
cmd/credential.go
Normal file
68
cmd/credential.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCredentialCmd() *cobra.Command {
|
||||
var (
|
||||
server string
|
||||
noKerberos bool
|
||||
noCache bool
|
||||
debugFlag bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "credential",
|
||||
Short: "kubectl exec credential plugin — serve a cached ExecCredential to kubectl",
|
||||
Long: `Acts as a kubectl exec credential plugin. Returns a cached ExecCredential
|
||||
JSON to kubectl. On a cache miss, silently attempts Kerberos SPNEGO; if that
|
||||
also fails, exits with an error directing the user to run 'ward login'.
|
||||
|
||||
Run 'ward login' once to authenticate and populate the cache. After that,
|
||||
kubectl works silently until the credential expires.
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
server = normalizeServer(server)
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
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, noKerberos, logf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !noCache {
|
||||
credWriteCache(server, ec, logf)
|
||||
}
|
||||
return credPrint(ec)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&server, "server", "", "ward server URL (required)")
|
||||
cmd.Flags().BoolVar(&noKerberos, "no-kerberos", false, "skip Kerberos SPNEGO")
|
||||
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)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
139
cmd/login.go
Normal file
139
cmd/login.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.shee.sh/james/ward/pkg/handler"
|
||||
"git.shee.sh/james/ward/pkg/kubeconfig"
|
||||
)
|
||||
|
||||
func newLoginCmd() *cobra.Command {
|
||||
var (
|
||||
server string
|
||||
username string
|
||||
contextName string
|
||||
noKerberos bool
|
||||
noSetContext bool
|
||||
debugFlag bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate to ward and configure kubectl",
|
||||
Long: `Authenticates to the ward server, caches a long-lived credential, and
|
||||
updates ~/.kube/config with the cluster, user (exec plugin), and context.
|
||||
After login, kubectl works silently until the credential expires, at
|
||||
which point you run 'ward login' again.
|
||||
|
||||
Authentication priority:
|
||||
1. Kerberos SPNEGO using the active credential cache (from kinit)
|
||||
2. Password prompt`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if server == "" {
|
||||
return fmt.Errorf("--server is required")
|
||||
}
|
||||
|
||||
server = normalizeServer(server)
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
if debugFlag {
|
||||
fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...)
|
||||
}
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
username = os.Getenv("USER")
|
||||
}
|
||||
|
||||
ec, err := loginFetch(server, username, noKerberos, logf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
credWriteCache(server, ec, logf)
|
||||
|
||||
bootstrap, err := fetchBootstrap(server, username, logf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bootstrap kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
if contextName != "" {
|
||||
kubeconfig.RenameContext(bootstrap, contextName)
|
||||
}
|
||||
|
||||
contextApplied := bootstrap.CurrentContext
|
||||
if err := kubeconfig.Merge(bootstrap, !noSetContext); err != nil {
|
||||
return fmt.Errorf("updating kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "ward: logged in — context %q configured in %s\n",
|
||||
contextApplied, kubeconfig.FilePath())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&server, "server", "", "ward server URL (required)")
|
||||
cmd.Flags().StringVar(&username, "username", "", "username (default: $USER)")
|
||||
cmd.Flags().StringVar(&contextName, "context", "", "kubectl context/cluster name (overrides server default)")
|
||||
cmd.Flags().BoolVar(&noKerberos, "no-kerberos", false, "skip Kerberos; use password auth")
|
||||
cmd.Flags().BoolVar(&noSetContext, "no-set-context", false, "do not set as current-context")
|
||||
cmd.Flags().BoolVar(&debugFlag, "debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loginFetch(server, username string, noKerberos bool, logf func(string, ...any)) (*handler.ExecCredential, error) {
|
||||
loginURL := strings.TrimRight(server, "/") + "/credential?login=true"
|
||||
|
||||
if !noKerberos {
|
||||
body, err := credFetchKerberos(loginURL, logf)
|
||||
if err != nil {
|
||||
logf("Kerberos: %v — falling back to password auth", err)
|
||||
fmt.Fprintf(os.Stderr, "ward: Kerberos failed (%v); using password auth\nhint: run 'kinit' to avoid the password prompt next time\n", err)
|
||||
} else {
|
||||
return credParse(body)
|
||||
}
|
||||
}
|
||||
|
||||
password, err := promptPassword(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := credFetchBasic(loginURL, username, password, logf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return credParse(body)
|
||||
}
|
||||
|
||||
func fetchBootstrap(server, username string, logf func(string, ...any)) (*kubeconfig.KubeConfig, error) {
|
||||
bootstrapURL := strings.TrimRight(server, "/") + "/bootstrap?user=" + url.QueryEscape(username)
|
||||
logf("HTTP: GET %s", bootstrapURL)
|
||||
|
||||
resp, err := http.Get(bootstrapURL) //nolint:gosec // URL derived from user-supplied server flag
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("server returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var cfg kubeconfig.KubeConfig
|
||||
if err := yaml.Unmarshal(body, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing bootstrap kubeconfig: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
46
cmd/root.go
Normal file
46
cmd/root.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Execute builds the root command and runs it.
|
||||
func Execute() {
|
||||
root := newRootCmd()
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "ward",
|
||||
Short: "Kubernetes credential gateway",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
root.AddCommand(newServeCmd())
|
||||
root.AddCommand(newCredentialCmd())
|
||||
root.AddCommand(newLoginCmd())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// normalizeServer ensures server has an https:// scheme and a port.
|
||||
// Shared by the credential and login commands.
|
||||
func normalizeServer(server string) string {
|
||||
if !strings.Contains(server, "://") {
|
||||
server = "https://" + server
|
||||
}
|
||||
parts := strings.Split(server, ":")
|
||||
if len(parts) == 2 {
|
||||
server += ":8443"
|
||||
}
|
||||
return server
|
||||
}
|
||||
252
cmd/serve.go
Normal file
252
cmd/serve.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.shee.sh/james/ward/pkg/auth"
|
||||
"git.shee.sh/james/ward/pkg/cert"
|
||||
"git.shee.sh/james/ward/pkg/handler"
|
||||
)
|
||||
|
||||
func newServeCmd() *cobra.Command {
|
||||
fqdn := detectFQDN()
|
||||
domain := domainPart(fqdn)
|
||||
realm := strings.ToUpper(domain)
|
||||
|
||||
var (
|
||||
addr string
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
k3sServer string
|
||||
serverCACert string
|
||||
clientCACert string
|
||||
clientCAKey string
|
||||
certDuration time.Duration
|
||||
clusterName string
|
||||
|
||||
ldapDomain string
|
||||
ldapOn bool
|
||||
ldapURI string
|
||||
ldapBindDN string
|
||||
|
||||
loginCertDuration time.Duration
|
||||
|
||||
kerberosOn bool
|
||||
keytabPath string
|
||||
spn string
|
||||
|
||||
htpasswdPath string
|
||||
debugFlag bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Run the ward authentication server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dbg := log.New(io.Discard, "[ward] ", 0)
|
||||
if debugFlag {
|
||||
dbg = log.New(os.Stderr, "[ward] ", log.Ltime)
|
||||
dbg.Print("debug logging enabled")
|
||||
}
|
||||
|
||||
if k3sServer == "" {
|
||||
return fmt.Errorf("--k3s-server is required (e.g. https://k3s.example.com:6443)")
|
||||
}
|
||||
|
||||
// ── LDAP ──────────────────────────────────────────────────────────────────
|
||||
ldapBindPassword := os.Getenv("WARD_LDAP_BIND_PASSWORD")
|
||||
var ldapAuth *auth.LDAPAuth
|
||||
if ldapURI != "" || ldapOn {
|
||||
var la *auth.LDAPAuth
|
||||
var err error
|
||||
if ldapURI != "" {
|
||||
la, err = auth.NewLDAPAuthFromURI(ldapURI, ldapDomain, ldapBindDN, ldapBindPassword, dbg)
|
||||
} else {
|
||||
la, err = auth.NewLDAPAuth(ldapDomain, ldapBindDN, ldapBindPassword, dbg)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if ldapAuth == nil && !kerberosOn && htpasswdPath == "" {
|
||||
return fmt.Errorf("no authentication providers configured: use at least one of --ldap, --ldap-uri, --kerberos, or --htpasswd")
|
||||
}
|
||||
|
||||
// ── Kerberos ──────────────────────────────────────────────────────────────
|
||||
var krbAuth *auth.KerberosAuth
|
||||
if kerberosOn {
|
||||
ka, err := auth.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 *auth.HtpasswdAuth
|
||||
if htpasswdPath != "" {
|
||||
ha, err := auth.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 := handler.NewHandler(ldapAuth, krbAuth, htpasswdAuth, &cert.CertConfig{
|
||||
ServerURL: k3sServer,
|
||||
ServerCACert: serverCACert,
|
||||
ClientCACert: clientCACert,
|
||||
ClientCAKey: clientCAKey,
|
||||
Duration: certDuration,
|
||||
LoginDuration: loginCertDuration,
|
||||
ClusterName: clusterName,
|
||||
}, dbg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("handler: %w", err)
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
c, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reloading TLS cert: %w", err)
|
||||
}
|
||||
return &c, 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)
|
||||
},
|
||||
}
|
||||
|
||||
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().DurationVar(&loginCertDuration, "login-cert-duration", 168*time.Hour, "Validity period of certificates issued by 'ward login' (default 7 days)")
|
||||
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)")
|
||||
// LDAP bind password is read exclusively from $WARD_LDAP_BIND_PASSWORD to avoid
|
||||
// exposure in process listings.
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
40
cmd/tty.go
Normal file
40
cmd/tty.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func promptPassword(username string) (string, error) {
|
||||
terminal, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot open terminal for password prompt\nhint: run 'kinit' for Kerberos auth")
|
||||
}
|
||||
|
||||
oldState, err := term.MakeRaw(int(terminal.Fd()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("setting terminal raw mode: %w", err)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
term.Restore(int(terminal.Fd()), oldState)
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
fmt.Fprintf(terminal, "Password for %s: ", username)
|
||||
pw, err := term.ReadPassword(int(terminal.Fd()))
|
||||
fmt.Fprintf(terminal, "\r\n")
|
||||
signal.Stop(sigCh)
|
||||
term.Restore(int(terminal.Fd()), oldState)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading password: %w", err)
|
||||
}
|
||||
return string(pw), nil
|
||||
}
|
||||
Reference in New Issue
Block a user