فهرست منبع

Serve print-cert on the info API, with -address and preferring our v2 certificate

A program that verifies certificates against its own CAs, as
nebula-auth-request does, needs the certificate itself rather than the
parsed identity, and it asked the ssh debug server for it. The info API
now serves print-cert, so it can ask there instead.

- print-cert declares Identity: it reads a certificate from the hostmap or our own, and nothing operational
- -address names the vpn addr, with a port accepted and stripped and 4in6 unmapped, as identity takes it; the operand stays, and giving both is refused
- an address of our own answers with our own certificate, and a tunnel without one is not found, a 404 on the API
- with no address the certificate is the one whoami reports, the v2 when both versions are loaded, not the one handshakes start with
- GET /v1/print-cert?address=<addr>&raw=true answers {"pem": ...}; example config, CHANGELOG, surface test, goldens and an e2e check

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Matt Richardson 5 ساعت پیش
والد
کامیت
6b7250d1c9
7فایلهای تغییر یافته به همراه141 افزوده شده و 15 حذف شده
  1. 41 8
      commands.go
  2. 63 0
      commands_test.go
  3. 30 5
      e2e/info_api_test.go
  4. 0 0
      e2e/testdata/commands_output.golden
  5. 3 0
      examples/config.yml
  6. 1 1
      ssh_commands_test.go
  7. 3 1
      testdata/ssh_commands.golden

+ 41 - 8
commands.go

