Import
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ward
|
||||||
245
README.md
Normal file
245
README.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# ward — setup and wiring guide
|
||||||
|
|
||||||
|
ward runs on the k3s master as root, issues short-lived client certificates,
|
||||||
|
and lets users fetch a ready-to-use kubeconfig (or act as a kubectl exec credential
|
||||||
|
plugin) without anyone having to touch the k3s CA by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Build and install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the k3s master (or cross-compile and copy)
|
||||||
|
go build -o /usr/local/bin/ward .
|
||||||
|
```
|
||||||
|
|
||||||
|
The same binary is both the **server** and the **client-side exec plugin** —
|
||||||
|
subcommand dispatch happens on the first argument.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Server setup
|
||||||
|
|
||||||
|
### Minimal (LDAP, auto-discovered via DNS SRV)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/local/bin/ward \
|
||||||
|
--k3s-server=https://k3s.example.com:6443 \
|
||||||
|
--addr=:8443 \
|
||||||
|
--ldap
|
||||||
|
```
|
||||||
|
|
||||||
|
### With an explicit LDAP URI (no DNS SRV required)
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/systemd/system/ward.service.d/local.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/local/bin/ward \
|
||||||
|
--k3s-server=https://k3s.example.com:6443 \
|
||||||
|
--addr=:8443 \
|
||||||
|
--ldap-uri=ldaps://ldap.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now ward
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Kerberos SPNEGO
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/local/bin/ward \
|
||||||
|
--k3s-server=https://k3s.example.com:6443 \
|
||||||
|
--addr=:8443 \
|
||||||
|
--kerberos \
|
||||||
|
--keytab=/etc/krb5.keytab \
|
||||||
|
--spn=HTTP/k3s.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Add the service principal to the KDC
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the KDC (MIT Kerberos)
|
||||||
|
kadmin.local -q "addprinc -randkey HTTP/k3s.example.com@EXAMPLE.COM"
|
||||||
|
kadmin.local -q "ktadd -k /etc/krb5.keytab HTTP/k3s.example.com@EXAMPLE.COM"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
klist -k /etc/krb5.keytab
|
||||||
|
```
|
||||||
|
|
||||||
|
For Samba AD / Heimdal, the equivalent is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
samba-tool spn add HTTP/k3s.example.com ward_service_account
|
||||||
|
net ads keytab add HTTP/k3s.example.com -U Administrator
|
||||||
|
```
|
||||||
|
|
||||||
|
### With htpasswd (break-glass / service accounts)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/local/bin/ward \
|
||||||
|
--k3s-server=https://k3s.example.com:6443 \
|
||||||
|
--addr=:8443 \
|
||||||
|
--kerberos \
|
||||||
|
--htpasswd=/etc/ward/htpasswd
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create the file and add a user (bcrypt is required for new entries)
|
||||||
|
mkdir -p /etc/ward
|
||||||
|
htpasswd -B -c /etc/ward/htpasswd alice
|
||||||
|
|
||||||
|
# Add more users
|
||||||
|
htpasswd -B /etc/ward/htpasswd bob
|
||||||
|
|
||||||
|
# Reload without restarting (SIGHUP)
|
||||||
|
systemctl reload ward
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Get the bootstrap kubeconfig
|
||||||
|
|
||||||
|
The `/bootstrap` endpoint returns a ready-to-use kubeconfig with no embedded
|
||||||
|
credentials — just the cluster endpoint, server CA, and the exec plugin stanza
|
||||||
|
that tells kubectl to call `ward credential` on demand. No authentication
|
||||||
|
required to fetch it.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://k3s.example.com:8443/bootstrap > ~/.kube/config
|
||||||
|
```
|
||||||
|
|
||||||
|
The username in the kubeconfig is resolved in priority order:
|
||||||
|
|
||||||
|
1. `?user=` query parameter — explicit override
|
||||||
|
2. Credentials in the request (Basic or Kerberos) — personalised on the fly
|
||||||
|
3. The placeholder `YOUR_USERNAME_HERE` — for anonymous fetches
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Personalised via query param (useful for scripting)
|
||||||
|
curl -s https://k3s.example.com:8443/bootstrap?user=alice > ~/.kube/config
|
||||||
|
|
||||||
|
# Personalised by authenticating while fetching
|
||||||
|
curl -su alice https://k3s.example.com:8443/bootstrap > ~/.kube/config
|
||||||
|
```
|
||||||
|
|
||||||
|
Distribute this URL to users alongside the `ward` binary. That's the entire
|
||||||
|
onboarding process.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Client setup
|
||||||
|
|
||||||
|
Each user needs:
|
||||||
|
|
||||||
|
1. The `ward` binary in their `$PATH`
|
||||||
|
2. The bootstrap kubeconfig
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the binary (copy from the server, or build locally)
|
||||||
|
sudo cp ward /usr/local/bin/ward
|
||||||
|
|
||||||
|
# Install the kubeconfig
|
||||||
|
cp bootstrap-kubeconfig.yaml ~/.kube/config # or merge manually
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. First use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kerberos path — if you have a valid ticket, it just works:
|
||||||
|
kinit alice@EXAMPLE.COM
|
||||||
|
kubectl get nodes
|
||||||
|
|
||||||
|
# Basic auth path — prompted on first use, then cached for 24 h:
|
||||||
|
kubectl get nodes
|
||||||
|
# Password for alice: ▌
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials are cached in `~/.cache/ward/` and reused until 5 minutes before
|
||||||
|
expiry. kubectl re-invokes the plugin automatically at that point — no manual
|
||||||
|
`kubectl login` step required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. RBAC setup
|
||||||
|
|
||||||
|
The issued certificate has:
|
||||||
|
- **CN** = username (LDAP `sAMAccountName` / `uid`, or Kerberos principal primary)
|
||||||
|
- **O** = LDAP group CNs (maps to Kubernetes RBAC groups)
|
||||||
|
|
||||||
|
Bind roles as usual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant a specific user cluster-admin
|
||||||
|
kubectl create clusterrolebinding alice-admin \
|
||||||
|
--clusterrole=cluster-admin \
|
||||||
|
--user=alice
|
||||||
|
|
||||||
|
# Grant an LDAP group view access
|
||||||
|
kubectl create clusterrolebinding k8s-readers \
|
||||||
|
--clusterrole=view \
|
||||||
|
--group=k8s-users
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Debugging
|
||||||
|
|
||||||
|
### Server-side
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable verbose logging
|
||||||
|
WARD_DEBUG=1 systemctl restart ward
|
||||||
|
journalctl -u ward -f
|
||||||
|
|
||||||
|
# Or pass the flag directly
|
||||||
|
ward --debug --k3s-server=... --addr=:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
Server debug output shows:
|
||||||
|
- LDAP connection attempts (host, TLS, bind DN)
|
||||||
|
- Auth method selected per request (Kerberos / Basic)
|
||||||
|
- Group lookup results
|
||||||
|
- Cert issuance details
|
||||||
|
|
||||||
|
### Client-side
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inline for a single command
|
||||||
|
WARD_DEBUG=1 kubectl get nodes
|
||||||
|
|
||||||
|
# Or permanently in the kubeconfig exec env block:
|
||||||
|
# env:
|
||||||
|
# - name: WARD_DEBUG
|
||||||
|
# value: "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Client debug output (`stderr`, passed through by kubectl) shows:
|
||||||
|
- Which Kerberos credential cache was found and the principal in it
|
||||||
|
- Cache hit/miss and time remaining on cached cert
|
||||||
|
- HTTP request URL and response status
|
||||||
|
- Fallback to Basic auth and why
|
||||||
|
|
||||||
|
### Bypass the cache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ward credential --server=https://k3s.example.com:8443 --no-cache --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test without kubectl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kerberos
|
||||||
|
kinit alice
|
||||||
|
ward credential --server=https://k3s.example.com:8443 --debug | python3 -m json.tool
|
||||||
|
|
||||||
|
# Basic auth
|
||||||
|
ward credential --server=https://k3s.example.com:8443 --no-kerberos --debug
|
||||||
|
```
|
||||||
259
cert.go
Normal file
259
cert.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) (*Credential, error) {
|
||||||
|
certPEM, keyPEM, err := g.signClientCert(username, groups)
|
||||||
|
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)
|
||||||
|
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) (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(g.cfg.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
|
||||||
|
}
|
||||||
316
cmd_credential.go
Normal file
316
cmd_credential.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
krb5client "github.com/jcmturner/gokrb5/v8/client"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/config"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/credentials"
|
||||||
|
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCredential implements the "ward credential" subcommand, which acts as a
|
||||||
|
// kubectl exec credential plugin. It fetches an ExecCredential JSON from the
|
||||||
|
// ward server and prints it to stdout for kubectl to consume.
|
||||||
|
//
|
||||||
|
// Authentication priority:
|
||||||
|
// 1. Kerberos SPNEGO using the active credential cache (from kinit)
|
||||||
|
// 2. Basic auth — prompts for password, or reads $WARD_PASSWORD
|
||||||
|
//
|
||||||
|
// Credentials are cached in ~/.cache/ward/ and reused until 5 minutes
|
||||||
|
// before expiry, so kubectl invocations are fast after the first call.
|
||||||
|
//
|
||||||
|
// Debug output goes to stderr (kubectl surfaces this to the terminal):
|
||||||
|
//
|
||||||
|
// WARD_DEBUG=1 kubectl get nodes
|
||||||
|
func runCredential(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("credential", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
server := fs.String("server", "", "ward server URL (required)")
|
||||||
|
username := fs.String("username", "", "username for Basic auth fallback (default: $USER)")
|
||||||
|
noKerberos := fs.Bool("no-kerberos", false, "skip Kerberos; always use Basic auth")
|
||||||
|
noCache := fs.Bool("no-cache", false, "bypass local cache; always fetch a fresh credential")
|
||||||
|
debug := fs.Bool("debug", os.Getenv("WARD_DEBUG") != "", "verbose debug output to stderr (also: $WARD_DEBUG=1)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if *server == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ward credential: --server is required")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
logf := func(format string, a ...interface{}) {
|
||||||
|
if *debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ward] "+format+"\n", a...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache ─────────────────────────────────────────────────────────────────
|
||||||
|
if !*noCache {
|
||||||
|
if ec, ok := credReadCache(*server, logf); ok {
|
||||||
|
return credPrint(ec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch from ward ───────────────────────────────────────────────────
|
||||||
|
ec, err := credFetch(*server, *username, *noKerberos, logf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ward credential: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*noCache {
|
||||||
|
credWriteCache(*server, ec, logf)
|
||||||
|
}
|
||||||
|
return credPrint(ec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func credFetch(server, username string, noKerberos bool, logf func(string, ...interface{})) (*ExecCredential, error) {
|
||||||
|
url := strings.TrimRight(server, "/") + "/credential"
|
||||||
|
|
||||||
|
// ── Kerberos SPNEGO ───────────────────────────────────────────────────────
|
||||||
|
if !noKerberos {
|
||||||
|
body, err := credFetchKerberos(url, logf)
|
||||||
|
if err != nil {
|
||||||
|
logf("Kerberos: %v — falling back to Basic auth", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "ward: Kerberos failed (%v); falling back to Basic auth\nhint: run 'kinit' to avoid the password prompt\n", err)
|
||||||
|
} else {
|
||||||
|
return credParse(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic auth ────────────────────────────────────────────────────────────
|
||||||
|
if username == "" {
|
||||||
|
username = os.Getenv("USER")
|
||||||
|
}
|
||||||
|
password := os.Getenv("WARD_PASSWORD")
|
||||||
|
if password == "" {
|
||||||
|
var err error
|
||||||
|
password, err = credPromptPassword(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logf("Basic: using password from $WARD_PASSWORD")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := credFetchBasic(url, username, password, logf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return credParse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func credFetchKerberos(url string, logf func(string, ...interface{})) ([]byte, error) {
|
||||||
|
krb5cfgPath := krb5ConfigPath()
|
||||||
|
logf("Kerberos: loading config from %s", krb5cfgPath)
|
||||||
|
krb5cfg, err := config.Load(krb5cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading krb5 config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ccPath := ccachePath()
|
||||||
|
logf("Kerberos: loading credential cache from %s", ccPath)
|
||||||
|
ccache, err := credentials.LoadCCache(ccPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no credential cache (%w) — run 'kinit'", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, err := krb5client.NewFromCCache(ccache, krb5cfg, krb5client.DisablePAFXFAST(true))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating Kerberos client: %w", err)
|
||||||
|
}
|
||||||
|
defer cl.Destroy()
|
||||||
|
|
||||||
|
logf("Kerberos: principal=%s@%s", cl.Credentials.UserName(), cl.Credentials.Domain())
|
||||||
|
|
||||||
|
spnegoClient := spnego.NewClient(cl, nil, "")
|
||||||
|
logf("HTTP: GET %s (Negotiate)", url)
|
||||||
|
resp, err := spnegoClient.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
logf("HTTP: %s", resp.Status)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return nil, fmt.Errorf("server rejected Kerberos token (check SPN and keytab)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("server returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
logf("HTTP: received %d bytes", len(body))
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credFetchBasic(url, username, password string, logf func(string, ...interface{})) ([]byte, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(username, password)
|
||||||
|
logf("HTTP: GET %s (Basic, user=%s)", url, username)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
logf("HTTP: %s", resp.Status)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading response: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return nil, fmt.Errorf("authentication failed (wrong username or password)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("server returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
logf("HTTP: received %d bytes", len(body))
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credParse(body []byte) (*ExecCredential, error) {
|
||||||
|
var ec ExecCredential
|
||||||
|
if err := json.Unmarshal(body, &ec); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing server response: %w", err)
|
||||||
|
}
|
||||||
|
if ec.Status == nil {
|
||||||
|
return nil, fmt.Errorf("server returned ExecCredential with no status field")
|
||||||
|
}
|
||||||
|
return &ec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credPrint(ec *ExecCredential) int {
|
||||||
|
if err := json.NewEncoder(os.Stdout).Encode(ec); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ward credential: writing output: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local credential cache ─────────────────────────────────────────────────────
|
||||||
|
// Caches ExecCredential JSON in ~/.cache/ward/<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, ...interface{})) (*ExecCredential, bool) {
|
||||||
|
path := credCacheFile(serverURL)
|
||||||
|
logf("cache: checking %s", path)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
logf("cache: miss (%v)", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var ec ExecCredential
|
||||||
|
if err := json.Unmarshal(data, &ec); err != nil {
|
||||||
|
logf("cache: corrupt, ignoring (%v)", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if ec.Status == nil || ec.Status.ExpirationTimestamp == "" {
|
||||||
|
logf("cache: no expiry stored, refreshing")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
expiry, err := time.Parse(time.RFC3339, ec.Status.ExpirationTimestamp)
|
||||||
|
if err != nil {
|
||||||
|
logf("cache: unparseable expiry %q, refreshing", ec.Status.ExpirationTimestamp)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
remaining := time.Until(expiry)
|
||||||
|
if remaining < 5*time.Minute {
|
||||||
|
logf("cache: expiring soon (%v remaining), refreshing", remaining.Truncate(time.Second))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
logf("cache: hit — cert for expires %s (%v remaining)",
|
||||||
|
expiry.Format(time.RFC3339), remaining.Truncate(time.Second))
|
||||||
|
return &ec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func credWriteCache(serverURL string, ec *ExecCredential, logf func(string, ...interface{})) {
|
||||||
|
dir := credCacheDir()
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
logf("cache: failed to create dir: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(ec)
|
||||||
|
if err != nil {
|
||||||
|
logf("cache: marshal failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := credCacheFile(serverURL)
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
logf("cache: write failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("cache: saved to %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kerberos helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func krb5ConfigPath() string {
|
||||||
|
if v := os.Getenv("KRB5_CONFIG"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "/etc/krb5.conf"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ccachePath returns the path to the active Kerberos credential cache.
|
||||||
|
// Respects $KRB5CCNAME; strips the "FILE:" prefix if present.
|
||||||
|
// Non-file ccache types (API:, KEYRING:, DIR:) are not supported by gokrb5
|
||||||
|
// and will produce an error when LoadCCache is called.
|
||||||
|
func ccachePath() string {
|
||||||
|
if v := os.Getenv("KRB5CCNAME"); v != "" {
|
||||||
|
return strings.TrimPrefix(v, "FILE:")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/tmp/krb5cc_%d", os.Getuid())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password prompt ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func credPromptPassword(username string) (string, error) {
|
||||||
|
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"stdin is not a terminal and $WARD_PASSWORD is not set\n" +
|
||||||
|
"hint: run 'kinit' for Kerberos auth, or set $WARD_PASSWORD for non-interactive use")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Password for %s: ", username)
|
||||||
|
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr) // newline after the hidden input
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading password: %w", err)
|
||||||
|
}
|
||||||
|
return string(pw), nil
|
||||||
|
}
|
||||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module ward
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
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
|
||||||
|
golang.org/x/crypto v0.21.0
|
||||||
|
golang.org/x/term v0.18.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
|
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/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
|
||||||
|
golang.org/x/net v0.22.0 // indirect
|
||||||
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
|
)
|
||||||
97
go.sum
Normal file
97
go.sum
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||||
|
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/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=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
|
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||||
|
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||||
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
|
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/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=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
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/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=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
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=
|
||||||
|
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=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
233
handler.go
Normal file
233
handler.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 *LDAPAuth
|
||||||
|
krb *KerberosAuth
|
||||||
|
htpasswd *HtpasswdAuth
|
||||||
|
gen *KubeconfigGenerator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler validates that at least one auth provider is configured, then
|
||||||
|
// loads the k3s CA files and returns a ready Handler.
|
||||||
|
func NewHandler(ldap *LDAPAuth, krb *KerberosAuth, htpasswd *HtpasswdAuth, cfg *CertConfig) (*Handler, error) {
|
||||||
|
if ldap == nil && krb == nil && htpasswd == nil {
|
||||||
|
return nil, fmt.Errorf("no authentication providers configured: enable at least one of LDAP, --kerberos, or --htpasswd")
|
||||||
|
}
|
||||||
|
gen, err := NewKubeconfigGenerator(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Handler{ldap: ldap, krb: krb, htpasswd: htpasswd, gen: gen}, 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
|
||||||
|
}
|
||||||
|
dbg.Printf("issuing exec credential for %q groups=%v", username, groups)
|
||||||
|
log.Printf("issuing exec credential for %q groups=%v", username, groups)
|
||||||
|
|
||||||
|
cred, err := h.gen.GenerateCredential(username, groups)
|
||||||
|
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 "):
|
||||||
|
dbg.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
|
||||||
|
}
|
||||||
|
dbg.Printf("auth: Kerberos OK, user=%q", username)
|
||||||
|
var groups []string
|
||||||
|
if h.ldap != nil {
|
||||||
|
groups = h.ldap.LookupGroups(username)
|
||||||
|
dbg.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
|
||||||
|
}
|
||||||
|
dbg.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
|
||||||
|
}
|
||||||
|
dbg.Printf("auth: Basic OK, user=%q groups=%v", user, groups)
|
||||||
|
return user, groups, true
|
||||||
|
|
||||||
|
default:
|
||||||
|
dbg.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
|
||||||
|
}
|
||||||
|
dbg.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)
|
||||||
|
}
|
||||||
111
htpasswd.go
Normal file
111
htpasswd.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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
kerberos.go
Normal file
81
kerberos.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
276
ldap.go
Normal file
276
ldap.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) (*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,
|
||||||
|
}, 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) (*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,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
dbg.Printf("LDAP: dialing TLS %s", addr)
|
||||||
|
return ldap.DialTLS("tcp", addr, tlsConfig)
|
||||||
|
}
|
||||||
|
dbg.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbg.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.
|
||||||
|
dbg.Printf("LDAP: binding as %s", userDN)
|
||||||
|
if err := conn.Bind(userDN, password); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
dbg.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 {
|
||||||
|
dbg.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)
|
||||||
|
dbg.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
|
||||||
|
}
|
||||||
232
main.go
Normal file
232
main.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server flags ──────────────────────────────────────────────────────────
|
||||||
|
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")
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
|
||||||
|
// 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)")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
} 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 {
|
||||||
|
log.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
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 {
|
||||||
|
log.Fatalf("listen %s: %v", *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)
|
||||||
|
log.Fatal(srv.Serve(ln))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
34
ward.service
Normal file
34
ward.service
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ward — Kubernetes credential gateway (LDAP/Kerberos/htpasswd)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
# Adjust or override in /etc/systemd/system/ward.service.d/local.conf
|
||||||
|
ExecStart=/usr/local/bin/ward \
|
||||||
|
--k3s-server=https://YOUR_K3S_HOSTNAME:6443 \
|
||||||
|
--addr=:8443
|
||||||
|
|
||||||
|
# SIGHUP reloads the htpasswd file without restarting.
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Must run as root to read:
|
||||||
|
# /var/lib/rancher/k3s/server/tls/client-ca.key
|
||||||
|
# /etc/letsencrypt/live/*/privkey.pem
|
||||||
|
# /etc/krb5.keytab (if using Kerberos)
|
||||||
|
# Tighten by granting group read access to those files instead.
|
||||||
|
User=root
|
||||||
|
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user