Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions client/cmd/kubeconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package cmd

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
"google.golang.org/grpc/status"

"github.com/netbirdio/netbird/client/proto"
)

var (
kubeconfigOutput string
kubeconfigCluster string
kubeconfigContext string
kubeconfigUser string
kubeconfigServer string
kubeconfigNamespace string
)

var kubeconfigCmd = &cobra.Command{
Use: "kubeconfig",
Short: "Generate kubeconfig for accessing Kubernetes via NetBird",
Long: `Generate a kubeconfig file that points to a Kubernetes cluster accessible via NetBird.

The generated kubeconfig uses a dummy bearer token for authentication when the
cluster's auth proxy is running in 'auth' mode. The actual authentication is
handled by the NetBird network - the auth proxy identifies users by their
NetBird peer IP and impersonates them in the Kubernetes API.

Example:
netbird kubeconfig --server https://k8s.example.netbird.cloud:6443 --cluster my-cluster
netbird kubeconfig --server https://10.100.0.1:6443 -o ~/.kube/netbird-config`,
RunE: kubeconfigFunc,
}

func init() {
kubeconfigCmd.Flags().StringVarP(&kubeconfigOutput, "output", "o", "", "Output file path (default: stdout)")
kubeconfigCmd.Flags().StringVar(&kubeconfigCluster, "cluster", "netbird-cluster", "Cluster name in kubeconfig")
kubeconfigCmd.Flags().StringVar(&kubeconfigContext, "context", "netbird", "Context name in kubeconfig")
kubeconfigCmd.Flags().StringVar(&kubeconfigUser, "user", "netbird-user", "User name in kubeconfig")
kubeconfigCmd.Flags().StringVar(&kubeconfigServer, "server", "", "Kubernetes API server URL (required)")
kubeconfigCmd.Flags().StringVar(&kubeconfigNamespace, "namespace", "default", "Default namespace")
_ = kubeconfigCmd.MarkFlagRequired("server")
}

// kubeconfigFunc generates a kubeconfig file for accessing Kubernetes via the NetBird auth proxy.
// It verifies NetBird connectivity and creates a kubeconfig with the appropriate server URL.
func kubeconfigFunc(cmd *cobra.Command, args []string) error {
ctx := context.Background()

// Get current NetBird status to verify connection
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
cmd.PrintErrf("Warning: Could not connect to NetBird daemon: %v\n", err)
cmd.PrintErrln("Generating kubeconfig anyway, but make sure NetBird is running before using it.")
} else {
defer conn.Close()

resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{})
if err != nil {
cmd.PrintErrf("Warning: Could not get NetBird status: %v\n", status.Convert(err).Message())
} else if resp.Status != "Connected" {
cmd.PrintErrf("Warning: NetBird is not connected (status: %s)\n", resp.Status)
cmd.PrintErrln("Make sure to run 'netbird up' before using the generated kubeconfig.")
}
}

kubeconfig := generateKubeconfig(kubeconfigServer, kubeconfigCluster, kubeconfigContext, kubeconfigUser, kubeconfigNamespace)

if kubeconfigOutput == "" {
fmt.Println(kubeconfig)
return nil
}

// Expand ~ in path
if strings.HasPrefix(kubeconfigOutput, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
kubeconfigOutput = filepath.Join(home, kubeconfigOutput[2:])
}

// Create directory if needed
dir := filepath.Dir(kubeconfigOutput)
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}

if err := os.WriteFile(kubeconfigOutput, []byte(kubeconfig), 0600); err != nil {
return fmt.Errorf("failed to write kubeconfig: %w", err)
}

cmd.Printf("Kubeconfig written to %s\n", kubeconfigOutput)
cmd.PrintErrln("\nWarning: TLS verification is disabled (insecure-skip-tls-verify: true).")
cmd.PrintErrln("This is safe when traffic is encrypted via NetBird's WireGuard tunnel.")
cmd.Printf("\nTo use this kubeconfig:\n")
cmd.Printf(" export KUBECONFIG=%s\n", kubeconfigOutput)
cmd.Printf(" kubectl get nodes\n")

