github.com/cilium/cilium@v1.16.2/pkg/kvstore/etcd_debug.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // Copyright Authors of Cilium
     3  
     4  package kvstore
     5  
     6  import (
     7  	"bytes"
     8  	"cmp"
     9  	"context"
    10  	"crypto/tls"
    11  	"crypto/x509"
    12  	"encoding/pem"
    13  	"errors"
    14  	"fmt"
    15  	"io"
    16  	"net"
    17  	"net/url"
    18  	"os"
    19  	"regexp"
    20  	"strings"
    21  
    22  	client "go.etcd.io/etcd/client/v3"
    23  	"go.uber.org/zap"
    24  	"google.golang.org/grpc"
    25  	"sigs.k8s.io/yaml"
    26  
    27  	"github.com/cilium/cilium/pkg/time"
    28  )
    29  
    30  var etcdVersionRegexp = regexp.MustCompile(`"etcdserver":"(?P<version>.*?)"`)
    31  
    32  // EtcdDbgDialer enables to override the LookupIP and DialContext functions,
    33  // e.g., to support service name to IP address resolution when CoreDNS is not
    34  // the configured DNS server --- for pods running in the host network namespace.
    35  type EtcdDbgDialer interface {
    36  	LookupIP(ctx context.Context, hostname string) ([]net.IP, error)
    37  	DialContext(ctx context.Context, addr string) (net.Conn, error)
    38  }
    39  
    40  // DefaultEtcdDbgDialer provides a default implementation of the EtcdDbgDialer interface.
    41  type DefaultEtcdDbgDialer struct{}
    42  
    43  func (DefaultEtcdDbgDialer) LookupIP(ctx context.Context, hostname string) ([]net.IP, error) {
    44  	return net.DefaultResolver.LookupIP(ctx, "ip", hostname)
    45  }
    46  
    47  func (DefaultEtcdDbgDialer) DialContext(ctx context.Context, addr string) (net.Conn, error) {
    48  	return (&net.Dialer{}).DialContext(ctx, "tcp", addr)
    49  }
    50  
    51  // EtcdDbg performs a set of sanity checks concerning the connection to the given
    52  // etcd cluster, and outputs the result in a user-friendly format.
    53  func EtcdDbg(ctx context.Context, cfgfile string, dialer EtcdDbgDialer, w io.Writer) {
    54  	iw := newIndentedWriter(w, 0)
    55  
    56  	iw.Println("📄 Configuration path: %s", cfgfile)
    57  	cfg, err := newConfig(cfgfile)
    58  	if err != nil {
    59  		iw.Println("❌ Cannot parse etcd configuration: %s", err)
    60  		return
    61  	}
    62  
    63  	iw.NewLine()
    64  	if len(cfg.Endpoints) == 0 {
    65  		iw.Println("❌ No available endpoints")
    66  	} else {
    67  		iw.Println("🔌 Endpoints:")
    68  		for _, ep := range cfg.Endpoints {
    69  			iiw := iw.WithExtraIndent(3)
    70  			iiw.Println("- %s", ep)
    71  			etcdDbgEndpoint(ctx, ep, cfg.TLS.Clone(), dialer, iiw.WithExtraIndent(2))
    72  		}
    73  	}
    74  
    75  	iw.NewLine()
    76  	iw.Println("🔑 Digital certificates:")
    77  	etcdDbgCerts(cfgfile, cfg, iw.WithExtraIndent(3))
    78  
    79  	iw.NewLine()
    80  	iw.Println("⚙ī¸ Etcd client:")
    81  	iiw := iw.WithExtraIndent(3)
    82  	cfg.Context = ctx
    83  	cfg.Logger = zap.NewNop()
    84  	cfg.DialOptions = append(cfg.DialOptions, grpc.WithBlock(), grpc.WithContextDialer(dialer.DialContext))
    85  	cfg.DialTimeout = 1 * time.Second // The client hangs in case the connection fails, hence set a short timeout.
    86  
    87  	cl, err := client.New(*cfg)
    88  	if err != nil {
    89  		iiw.Println("❌ Failed to establish connection: %s", err)
    90  		return
    91  	}
    92  	defer cl.Close()
    93  
    94  	// Try to retrieve the heartbeat key, as a basic authorization check.
    95  	// It doesn't really matter whether the heartbeat key exists or not.
    96  	out, err := cl.Get(ctx, HeartbeatPath)
    97  	if err != nil {
    98  		iiw.Println("❌ Failed to retrieve key from etcd: %s", err)
    99  		return
   100  	}
   101  
   102  	iiw.Println("✅ Etcd connection successfully established")
   103  	if out.Header != nil {
   104  		iiw.Println("ℹī¸  Etcd cluster ID: %x", out.Header.GetClusterId())
   105  	}
   106  }
   107  
   108  func etcdDbgEndpoint(ctx context.Context, ep string, tlscfg *tls.Config, dialer EtcdDbgDialer, iw *indentedWriter) {
   109  	u, err := url.Parse(ep)
   110  	if err != nil {
   111  		iw.Println("❌ Cannot parse endpoint: %s", err)
   112  		return
   113  	}
   114  
   115  	// Hostname resolution
   116  	hostname := u.Hostname()
   117  	if net.ParseIP(hostname) == nil {
   118  		ips, err := dialer.LookupIP(ctx, hostname)
   119  		if err != nil {
   120  			iw.Println("❌ Cannot resolve hostname: %s", err)
   121  		} else {
   122  			iw.Println("✅ Hostname resolved to: %s", etcdDbgOutputIPs(ips))
   123  		}
   124  	}
   125  
   126  	// TCP Connection
   127  	conn, err := dialer.DialContext(ctx, u.Host)
   128  	if err != nil {
   129  		iw.Println("❌ Cannot establish TCP connection to %s: %s", u.Host, err)
   130  		return
   131  	}
   132  
   133  	iw.Println("✅ TCP connection successfully established to %s", conn.RemoteAddr())
   134  	if u.Scheme != "https" {
   135  		conn.Close()
   136  		return
   137  	}
   138  
   139  	// TLS Connection
   140  	if tlscfg.ServerName == "" {
   141  		tlscfg.ServerName = hostname
   142  	}
   143  
   144  	// We use GetClientCertificate rather than Certificates to return an error
   145  	// in case the certificate does not match any of the requested CAs. One
   146  	// limitation, though, is that the match appears to be performed based on
   147  	// the distinguished name only, and it doesn't fail if two CAs have the same
   148  	// DN (which is typically the case with the default CA generated by Cilium).
   149  	var acceptableCAs [][]byte
   150  	tlscfg.GetClientCertificate = func(cri *tls.CertificateRequestInfo) (*tls.Certificate, error) {
   151  		for _, chain := range tlscfg.Certificates {
   152  			if err := cri.SupportsCertificate(&chain); err == nil {
   153  				return &chain, nil
   154  			}
   155  		}
   156  
   157  		acceptableCAs = cri.AcceptableCAs
   158  		return nil, fmt.Errorf("client certificate is not signed by any acceptable CA")
   159  	}
   160  
   161  	tconn := tls.Client(conn, tlscfg)
   162  	defer tconn.Close()
   163  
   164  	err = tconn.HandshakeContext(ctx)
   165  	if err != nil {
   166  		iw.Println("❌ Cannot establish TLS connection to %s: %s", u.Host, err)
   167  		if len(acceptableCAs) > 0 {
   168  			// The output is suboptimal being DER-encoded, but there doesn't
   169  			// seem to be any easy way to parse it (the utility used by
   170  			// ParseCertificate is not exported). Better than nothing though.
   171  			var buf bytes.Buffer
   172  			for i, ca := range acceptableCAs {
   173  				if i != 0 {
   174  					buf.WriteString(", ")
   175  				}
   176  				buf.WriteRune('"')
   177  				buf.WriteString(string(ca))
   178  				buf.WriteRune('"')
   179  			}
   180  
   181  			iw.Println("ℹī¸  Acceptable CAs: %s", buf.String())
   182  		}
   183  		return
   184  	}
   185  
   186  	iw.Println("✅ TLS connection successfully established to %s", tconn.RemoteAddr())
   187  	iw.Println("ℹī¸  Negotiated TLS version: %s, ciphersuite %s",
   188  		tls.VersionName(tconn.ConnectionState().Version),
   189  		tls.CipherSuiteName(tconn.ConnectionState().CipherSuite))
   190  
   191  	// With TLS 1.3, the server doesn't acknowledge whether client authentication
   192  	// succeeded, and a possible error is returned only when reading some data.
   193  	// Hence, let's trigger a request, so that we see if it failed.
   194  	tconn.SetDeadline(time.Now().Add(1 * time.Second))
   195  	data := fmt.Sprintf("GET /version HTTP/1.1\r\nHost: %s\r\n\r\n", u.Host)
   196  	_, err = tconn.Write([]byte(data))
   197  	if err != nil {
   198  		iw.Println("❌ Failed to perform a GET /version request: %s", err)
   199  		return
   200  	}
   201  
   202  	buf := make([]byte, 1000)
   203  	_, err = tconn.Read(buf)
   204  	if err != nil {
   205  		opErr := &net.OpError{}
   206  		if errors.As(err, &opErr) && opErr.Op == "remote error" {
   207  			iw.Println("❌ TLS client authentication failed: %s", err)
   208  		} else {
   209  			iw.Println("❌ Failed to retrieve GET /version answer: %s", err)
   210  		}
   211  		return
   212  	}
   213  
   214  	matches := etcdVersionRegexp.FindAllStringSubmatch(string(buf), 1)
   215  	if len(matches) != 1 {
   216  		iw.Println("⚠ī¸ Could not retrieve etcd server version")
   217  		return
   218  	}
   219  
   220  	iw.Println("ℹī¸  Etcd server version: %s", matches[0][etcdVersionRegexp.SubexpIndex("version")])
   221  }
   222  
   223  func etcdDbgCerts(cfgfile string, cfg *client.Config, iw *indentedWriter) {
   224  	if cfg.TLS.RootCAs == nil {
   225  		iw.Println("⚠ī¸ Root CA unset: using system pool")
   226  	} else {
   227  		// Retrieve the RootCA path from the configuration, as it appears
   228  		// that we cannot introspect cfg.TLS.RootCAs.
   229  		certs, err := etcdDbgRetrieveRootCAFile(cfgfile)
   230  		if err != nil {
   231  			iw.Println("❌ Failed to retrieve Root CA path: %s", err)
   232  		} else {
   233  			iw.Println("✅ TLS Root CA certificates:")
   234  			for _, cert := range certs {
   235  				parsed, err := x509.ParseCertificate(cert)
   236  				if err != nil {
   237  					iw.Println("❌ Failed to parse certificate: %s", err)
   238  					continue
   239  				}
   240  
   241  				etcdDbgOutputCert(parsed, iw.WithExtraIndent(3))
   242  			}
   243  		}
   244  	}
   245  
   246  	if len(cfg.TLS.Certificates) == 0 {
   247  		iw.Println("⚠ī¸ No available TLS client certificates")
   248  	} else {
   249  		iw.Println("✅ TLS client certificates:")
   250  		for _, cert := range cfg.TLS.Certificates {
   251  			if len(cert.Certificate) == 0 {
   252  				iw.Println("❌ The certificate looks invalid")
   253  				continue
   254  			}
   255  
   256  			leaf, err := x509.ParseCertificate(cert.Certificate[0])
   257  			if err != nil {
   258  				iw.Println("❌ Failed to parse certificate: %s", err)
   259  				continue
   260  			}
   261  
   262  			iiw := iw.WithExtraIndent(3)
   263  			etcdDbgOutputCert(leaf, iiw)
   264  			iiw = iiw.WithExtraIndent(2)
   265  
   266  			// Print intermediate certificates, if any.
   267  			intermediates := x509.NewCertPool()
   268  			for _, cert := range cert.Certificate[1:] {
   269  				iiw.Println("Intermediates:")
   270  
   271  				intermediate, err := x509.ParseCertificate(cert)
   272  				if err != nil {
   273  					iw.Println("❌ Failed to parse intermediate certificate: %s", err)
   274  					continue
   275  				}
   276  
   277  				etcdDbgOutputCert(intermediate, iiw)
   278  				intermediates.AddCert(intermediate)
   279  			}
   280  
   281  			// Attempt to verify whether the given certificate can be validated
   282  			// using the configured root CAs. Although a failure is not necessarily
   283  			// an error, as the remote etcd server may be configured with a different
   284  			// root CA, it still signals a misconfiguration in most cases.
   285  			opts := x509.VerifyOptions{
   286  				Roots:         cfg.TLS.RootCAs,
   287  				Intermediates: intermediates,
   288  			}
   289  
   290  			_, err = leaf.Verify(opts)
   291  			if err != nil {
   292  				iiw.Println("⚠ī¸ Cannot verify certificate with the configured root CAs")
   293  			}
   294  		}
   295  	}
   296  
   297  	if cfg.Username != "" {
   298  		passwd := "unset"
   299  		if cfg.Password != "" {
   300  			passwd = "set"
   301  		}
   302  
   303  		iw.Println("✅ Username set to %s, password is %s", cfg.Username, passwd)
   304  	}
   305  }
   306  
   307  func etcdDbgOutputIPs(ips []net.IP) string {
   308  	var buf bytes.Buffer
   309  	for i, ip := range ips {
   310  		if i > 0 {
   311  			buf.WriteString(", ")
   312  		}
   313  
   314  		if i == 4 {
   315  			buf.WriteString("...")
   316  			break
   317  		}
   318  
   319  		buf.WriteString(ip.String())
   320  	}
   321  	return buf.String()
   322  }
   323  
   324  func etcdDbgRetrieveRootCAFile(cfgfile string) (certs [][]byte, err error) {
   325  	var yc yamlConfig
   326  
   327  	b, err := os.ReadFile(cfgfile)
   328  	if err != nil {
   329  		return nil, err
   330  	}
   331  
   332  	err = yaml.Unmarshal(b, &yc)
   333  	if err != nil {
   334  		return nil, err
   335  	}
   336  
   337  	crtfile := cmp.Or(yc.TrustedCAfile, yc.CAfile)
   338  	if crtfile == "" {
   339  		return nil, errors.New("not provided")
   340  	}
   341  
   342  	data, err := os.ReadFile(crtfile)
   343  	if err != nil {
   344  		return nil, err
   345  	}
   346  
   347  	for {
   348  		block, rest := pem.Decode(data)
   349  		if block == nil {
   350  			if len(certs) == 0 {
   351  				return nil, errors.New("no certificate found")
   352  			}
   353  
   354  			return certs, nil
   355  		}
   356  
   357  		if block.Type == "CERTIFICATE" {
   358  			certs = append(certs, block.Bytes)
   359  		}
   360  
   361  		data = rest
   362  	}
   363  }
   364  
   365  func etcdDbgOutputCert(cert *x509.Certificate, iw *indentedWriter) {
   366  	sn := cert.SerialNumber.Text(16)
   367  	for i := 2; i < len(sn); i += 3 {
   368  		sn = sn[:i] + ":" + sn[i:]
   369  	}
   370  
   371  	iw.Println("- Serial number:       %s", string(sn))
   372  	iw.Println("  Subject:             %s", cert.Subject)
   373  	iw.Println("  Issuer:              %s", cert.Issuer)
   374  	iw.Println("  Validity:")
   375  	iw.Println("    Not before:  %s", cert.NotBefore)
   376  	iw.Println("    Not after:   %s", cert.NotAfter)
   377  }
   378  
   379  type indentedWriter struct {
   380  	w      io.Writer
   381  	indent []byte
   382  }
   383  
   384  func newIndentedWriter(w io.Writer, indent int) *indentedWriter {
   385  	return &indentedWriter{w: w, indent: []byte(strings.Repeat(" ", indent))}
   386  }
   387  
   388  func (iw *indentedWriter) NewLine() { iw.w.Write([]byte("\n")) }
   389  
   390  func (iw *indentedWriter) Println(format string, a ...any) {
   391  	iw.w.Write(iw.indent)
   392  	fmt.Fprintf(iw.w, format, a...)
   393  	iw.NewLine()
   394  }
   395  
   396  func (iw *indentedWriter) WithExtraIndent(indent int) *indentedWriter {
   397  	return newIndentedWriter(iw.w, len(iw.indent)+indent)
   398  }