@@ -28,6 +28,17 @@ type listHostMapFlags struct {
 
 type printCertFlags struct {
 	Raw bool
+
+	// Address is the -address form of the vpn addr, which the info API needs since a
+	// query string has no operands; see identityFlags for why presence is recorded.
+	Address    string
+	addressSet bool
+}
+
+func (f *printCertFlags) setAddress(v string) error {
+	f.Address = v
+	f.addressSet = true
+	return nil
 }
 
 type changeRemoteFlags struct {
@@ -176,9 +187,12 @@ func attachCommands(l *slog.Logger, c *config.C, reg *commands.Registry, f *Inte
 	reg.Register(commands.Flagged(&commands.Command{
 		Name:             commands.PrintCert,
 		ShortDescription: "Prints the current certificate being used or the certificate for the provided vpn addr",
+		Help:             "The vpn addr is an operand or -address, which may carry a port so a connection's remote address can be passed through as is. For a peer it is the certificate nebula verified when the tunnel came up; with no address it is this node's own, the v2 certificate when both versions are loaded. -raw prints the PEM.",
+		Identity:         true,
 		Text:             textPrintCert,
 	}, func(fl *flag.FlagSet, s *printCertFlags) {
 		fl.BoolVar(&s.Raw, "raw", false, "prints the PEM encoded certificate; with -json, an object holding it")
+		fl.Func("address", "The vpn addr to look up, optionally with a port", s.setAddress)
 	}, func(s *printCertFlags, a []string) (commands.Result, error) {
 		return runPrintCert(f, s, a)
 	}))
@@ -513,20 +527,39 @@ func textLogFormat(fi logFormatInfo, w io.Writer) error {
 	return err
 }
 
+// runPrintCert prints a certificate: this node's own, or the one the host owning a vpn
+// addr presented. The addr is an operand, as it always was over ssh, or -address, which
+// is how the info API names it, and an address of our own answers with our own
+// certificate as identity does. A tunnel still handshaking, or one being torn down, has
+// no certificate and is reported as not found.
 func runPrintCert(ifce *Interface, args *printCertFlags, a []string) (commands.Result, error) {
+	if len(a) > 0 && args.addressSet {
+		return commands.Failed("print-cert takes one vpn addr, as -address or as an operand, not both"), nil
+	}
 
-	c := ifce.pki.getCertState().GetDefaultCertificate()
-	if len(a) > 0 {
-		hostInfo, res, ok := tunnelOperand(ifce, a, "vpn addr")
+	cs := ifce.pki.getCertState()
+	var c cert.Certificate
+	switch {
+	case len(a) > 0:
+		vpnAddr, res, ok := vpnAddrOperand(a, "vpn addr")
 		if !ok {
 			return res, nil
 		}
-		// A tunnel still handshaking, or one being torn down, has no certificate.
-		cc := hostInfo.GetCert()
-		if cc == nil {
-			return commands.Failed("Could not find tunnel for vpn addr: %v", a[0]), nil
+		if c, _ = findCertificateForVpnAddr(cs, ifce.hostMap, vpnAddr); c == nil {
+			return commands.NotFound("Could not find tunnel for vpn addr: %v", a[0]), nil
+		}
+	case args.addressSet:
+		vpnAddr, err := parseVpnAddrWithOptionalPort(args.Address)
+		if err != nil {
+			return commands.Failed("The provided vpn addr could not be parsed: %s", args.Address), nil
+		}
+		if c, _ = findCertificateForVpnAddr(cs, ifce.hostMap, vpnAddr); c == nil {
+			return commands.NotFound("Could not find tunnel for vpn addr: %v", args.Address), nil
+		}
+	default:
+		if c = ownCertificate(cs); c == nil {
+			return commands.Failed("No certificate is loaded"), nil
 		}
-		c = cc.Certificate
 	}
 
 	if args.Raw {

+ 63 - 0
commands_test.go

@@ -6,7 +6,10 @@ import (
 	"io"
 	"net/netip"
 	"testing"
+	"time"
 
+	"github.com/slackhq/nebula/cert"
+	"github.com/slackhq/nebula/cert_test"
 	"github.com/slackhq/nebula/commands"
 	"github.com/slackhq/nebula/logging"
 	"github.com/slackhq/nebula/test"
@@ -80,3 +83,63 @@ func TestPrintCertOfATunnelWithoutACertificate(t *testing.T) {
 	require.NoError(t, err)
 	assert.True(t, r.OK, "a tunnel with a certificate is printed")
 }
+
+// TestPrintCertByAddressFlag: -address is the info API's spelling of the operand, takes a
+// port and a 4in6 form the way identity does, answers our own address with our own
+// certificate, and is refused together with an operand.
+func TestPrintCertByAddressFlag(t *testing.T) {
+	f, peerCert := identityFixture(t)
+
+	for _, addr := range []string{"10.0.0.2", "10.0.0.2:4242", "[::ffff:10.0.0.2]:4242"} {
+		fl := &printCertFlags{}
+		require.NoError(t, fl.setAddress(addr))
+		r, err := runPrintCert(f, fl, nil)
+		require.NoError(t, err)
+		require.True(t, r.OK, addr)
+		assert.Equal(t, peerCert, r.Value, addr)
+	}
+
+	fl := &printCertFlags{}
+	require.NoError(t, fl.setAddress("10.0.0.1"))
+	r, err := runPrintCert(f, fl, nil)
+	require.NoError(t, err)
+	require.True(t, r.OK)
+	assert.Equal(t, ownCertificate(f.pki.getCertState()), r.Value, "our own address is our own certificate")
+
+	fl = &printCertFlags{}
+	require.NoError(t, fl.setAddress("10.0.0.3"))
+	r, err = runPrintCert(f, fl, nil)
+	require.NoError(t, err)
+	assert.False(t, r.OK)
+	assert.True(t, r.NotFound)
+
+	fl = &printCertFlags{}
+	require.NoError(t, fl.setAddress("not-an-address"))
+	r, err = runPrintCert(f, fl, nil)
+	require.NoError(t, err)
+	assert.Equal(t, "The provided vpn addr could not be parsed: not-an-address", r.Reason)
+
+	fl = &printCertFlags{}
+	require.NoError(t, fl.setAddress("10.0.0.2"))
+	r, err = runPrintCert(f, fl, []string{"10.0.0.2"})
+	require.NoError(t, err)
+	assert.Equal(t, "print-cert takes one vpn addr, as -address or as an operand, not both", r.Reason)
+}
+
+// TestPrintCertPrefersOwnV2: with no address print-cert reports the certificate whoami
+// does, the v2 when both versions are loaded, rather than the one handshakes start with.
+func TestPrintCertPrefersOwnV2(t *testing.T) {
+	v4 := netip.MustParsePrefix("10.0.0.1/24")
+	ca1, _, caKey1, _ := cert_test.NewTestCaCert(cert.Version1, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	v1, _, _, _ := cert_test.NewTestCert(cert.Version1, cert.Curve_CURVE25519, ca1, caKey1, "me", time.Time{}, time.Time{}, []netip.Prefix{v4}, nil, nil)
+	ca2, _, caKey2, _ := cert_test.NewTestCaCert(cert.Version2, cert.Curve_CURVE25519, time.Time{}, time.Time{}, nil, nil, nil)
+	v2, _, _, _ := cert_test.NewTestCert(cert.Version2, cert.Curve_CURVE25519, ca2, caKey2, "me", time.Time{}, time.Time{}, []netip.Prefix{v4}, nil, nil)
+
+	f := &Interface{pki: &PKI{}, hostMap: newHostMap(test.NewLogger())}
+	f.pki.cs.Store(&CertState{v1Cert: v1, v2Cert: v2, initiatingVersion: cert.Version1})
+
+	r, err := runPrintCert(f, &printCertFlags{}, nil)
+	require.NoError(t, err)
+	require.True(t, r.OK)
+	assert.Equal(t, v2, r.Value)
+}

+ 30 - 5
e2e/info_api_test.go

@@ -64,11 +64,12 @@ func TestIdentityOverTheInfoAPI(t *testing.T) {
 	assertTunnel(t, myVpnIpNet[0].Addr(), theirVpnIpNet[0].Addr(), myControl, theirControl, r)
 
 	type identity struct {
-		Name     string         `json:"name"`
-		VpnAddrs []netip.Addr   `json:"vpnAddrs"`
-		Networks []netip.Prefix `json:"networks"`
-		Groups   []string       `json:"groups"`
-		Issuer   string         `json:"issuer"`
+		Name        string         `json:"name"`
+		VpnAddrs    []netip.Addr   `json:"vpnAddrs"`
+		Networks    []netip.Prefix `json:"networks"`
+		Groups      []string       `json:"groups"`
+		Issuer      string         `json:"issuer"`
+		Fingerprint string         `json:"fingerprint"`
 	}
 
 	// A connection's RemoteAddr, port and all, resolves to the peer.
@@ -102,6 +103,30 @@ func TestIdentityOverTheInfoAPI(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, status)
 	assert.JSONEq(t, `{"error":"The provided vpn addr could not be parsed: "}`, body)
 
+	// The certificate itself, for a program that verifies it against its own CAs: the one
+	// nebula verified when the tunnel came up, as PEM, by the address a connection carries.
+	status, body = test.HTTPGet(t, path, "/v1/print-cert?address="+netip.AddrPortFrom(theirVpnIpNet[0].Addr(), 51234).String()+"&raw=true")
+	require.Equal(t, http.StatusOK, status, body)
+	var raw struct {
+		PEM string `json:"pem"`
+	}
+	require.NoError(t, json.Unmarshal([]byte(body), &raw))
+	theirs, _, err := cert.UnmarshalCertificateFromPEM([]byte(raw.PEM))
+	require.NoError(t, err)
+	assert.Equal(t, "them", theirs.Name())
+	fp, err := theirs.Fingerprint()
+	require.NoError(t, err)
+	assert.Equal(t, them.Fingerprint, fp, "the same certificate identity reports")
+
+	// Without raw it is the certificate's fields; without an address, our own.
+	status, body = test.HTTPGet(t, path, "/v1/print-cert")
+	require.Equal(t, http.StatusOK, status, body)
+	assert.Contains(t, body, `"name":"me"`)
+
+	status, body = test.HTTPGet(t, path, "/v1/print-cert?address=10.128.0.9")
+	assert.Equal(t, http.StatusNotFound, status)
+	assert.JSONEq(t, `{"error":"Could not find tunnel for vpn addr: 10.128.0.9"}`, body)
+
 	// Nothing else nebula serves is reachable here.
 	status, body = test.HTTPGet(t, path, "/v1/list-hostmap")
 	assert.Equal(t, http.StatusForbidden, status)

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
e2e/testdata/commands_output.golden


+ 3 - 0
examples/config.yml

@@ -262,6 +262,9 @@ punchy:
   #                                               is ignored. 404 when no tunnel is
   #                                               up, 400 when no address is given.
   #   GET /v1/whoami                              this node's identity
+  #   GET /v1/print-cert?address=<vpn addr>[:port]&raw=true
+  #                                               the certificate for the specified
+  #                                               address, as {"pem": ...}.
   #   GET /v1                                     describes what is served
   #
   # NB: unless a connection arrived on the nebula interface, there's no guarantee

+ 1 - 1
ssh_commands_test.go

@@ -115,5 +115,5 @@ func TestInfoAPISurface(t *testing.T) {
 		}
 	}
 
-	assert.ElementsMatch(t, []commands.Name{commands.Identity, commands.Whoami}, served)
+	assert.ElementsMatch(t, []commands.Name{commands.PrintCert, commands.Identity, commands.Whoami}, served)
 }

+ 3 - 1
testdata/ssh_commands.golden

@@ -100,8 +100,10 @@ log-level
     	pretty prints json, assumes -json
 print-cert
   short: Prints the current certificate being used or the certificate for the provided vpn addr
-  help: 
+  help: The vpn addr is an operand or -address, which may carry a port so a connection's remote address can be passed through as is. For a peer it is the certificate nebula verified when the tunnel came up; with no address it is this node's own, the v2 certificate when both versions are loaded. -raw prints the PEM.
   flags:
+  -address string
+    	The vpn addr to look up, optionally with a port
   -raw
     	prints the PEM encoded certificate; with -json, an object holding it
   -json

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است