return nil
}

// generateKubeconfig creates a kubeconfig YAML string with the given parameters.
// The config uses insecure-skip-tls-verify since traffic is encrypted via WireGuard.
func generateKubeconfig(server, cluster, context, user, namespace string) string {
return fmt.Sprintf(`apiVersion: v1
kind: Config
clusters:
- cluster:
insecure-skip-tls-verify: true
server: %s
name: %s
contexts:
- context:
cluster: %s
namespace: %s
user: %s
name: %s
current-context: %s
users:
- name: %s
user:
token: netbird-auth-proxy
`, server, cluster, cluster, namespace, user, context, context, user)
}
Comment on lines +111 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security concern: TLS verification disabled by default.

Line 109 hardcodes insecure-skip-tls-verify: true in the generated kubeconfig. This disables TLS certificate verification, which could expose users to man-in-the-middle attacks.

Consider one of the following approaches:

  1. Add a flag to control this behavior (e.g., --insecure-skip-tls-verify) and default to false
  2. If the NetBird-accessible cluster uses self-signed certificates that users trust, consider providing a way to specify a CA certificate bundle instead
  3. At minimum, add a warning message when generating the kubeconfig that TLS verification is disabled

The dummy token on line 122 is acceptable per the PR description (auth handled by NetBird IP).

🤖 Prompt for AI Agents
In client/cmd/kubeconfig.go around lines 104 to 124, the generated kubeconfig
unconditionally sets insecure-skip-tls-verify: true; change the function to
accept an insecureSkipTLS boolean (default false) and an optional caBundle
string/path, set insecure-skip-tls-verify to the boolean instead of hardcoding
true, and when caBundle is provided include it in the cluster block as
certificate-authority-data (base64) instead of enabling insecure skip;
additionally, if insecureSkipTLS is true emit a clear warning message to the
user so they know they are disabling certificate verification.

1 change: 1 addition & 0 deletions client/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func init() {
rootCmd.AddCommand(forwardingRulesCmd)
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(kubeconfigCmd)

networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
Expand Down
42 changes: 42 additions & 0 deletions client/embed/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,48 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
}

// PeerIdentity contains identity information for a peer.
// This type mirrors peer.PeerIdentity from client/internal/peer/status.go
// to provide a clean public API without exposing internal types.
type PeerIdentity struct {
FQDN string
UserId string
Groups []string
}

// WhoIs returns the identity of the peer with the given IP address.
// This is used by the Kubernetes auth proxy to look up the identity
// of incoming requests based on their WireGuard IP.
func (c *Client) WhoIs(ip string) (PeerIdentity, error) {
c.mu.Lock()
connect := c.connect
c.mu.Unlock()

if connect == nil {
return PeerIdentity{}, ErrClientNotStarted
}

statusRecorder := connect.StatusRecorder()
if statusRecorder == nil {
return PeerIdentity{}, fmt.Errorf("status recorder not initialized")
}

identity, found := statusRecorder.GetPeerIdentityByIP(ip)
if !found {
return PeerIdentity{}, fmt.Errorf("peer with IP %s not found", ip)
}

// Clone the Groups slice to prevent caller from modifying internal state
groups := make([]string, len(identity.Groups))
copy(groups, identity.Groups)

return PeerIdentity{
FQDN: identity.FQDN,
UserId: identity.UserId,
Groups: groups,
}, nil
}

// getEngine safely retrieves the engine from the client with proper locking.
// Returns ErrClientNotStarted if the client is not started.
// Returns ErrEngineNotStarted if the engine is not available.
Expand Down
5 changes: 5 additions & 0 deletions client/internal/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@ func (c *ConnectClient) GetLatestSyncResponse() (*mgmProto.SyncResponse, error)
return syncResponse, nil
}

