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>
This commit is contained in:
Matt RichardsonandClaude Fable 5.1 committed 2026-10-21 12:27:12 -04:00
1 parent e0dc742e17
commit 660a271466
7 files changed
+156 -17

No files matched your search

+41 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+15 -2
View File
@@ -779,7 +779,7 @@ whoami - Prints the certificate identity of this node
$ help -json
[{"name":"change-remote","short":"Changes the remote address used in the tunnel for the provided vpn addr","flags":[{"name":"address","usage":"The new remote address, ip:port"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"close-tunnel","short":"Closes a tunnel for the provided vpn addr","flags":[{"name":"local-only","bool":true,"default":"false","usage":"Disables notifying the remote that the tunnel is shutting down"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"create-tunnel","short":"Creates a tunnel for the provided vpn address","help":"The lighthouses will be queried for real addresses but you can provide one as well.","flags":[{"name":"address","usage":"Optionally provide a real remote address, ip:port "},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"device-info","short":"Prints information about the network device.","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"help","short":"prints available commands or help \u003ccommand\u003e for specific usage info","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"identity","short":"Prints the certificate identity of the host owning the provided vpn addr","help":"Identity is what the certificate says: name, vpn addrs, networks, groups, fingerprint, issuer and validity. The address may carry a port, so a connection's remote address can be passed through as is. For this node's own identity use whoami.","flags":[{"name":"address","usage":"The vpn addr to look up, optionally with a port"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-hostmap","short":"List all known previously connected hosts","flags":[{"name":"by-index","bool":true,"default":"false","usage":"gets all hosts in the hostmap from the index table"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-lighthouse-addrmap","short":"List all lighthouse map entries","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-pending-hostmap","short":"List all handshaking hosts","flags":[{"name":"by-index","bool":true,"default":"false","usage":"gets all hosts in the hostmap from the index table"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"log-format","short":"Gets or sets the current log format","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"log-level","short":"Gets or sets the current log level","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"logout","short":"Ends the current session","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"print-cert","short":"Prints the current certificate being used or the certificate for the provided vpn addr","flags":[{"name":"raw","bool":true,"default":"false","usage":"prints the PEM encoded certificate; with -json, an object holding it"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"print-relays","short":"Prints json details about all relay info","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"print-tunnel","short":"Prints json details about a tunnel for the provided vpn addr","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"query-lighthouse","short":"Query the lighthouses for the provided vpn address","help":"This command Line truncated
[{"name":"change-remote","short":"Changes the remote address used in the tunnel for the provided vpn addr","flags":[{"name":"address","usage":"The new remote address, ip:port"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"close-tunnel","short":"Closes a tunnel for the provided vpn addr","flags":[{"name":"local-only","bool":true,"default":"false","usage":"Disables notifying the remote that the tunnel is shutting down"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"create-tunnel","short":"Creates a tunnel for the provided vpn address","help":"The lighthouses will be queried for real addresses but you can provide one as well.","flags":[{"name":"address","usage":"Optionally provide a real remote address, ip:port "},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"device-info","short":"Prints information about the network device.","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"help","short":"prints available commands or help \u003ccommand\u003e for specific usage info","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"identity","short":"Prints the certificate identity of the host owning the provided vpn addr","help":"Identity is what the certificate says: name, vpn addrs, networks, groups, fingerprint, issuer and validity. The address may carry a port, so a connection's remote address can be passed through as is. For this node's own identity use whoami.","flags":[{"name":"address","usage":"The vpn addr to look up, optionally with a port"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-hostmap","short":"List all known previously connected hosts","flags":[{"name":"by-index","bool":true,"default":"false","usage":"gets all hosts in the hostmap from the index table"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-lighthouse-addrmap","short":"List all lighthouse map entries","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"list-pending-hostmap","short":"List all handshaking hosts","flags":[{"name":"by-index","bool":true,"default":"false","usage":"gets all hosts in the hostmap from the index table"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"log-format","short":"Gets or sets the current log format","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"log-level","short":"Gets or sets the current log level","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"logout","short":"Ends the current session","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"print-cert","short":"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.","flags":[{"name":"address","usage":"The vpn addr to look up, optionally with a port"},{"name":"raw","bool":true,"default":"false","usage":"prints the PEM encoded certificate; with -json, an object holding it"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]},{"name":"print-relays","short":"Prints json details about all relay info","flags":[{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, aLine truncated
$ help -pretty
[
@@ -1034,7 +1034,12 @@ $ help -pretty
{
"name": "print-cert",
"short": "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.",
"flags": [
{
"name": "address",
"usage": "The vpn addr to look up, optionally with a port"
},
{
"name": "raw",
"bool": true,
@@ -1169,6 +1174,9 @@ $ help -pretty
$ help print-cert
print-cert - Prints the current certificate being used or the certificate for the provided vpn addr
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.
-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
@@ -1177,13 +1185,18 @@ print-cert - Prints the current certificate being used or the certificate for th
pretty prints json, assumes -json
$ help -json print-cert
{"name":"print-cert","short":"Prints the current certificate being used or the certificate for the provided vpn addr","flags":[{"name":"raw","bool":true,"default":"false","usage":"prints the PEM encoded certificate; with -json, an object holding it"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]}
{"name":"print-cert","short":"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.","flags":[{"name":"address","usage":"The vpn addr to look up, optionally with a port"},{"name":"raw","bool":true,"default":"false","usage":"prints the PEM encoded certificate; with -json, an object holding it"},{"name":"json","bool":true,"default":"false","usage":"outputs as json"},{"name":"pretty","bool":true,"default":"false","usage":"pretty prints json, assumes -json"}]}
$ help -pretty print-cert
{
"name": "print-cert",
"short": "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.",
"flags": [
{
"name": "address",
"usage": "The vpn addr to look up, optionally with a port"
},
{
"name": "raw",
"bool": true,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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