This commit is contained in:
111
pkg/auth/htpasswd.go
Normal file
111
pkg/auth/htpasswd.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
81
pkg/auth/kerberos.go
Normal file
81
pkg/auth/kerberos.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
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/<fqdn>@REALM)
|
||||
// and present it via the standard "Authorization: Negotiate <base64>" 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
|
||||
}
|
||||
289
pkg/auth/ldap.go
Normal file
289
pkg/auth/ldap.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"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
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// 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, dbg *log.Logger) (*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,
|
||||
log: dbg,
|
||||
}, 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, dbg *log.Logger) (*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,
|
||||
log: dbg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Host returns the LDAP server hostname (used for logging).
|
||||
func (a *LDAPAuth) Host() string { return a.host }
|
||||
|
||||
// Port returns the LDAP server port (used for logging).
|
||||
func (a *LDAPAuth) Port() int { return a.port }
|
||||
|
||||
// UseTLS reports whether TLS is in use (used for logging).
|
||||
func (a *LDAPAuth) UseTLS() bool { return a.useTLS }
|
||||
|
||||
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 {
|
||||
a.log.Printf("LDAP: dialing TLS %s", addr)
|
||||
return ldap.DialTLS("tcp", addr, tlsConfig)
|
||||
}
|
||||
a.log.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)
|
||||
}
|
||||
|
||||
a.log.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.
|
||||
a.log.Printf("LDAP: binding as %s", userDN)
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
a.log.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 {
|
||||
a.log.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)
|
||||
a.log.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
|
||||
}
|
||||
263
pkg/cert/cert.go
Normal file
263
pkg/cert/cert.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package cert
|
||||
|
||||
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
|
||||
LoginDuration time.Duration // validity period for certs issued by 'ward login'
|
||||
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
|
||||
}
|
||||
|
||||
// Cfg returns the CertConfig (used by handler to read Duration/LoginDuration).
|
||||
func (g *KubeconfigGenerator) Cfg() *CertConfig { return g.cfg }
|
||||
|
||||
// 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, duration time.Duration) (*Credential, error) {
|
||||
certPEM, keyPEM, err := g.signClientCert(username, groups, duration)
|
||||
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, g.cfg.Duration)
|
||||
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, duration time.Duration) (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(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=<wardURL>" 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
|
||||
}
|
||||
240
pkg/handler/handler.go
Normal file
240
pkg/handler/handler.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.shee.sh/james/ward/pkg/auth"
|
||||
"git.shee.sh/james/ward/pkg/cert"
|
||||
)
|
||||
|
||||
// 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 *auth.LDAPAuth
|
||||
krb *auth.KerberosAuth
|
||||
htpasswd *auth.HtpasswdAuth
|
||||
gen *cert.KubeconfigGenerator
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// NewHandler validates that at least one auth provider is configured, then
|
||||
// loads the k3s CA files and returns a ready Handler.
|
||||
func NewHandler(ldapAuth *auth.LDAPAuth, krbAuth *auth.KerberosAuth, htpasswdAuth *auth.HtpasswdAuth, cfg *cert.CertConfig, dbg *log.Logger) (*Handler, error) {
|
||||
if ldapAuth == nil && krbAuth == nil && htpasswdAuth == nil {
|
||||
return nil, fmt.Errorf("no authentication providers configured: enable at least one of LDAP, --kerberos, or --htpasswd")
|
||||
}
|
||||
gen, err := cert.NewKubeconfigGenerator(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler{ldap: ldapAuth, krb: krbAuth, htpasswd: htpasswdAuth, gen: gen, log: dbg}, 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
|
||||
}
|
||||
log.Printf("issuing exec credential for %q groups=%v", username, groups)
|
||||
|
||||
duration := h.gen.Cfg().Duration
|
||||
if r.URL.Query().Get("login") == "true" {
|
||||
duration = h.gen.Cfg().LoginDuration
|
||||
}
|
||||
cred, err := h.gen.GenerateCredential(username, groups, duration)
|
||||
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=<name> 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 "):
|
||||
h.log.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
|
||||
}
|
||||
h.log.Printf("auth: Kerberos OK, user=%q", username)
|
||||
var groups []string
|
||||
if h.ldap != nil {
|
||||
groups = h.ldap.LookupGroups(username)
|
||||
h.log.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
|
||||
}
|
||||
h.log.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
|
||||
}
|
||||
h.log.Printf("auth: Basic OK, user=%q groups=%v", user, groups)
|
||||
return user, groups, true
|
||||
|
||||
default:
|
||||
h.log.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
|
||||
}
|
||||
h.log.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)
|
||||
}
|
||||
165
pkg/kubeconfig/kubeconfig.go
Normal file
165
pkg/kubeconfig/kubeconfig.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package kubeconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// KubeConfig is the top-level kubeconfig structure.
|
||||
type KubeConfig struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Kind string `yaml:"kind"`
|
||||
Preferences map[string]any `yaml:"preferences,omitempty"`
|
||||
Clusters []NamedCluster `yaml:"clusters"`
|
||||
Users []NamedUser `yaml:"users"`
|
||||
Contexts []NamedContext `yaml:"contexts"`
|
||||
CurrentContext string `yaml:"current-context,omitempty"`
|
||||
}
|
||||
|
||||
// NamedCluster is a named cluster entry.
|
||||
type NamedCluster struct {
|
||||
Name string `yaml:"name"`
|
||||
Cluster ClusterData `yaml:"cluster"`
|
||||
}
|
||||
|
||||
// ClusterData holds the cluster connection details.
|
||||
type ClusterData struct {
|
||||
Server string `yaml:"server"`
|
||||
CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"`
|
||||
}
|
||||
|
||||
// NamedUser is a named user entry.
|
||||
type NamedUser struct {
|
||||
Name string `yaml:"name"`
|
||||
User UserData `yaml:"user"`
|
||||
}
|
||||
|
||||
// UserData holds user credentials.
|
||||
type UserData struct {
|
||||
Exec *ExecData `yaml:"exec,omitempty"`
|
||||
}
|
||||
|
||||
// ExecData holds exec plugin configuration.
|
||||
type ExecData struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Command string `yaml:"command"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
InteractiveMode string `yaml:"interactiveMode,omitempty"`
|
||||
}
|
||||
|
||||
// NamedContext is a named context entry.
|
||||
type NamedContext struct {
|
||||
Name string `yaml:"name"`
|
||||
Context ContextData `yaml:"context"`
|
||||
}
|
||||
|
||||
// ContextData holds context details.
|
||||
type ContextData struct {
|
||||
Cluster string `yaml:"cluster"`
|
||||
User string `yaml:"user"`
|
||||
}
|
||||
|
||||
// FilePath returns the path to the active kubeconfig file.
|
||||
// If KUBECONFIG is set to a colon-separated list, the first entry is returned.
|
||||
func FilePath() string {
|
||||
if k := os.Getenv("KUBECONFIG"); k != "" {
|
||||
if idx := strings.IndexByte(k, os.PathListSeparator); idx >= 0 {
|
||||
return k[:idx]
|
||||
}
|
||||
return k
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
|
||||
// RenameContext renames the cluster and context (but not the user) in cfg.
|
||||
// The bootstrap template uses the cluster name as both the cluster and context
|
||||
// name; the user name is the actual username and is left unchanged.
|
||||
func RenameContext(cfg *KubeConfig, newName string) {
|
||||
oldName := cfg.CurrentContext
|
||||
if oldName == newName {
|
||||
return
|
||||
}
|
||||
for i := range cfg.Clusters {
|
||||
if cfg.Clusters[i].Name == oldName {
|
||||
cfg.Clusters[i].Name = newName
|
||||
}
|
||||
}
|
||||
for i := range cfg.Contexts {
|
||||
if cfg.Contexts[i].Name == oldName {
|
||||
cfg.Contexts[i].Name = newName
|
||||
cfg.Contexts[i].Context.Cluster = newName
|
||||
}
|
||||
}
|
||||
cfg.CurrentContext = newName
|
||||
}
|
||||
|
||||
// Merge merges incoming into the kubeconfig file at FilePath().
|
||||
// If setContext is true, the current-context is updated to incoming.CurrentContext.
|
||||
func Merge(incoming *KubeConfig, setContext bool) error {
|
||||
path := FilePath()
|
||||
|
||||
existing := &KubeConfig{APIVersion: "v1", Kind: "Config"}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := yaml.Unmarshal(data, existing); err != nil {
|
||||
return fmt.Errorf("parsing existing kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range incoming.Clusters {
|
||||
existing.Clusters = upsertCluster(existing.Clusters, c)
|
||||
}
|
||||
for _, u := range incoming.Users {
|
||||
existing.Users = upsertUser(existing.Users, u)
|
||||
}
|
||||
for _, ctx := range incoming.Contexts {
|
||||
existing.Contexts = upsertContext(existing.Contexts, ctx)
|
||||
}
|
||||
if setContext {
|
||||
existing.CurrentContext = incoming.CurrentContext
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return fmt.Errorf("creating kubeconfig directory: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func upsertCluster(list []NamedCluster, item NamedCluster) []NamedCluster {
|
||||
for i, c := range list {
|
||||
if c.Name == item.Name {
|
||||
list[i] = item
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
|
||||
func upsertUser(list []NamedUser, item NamedUser) []NamedUser {
|
||||
for i, u := range list {
|
||||
if u.Name == item.Name {
|
||||
list[i] = item
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
|
||||
func upsertContext(list []NamedContext, item NamedContext) []NamedContext {
|
||||
for i, c := range list {
|
||||
if c.Name == item.Name {
|
||||
list[i] = item
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, item)
|
||||
}
|
||||
Reference in New Issue
Block a user