// StatusRecorder returns the status recorder
func (c *ConnectClient) StatusRecorder() *peer.Status {
return c.statusRecorder
}

// Status returns the current client status
func (c *ConnectClient) Status() StatusType {
if c == nil {
Expand Down
9 changes: 8 additions & 1 deletion client/internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,11 @@ func (e *Engine) modifyPeers(peersUpdate []*mgmProto.RemotePeerConfig) error {
if err := e.statusRecorder.UpdatePeerFQDN(peerPubKey, p.GetFqdn()); err != nil {
log.Warnf("error updating peer's %s fqdn in the status recorder, got error: %v", peerPubKey, err)
}

// Update peer identity (groups/userId) for K8s Auth Proxy impersonation
if err := e.statusRecorder.UpdatePeerIdentity(peerPubKey, p.GetGroups(), p.GetUserId()); err != nil {
log.Warnf("error updating peer's %s identity in the status recorder, got error: %v", peerPubKey, err)
}
}

// second, close all modified connections and remove them from the state map
Expand Down Expand Up @@ -1267,6 +1272,8 @@ func (e *Engine) updateOfflinePeers(offlinePeers []*mgmProto.RemotePeerConfig) {
ConnStatus: peer.StatusIdle,
ConnStatusUpdate: time.Now(),
Mux: new(sync.RWMutex),
Groups: offlinePeer.GetGroups(),
UserId: offlinePeer.GetUserId(),
}
}
e.statusRecorder.ReplaceOfflinePeers(replacement)
Expand Down Expand Up @@ -1305,7 +1312,7 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
return fmt.Errorf("create peer connection: %w", err)
}

err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerIPs[0].Addr().String())
err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn, peerIPs[0].Addr().String(), peerConfig.GetGroups(), peerConfig.GetUserId())
if err != nil {
log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
}
Expand Down
66 changes: 64 additions & 2 deletions client/internal/peer/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ type State struct {
RosenpassEnabled bool
SSHHostKey []byte
routes map[string]struct{}
// Groups contains the NetBird group names this peer belongs to.
// Used by K8s Auth Proxy to map NetBird groups to Kubernetes RBAC groups.
Groups []string
// UserId is the user identifier (typically email) of the peer's owner.
// Used by K8s Auth Proxy to impersonate the user in Kubernetes API requests.
UserId string
}

// AddRoute add a single route to routes map
Expand Down Expand Up @@ -256,21 +262,29 @@ func (d *Status) ReplaceOfflinePeers(replacement []State) {
d.peerListChangedForNotification = true
}

// AddPeer adds peer to Daemon status map
func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string) error {
// AddPeer adds peer to Daemon status map.
// The groups slice is cloned to prevent caller from modifying internal state.
func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, groups []string, userId string) error {
d.mux.Lock()
defer d.mux.Unlock()

_, ok := d.peers[peerPubKey]
if ok {
return errors.New("peer already exist")
}

// Clone the groups slice to prevent caller from modifying internal state
groupsCopy := make([]string, len(groups))
copy(groupsCopy, groups)

d.peers[peerPubKey] = State{
PubKey: peerPubKey,
IP: ip,
ConnStatus: StatusIdle,
FQDN: fqdn,
Mux: new(sync.RWMutex),
Groups: groupsCopy,
UserId: userId,
}
d.peerListChangedForNotification = true
return nil
Expand Down Expand Up @@ -300,6 +314,34 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
return "", false
}

// PeerIdentity contains identity information for a peer, used for K8s Auth Proxy impersonation.
type PeerIdentity struct {
FQDN string
UserId string
Groups []string
}

