diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 3fd7246..6793b0e 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -40,4 +40,4 @@ jobs: charts_dir: helm env: CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - CR_RELEASE_NAME_TEMPLATE: "helm-v{{ .Version }}" + CR_RELEASE_NAME_TEMPLATE: "helm-{{ .Name }}-v{{ .Version }}" diff --git a/Dockerfile.auth-proxy b/Dockerfile.auth-proxy new file mode 100644 index 0000000..28f096b --- /dev/null +++ b/Dockerfile.auth-proxy @@ -0,0 +1,29 @@ +# Build the auth-proxy binary +FROM golang:1.24 AS builder + +ARG TARGETOS=linux +ARG TARGETARCH + +WORKDIR /workspace + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY cmd/ cmd/ + +# Build the binary +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-w -s" -a -o auth-proxy ./cmd/auth-proxy + +# Runtime image - using alpine because NetBird client requires +# system utilities (uname, etc.) for system detection +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR / +COPY --from=builder /workspace/auth-proxy . +USER 65532:65532 +ENTRYPOINT ["/auth-proxy"] + diff --git a/Dockerfile.kubectl b/Dockerfile.kubectl index c7dcf87..fc048f1 100644 --- a/Dockerfile.kubectl +++ b/Dockerfile.kubectl @@ -1,10 +1,10 @@ FROM alpine:3 AS builder -RUN apk update && apk add curl +RUN apk update && apk add curl bash RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ chmod +x kubectl && \ mv kubectl /usr/local/bin/kubectl FROM alpine:3 AS final -COPY --from=builder /usr/local/bin/kubectl /usr/local/bin/kubectl \ No newline at end of file +COPY --from=builder /usr/local/bin/kubectl /usr/local/bin/kubectl diff --git a/cmd/auth-proxy/main.go b/cmd/auth-proxy/main.go new file mode 100644 index 0000000..b5559f6 --- /dev/null +++ b/cmd/auth-proxy/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "flag" + "log" + "math/big" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/signal" + "syscall" + "time" + + "github.com/netbirdio/netbird/client/embed" +) + +func main() { + var ( + mgmtURL string + setupKey string + target string + listenAddr string + statePath string + logLevel string + configPath string + deviceName string + dnsDomain string + ) + + flag.StringVar(&mgmtURL, "management-url", os.Getenv("NB_MANAGEMENT_URL"), "NetBird Management URL") + flag.StringVar(&setupKey, "setup-key", os.Getenv("NB_SETUP_KEY"), "NetBird Setup Key") + flag.StringVar(&target, "target", os.Getenv("KUBERNETES_API_SERVER"), "Target Kubernetes API Server URL") + flag.StringVar(&listenAddr, "listen-addr", ":443", "Address to listen on (within NetBird network)") + flag.StringVar(&statePath, "state-path", "/var/lib/netbird/state.json", "Path to NetBird state file") + flag.StringVar(&logLevel, "log-level", "info", "Log level") + flag.StringVar(&configPath, "config-path", "/etc/netbird/config.json", "Path to NetBird config file") + flag.StringVar(&deviceName, "hostname", os.Getenv("NB_HOSTNAME"), "Device name (hostname)") + flag.StringVar(&dnsDomain, "dns-domain", os.Getenv("NB_DNS_DOMAIN"), "NetBird DNS domain (e.g., netbird.cloud)") + + flag.Parse() + + if setupKey == "" && os.Getenv("NB_SETUP_KEY") == "" { + if _, err := os.Stat(configPath); os.IsNotExist(err) { + log.Fatal("Setup key is required for initial setup") + } + } + + if target == "" { + target = "https://kubernetes.default.svc" + } + + // 1. Initialize NetBird Client + opts := embed.Options{ + SetupKey: setupKey, + ManagementURL: mgmtURL, + StatePath: statePath, + ConfigPath: configPath, + LogLevel: logLevel, + DeviceName: deviceName, + } + + client, err := embed.New(opts) + if err != nil { + log.Fatalf("Failed to create NetBird client: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("Received signal, shutting down...") + cancel() + client.Stop(context.Background()) + os.Exit(0) + }() + + log.Println("Starting NetBird client...") + if err := client.Start(ctx); err != nil { + log.Fatalf("Failed to start NetBird client: %v", err) + } + + // 2. Setup Reverse Proxy + targetURL, err := url.Parse(target) + if err != nil { + log.Fatalf("Invalid target URL: %v", err) + } + + log.Printf("Proxying to %s", targetURL) + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + + rootCAs, _ := x509.SystemCertPool() + if rootCAs == nil { + rootCAs = x509.NewCertPool() + } + + k8sCA, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") + if err == nil { + rootCAs.AppendCertsFromPEM(k8sCA) + } + + saToken, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + if err != nil { + log.Printf("Warning: Could not load ServiceAccount token: %v", err) + } + bearerToken := string(saToken) + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: rootCAs, + }, + } + proxy.Transport = transport + + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + + remoteIP, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + remoteIP = req.RemoteAddr + } + + identity, err := client.WhoIs(remoteIP) + if err != nil { + log.Printf("Authentication failed: could not look up peer for IP %s: %v", remoteIP, err) + req.Header.Del("Impersonate-User") + req.Header.Del("Impersonate-Group") + return + } + + // Use UserId if available, otherwise fall back to FQDN as username + username := identity.UserId + if username == "" { + username = identity.FQDN + } + + log.Printf("Authenticated peer: %s (User: %s, Groups: %v)", identity.FQDN, username, identity.Groups) + + req.Header.Set("Impersonate-User", username) + req.Header.Del("Impersonate-Group") + for _, group := range identity.Groups { + req.Header.Add("Impersonate-Group", group) + } + } + + authHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + remoteIP = r.RemoteAddr + } + + _, err = client.WhoIs(remoteIP) + if err != nil { + log.Printf("Access denied for %s: %v", remoteIP, err) + http.Error(w, "Unauthorized: Unknown NetBird Peer", http.StatusUnauthorized) + return + } + + proxy.ServeHTTP(w, r) + }) + + // 3. Generate self-signed TLS certificate + tlsCert, err := generateSelfSignedCert(deviceName, dnsDomain) + if err != nil { + log.Fatalf("Failed to generate TLS certificate: %v", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + MinVersion: tls.VersionTLS12, + } + + // 4. Listen on NetBird Network with TLS + listener, err := client.ListenTCP(listenAddr) + if err != nil { + log.Fatalf("Failed to listen on %s: %v", listenAddr, err) + } + + tlsListener := tls.NewListener(listener, tlsConfig) + + log.Printf("Listening on %s (NetBird Network) with TLS", listenAddr) + if err := http.Serve(tlsListener, authHandler); err != nil { + log.Fatalf("Server error: %v", err) + } +} + +// generateSelfSignedCert creates a self-signed certificate for the proxy +func generateSelfSignedCert(hostname, dnsDomain string) (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + + // Build DNS names for the certificate + dnsNames := []string{hostname, "localhost"} + if dnsDomain != "" { + dnsNames = append(dnsNames, hostname+"."+dnsDomain) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"NetBird K8s Auth Proxy"}, + CommonName: hostname, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: dnsNames, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, err + } + + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: priv, + }, nil +} diff --git a/go.mod b/go.mod index 0a7b498..544e655 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ godebug default=go1.23 require ( github.com/go-logr/logr v1.4.2 github.com/google/uuid v1.6.0 + // NOTE: The auth-proxy requires netbird with embed client support. + // Update to the version that includes github.com/netbirdio/netbird/client/embed github.com/netbirdio/netbird v0.36.7 github.com/onsi/ginkgo/v2 v2.21.0 github.com/onsi/gomega v1.35.1 diff --git a/helm/kubernetes-operator/templates/_helpers.tpl b/helm/kubernetes-operator/templates/_helpers.tpl index ebef1ea..1192981 100644 --- a/helm/kubernetes-operator/templates/_helpers.tpl +++ b/helm/kubernetes-operator/templates/_helpers.tpl @@ -66,6 +66,16 @@ Create the name of the service account to use {{- end }} {{- end }} +{{/* +Create the name of the service account to use for auth proxy +*/}} +{{- define "kubernetes-operator.authProxyServiceAccountName" -}} +{{- if .Values.authProxy.serviceAccount.create }} +{{- default (printf "%s-auth-proxy" (include "kubernetes-operator.fullname" .)) .Values.authProxy.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.authProxy.serviceAccount.name }} +{{- end }} +{{- end }} {{/* Create the name of the webhook service diff --git a/helm/kubernetes-operator/templates/auth-proxy-deployment.yaml b/helm/kubernetes-operator/templates/auth-proxy-deployment.yaml new file mode 100644 index 0000000..3144aaa --- /dev/null +++ b/helm/kubernetes-operator/templates/auth-proxy-deployment.yaml @@ -0,0 +1,62 @@ +{{- if .Values.authProxy.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-operator.labels" . | nindent 4 }} + app.kubernetes.io/component: auth-proxy +spec: + replicas: {{ .Values.authProxy.replicaCount }} + selector: + matchLabels: + {{- include "kubernetes-operator.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: auth-proxy + template: + metadata: + labels: + {{- include "kubernetes-operator.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: auth-proxy + spec: + serviceAccountName: {{ include "kubernetes-operator.authProxyServiceAccountName" . }} + containers: + - name: auth-proxy + image: "{{ .Values.authProxy.image.repository }}:{{ .Values.authProxy.image.tag }}" + imagePullPolicy: {{ .Values.authProxy.image.pullPolicy }} + args: + - "--config-path=/var/lib/netbird/config.json" + - "--state-path=/var/lib/netbird/state.json" + env: + - name: NB_MANAGEMENT_URL + value: {{ .Values.authProxy.managementURL | default "https://api.netbird.io" }} + - name: NB_HOSTNAME + value: {{ .Values.authProxy.hostname }} + - name: KUBERNETES_API_SERVER + value: "https://kubernetes.default.svc" + - name: NB_LOG_LEVEL + value: {{ .Values.authProxy.logLevel | default "info" }} + {{- if .Values.authProxy.dnsDomain }} + - name: NB_DNS_DOMAIN + value: {{ .Values.authProxy.dnsDomain }} + {{- end }} + - name: NB_SETUP_KEY + valueFrom: + secretKeyRef: + {{- if .Values.authProxy.existingSecret }} + name: {{ .Values.authProxy.existingSecret }} + key: NB_SETUP_KEY + {{- else }} + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy-secret + key: NB_SETUP_KEY + {{- end }} + resources: + {{- toYaml .Values.authProxy.resources | nindent 12 }} + volumeMounts: + - name: state + mountPath: /var/lib/netbird + volumes: + - name: state + emptyDir: {} +{{- end }} + diff --git a/helm/kubernetes-operator/templates/auth-proxy-rbac.yaml b/helm/kubernetes-operator/templates/auth-proxy-rbac.yaml new file mode 100644 index 0000000..c07e2c7 --- /dev/null +++ b/helm/kubernetes-operator/templates/auth-proxy-rbac.yaml @@ -0,0 +1,43 @@ +{{- if .Values.authProxy.enabled -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kubernetes-operator.authProxyServiceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-operator.labels" . | nindent 4 }} + {{- with .Values.authProxy.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy + labels: + {{- include "kubernetes-operator.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["users", "groups", "serviceaccounts"] + verbs: ["impersonate"] + - apiGroups: ["authentication.k8s.io"] + resources: ["userextras/scopes"] + verbs: ["impersonate"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy + labels: + {{- include "kubernetes-operator.labels" . | nindent 4 }} +subjects: + - kind: ServiceAccount + name: {{ include "kubernetes-operator.authProxyServiceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy + apiGroup: rbac.authorization.k8s.io +{{- end }} + diff --git a/helm/kubernetes-operator/templates/auth-proxy-secret.yaml b/helm/kubernetes-operator/templates/auth-proxy-secret.yaml new file mode 100644 index 0000000..ab2efa2 --- /dev/null +++ b/helm/kubernetes-operator/templates/auth-proxy-secret.yaml @@ -0,0 +1,13 @@ +{{- if and .Values.authProxy.enabled .Values.authProxy.setupKey (not .Values.authProxy.existingSecret) -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "kubernetes-operator.fullname" . }}-auth-proxy-secret + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-operator.labels" . | nindent 4 }} +type: Opaque +data: + NB_SETUP_KEY: {{ .Values.authProxy.setupKey | b64enc | quote }} +{{- end }} + diff --git a/helm/kubernetes-operator/values.yaml b/helm/kubernetes-operator/values.yaml index 4355a2c..1986f7d 100644 --- a/helm/kubernetes-operator/values.yaml +++ b/helm/kubernetes-operator/values.yaml @@ -1,3 +1,54 @@ +# Kubernetes API Auth Proxy - Provides identity-aware access to the Kubernetes API +# through NetBird peer authentication and Kubernetes impersonation +authProxy: + # Enable the auth proxy deployment + enabled: false + + image: + repository: netbirdio/kubernetes-auth-proxy + tag: latest + pullPolicy: IfNotPresent + + # Number of auth proxy replicas + replicaCount: 1 + + serviceAccount: + # Create a dedicated service account for the auth proxy + create: true + # Override the service account name + name: "" + # Annotations for the service account + annotations: {} + + # Resource limits and requests + resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 50m + # memory: 64Mi + + # NetBird setup key for the auth proxy peer + # Can also be provided via existingSecret + setupKey: "" + + # Use an existing secret containing NB_SETUP_KEY + existingSecret: "" + + # Hostname for the auth proxy peer in NetBird + hostname: "k8s-api" + + # NetBird DNS domain (e.g., "netbird.cloud" or "netbird.selfhosted") + # Used for TLS certificate generation + dnsDomain: "" + + # Override the global management URL + managementURL: "" + + # Log level (debug, info, warn, error) + logLevel: "info" + clusterSecretsPermissions: # Required for NBSetupKey validation # Required for Ingress functionality to create and validate secrets for routing peers diff --git a/helm/netbird-operator-config/templates/kubernetes-nbresource.yaml b/helm/netbird-operator-config/templates/kubernetes-nbresource.yaml index aef1a5f..8143358 100644 --- a/helm/netbird-operator-config/templates/kubernetes-nbresource.yaml +++ b/helm/netbird-operator-config/templates/kubernetes-nbresource.yaml @@ -1,6 +1,6 @@ -{{- if and .Values.ingress.enabled .Values.ingress.kubernetesAPI.enabled }} +{{- if and .Values.enabled .Values.kubernetesAPI.enabled }} {{- $routerNS := .Release.Namespace }} -{{- if .Values.ingress.namespacedNetworks }} +{{- if .Values.namespacedNetworks }} {{- $routerNS = "default" }} {{- end }} apiVersion: batch/v1