// GetPeerIdentityByIP returns peer identity information (groups, userId) by IP address.
// This is used by the K8s Auth Proxy to impersonate users based on their NetBird identity.
func (d *Status) GetPeerIdentityByIP(ip string) (PeerIdentity, bool) {
d.mux.RLock()
defer d.mux.RUnlock()

for _, state := range d.peers {
if state.IP == ip {
// Clone the Groups slice to prevent caller from modifying internal state
groups := make([]string, len(state.Groups))
copy(groups, state.Groups)
return PeerIdentity{
FQDN: state.FQDN,
UserId: state.UserId,
Groups: groups,
}, true
}
}
return PeerIdentity{}, false
}

// RemovePeer removes peer from Daemon status map
func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
Expand Down Expand Up @@ -573,6 +615,26 @@ func (d *Status) UpdatePeerFQDN(peerPubKey, fqdn string) error {
return nil
}

// UpdatePeerIdentity updates peer's groups and userId for K8s Auth Proxy impersonation.
// This is called when the management server sends updated peer config.
func (d *Status) UpdatePeerIdentity(peerPubKey string, groups []string, userId string) error {
d.mux.Lock()
defer d.mux.Unlock()

peerState, ok := d.peers[peerPubKey]
if !ok {
return errors.New("peer doesn't exist")
}

// Clone the groups slice to prevent caller from modifying internal state
peerState.Groups = make([]string, len(groups))
copy(peerState.Groups, groups)
peerState.UserId = userId
d.peers[peerPubKey] = peerState

return nil
}

// UpdatePeerSSHHostKey updates peer's SSH host key
func (d *Status) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) error {
d.mux.Lock()
Expand Down
10 changes: 5 additions & 5 deletions client/internal/peer/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ func TestAddPeer(t *testing.T) {
key := "abc"
ip := "100.108.254.1"
status := NewRecorder("https://mgm")
err := status.AddPeer(key, "abc.netbird", ip)
err := status.AddPeer(key, "abc.netbird", ip, nil, "")
assert.NoError(t, err, "shouldn't return error")

_, exists := status.peers[key]
assert.True(t, exists, "value was found")

err = status.AddPeer(key, "abc.netbird", ip)
err = status.AddPeer(key, "abc.netbird", ip, nil, "")

assert.Error(t, err, "should return error on duplicate")
}
Expand All @@ -29,7 +29,7 @@ func TestGetPeer(t *testing.T) {
key := "abc"
ip := "100.108.254.1"
status := NewRecorder("https://mgm")
err := status.AddPeer(key, "abc.netbird", ip)
err := status.AddPeer(key, "abc.netbird", ip, nil, "")
assert.NoError(t, err, "shouldn't return error")

peerStatus, err := status.GetPeer(key)
Expand All @@ -46,7 +46,7 @@ func TestUpdatePeerState(t *testing.T) {
ip := "10.10.10.10"
fqdn := "peer-a.netbird.local"
status := NewRecorder("https://mgm")
_ = status.AddPeer(key, fqdn, ip)
_ = status.AddPeer(key, fqdn, ip, nil, "")

peerState := State{
PubKey: key,
Expand Down Expand Up @@ -85,7 +85,7 @@ func TestGetPeerStateChangeNotifierLogic(t *testing.T) {
key := "abc"
ip := "10.10.10.10"
status := NewRecorder("https://mgm")
_ = status.AddPeer(key, "abc.netbird", ip)
_ = status.AddPeer(key, "abc.netbird", ip, nil, "")

sub := status.SubscribeToPeerStateChanges(context.Background(), key)
assert.NotNil(t, sub, "channel shouldn't be nil")
Expand Down
2 changes: 1 addition & 1 deletion client/internal/routemanager/client/client_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func generateBenchmarkData(tier benchmarkTier) (*peer.Status, map[route.ID]*rout
fqdn := fmt.Sprintf("peer-%d.example.com", i)
ip := fmt.Sprintf("10.0.%d.%d", i/256, i%256)

err := statusRecorder.AddPeer(peerKey, fqdn, ip)
err := statusRecorder.AddPeer(peerKey, fqdn, ip, nil, "")
if err != nil {
panic(fmt.Sprintf("failed to add peer: %v", err))
}
Expand Down
Loading