Repository: nats-io/nats-server (go)

Issue:
Include TLS client information in connz output
## Feature Request
When clients connect via TLS with client side certificate validation, no information about the client is available in the HTTP monitoring endpoints. This is what the data looks like right now:

![image](https://user-images.githubusercontent.com/3098010/182106512-188f1f30-a5bd-4ffd-978f-0671c3594933.png)


#### Use Case:
Better insight into NATS server activity and its client connections.

#### Proposed Change:
Add public key, fingerprints, serial, subject to connz endpoint when query parameter `auth` is truthy.



Pull request description:
[ADDED] Monitoring: TLS Peer Certificates in Connz when auth is on
Add basic peer certificates information in /connz endpoint when
the "auth" option is provided.

```
{
  "server_id": "NDA6JPO2S27UL7QCA5OO7LP5JT324OMEYXTNEWDCSH2JRJYORSMW4HWD",
  "now": "2022-08-24T20:15:58.287276Z",
  "num_connections": 1,
  "total": 1,
  "offset": 0,
  "limit": 1024,
  "connections": [
    {
      "cid": 4,
      "kind": "Client",
      "type": "nats",
      "ip": "127.0.0.1",
      "port": 55361,
      "start": "2022-08-24T20:15:58.275853Z",
      "last_activity": "2022-08-24T20:15:58.284893Z",
      "rtt": "9.041ms",
      "uptime": "0s",
      "idle": "0s",
      "pending_bytes": 0,
      "in_msgs": 0,
      "out_msgs": 0,
      "in_bytes": 0,
      "out_bytes": 0,
      "subscriptions": 0,
      "lang": "go",
      "version": "1.16.0",
      "tls_version": "1.3",
      "tls_cipher_suite": "TLS_AES_128_GCM_SHA256",
      "tls_peer_certs": [
        {
          "subject": "CN=localhost,OU=nats.io,O=Synadia,ST=California,C=US",
          "spki_sha256": "bf6f821f09fde09451411ba3b42c0f74727d61a974c69fd3cf5257f39c75f0e9",
          "cert_sha256": "1316eeeb26856757afe1f4fc8b0621b24ca382c7bc73b285b0e282c714cf6935"
        }
      ]
    }
  ]
}
```



Resolves #3317

Signed-off-by: Ivan Kozlovic <ivan@synadia.com>


Patch:
diff --git a/server/monitor.go b/server/monitor.go
index 78e88ced..13b145b3 100644
--- a/server/monitor.go
+++ b/server/monitor.go
@@ -14,7 +14,10 @@
 package server
 
 import (
+	"crypto/sha256"
 	"crypto/tls"
+	"crypto/x509"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"net"
@@ -108,38 +111,46 @@ const (
 
 // ConnInfo has detailed information on a per connection basis.
 type ConnInfo struct {
-	Cid            uint64      `json:"cid"`
-	Kind           string      `json:"kind,omitempty"`
-	Type           string      `json:"type,omitempty"`
-	IP             string      `json:"ip"`
-	Port           int         `json:"port"`
-	Start          time.Time   `json:"start"`
-	LastActivity   time.Time   `json:"last_activity"`
-	Stop           *time.Time  `json:"stop,omitempty"`
-	Reason         string      `json:"reason,omitempty"`
-	RTT            string      `json:"rtt,omitempty"`
-	Uptime         string      `json:"uptime"`
-	Idle           string      `json:"idle"`
-	Pending        int         `json:"pending_bytes"`
-	InMsgs         int64       `json:"in_msgs"`
-	OutMsgs        int64       `json:"out_msgs"`
-	InBytes        int64       `json:"in_bytes"`
-	OutBytes       int64       `json:"out_bytes"`
-	NumSubs        uint32      `json:"subscriptions"`
-	Name           string      `json:"name,omitempty"`
-	Lang           string      `json:"lang,omitempty"`
-	Version        string      `json:"version,omitempty"`
-	TLSVersion     string      `json:"tls_version,omitempty"`
-	TLSCipher      string      `json:"tls_cipher_suite,omitempty"`
-	AuthorizedUser string      `json:"authorized_user,omitempty"`
-	Account        string      `json:"account,omitempty"`
-	Subs           []string    `json:"subscriptions_list,omitempty"`
-	SubsDetail     []SubDetail `json:"subscriptions_list_detail,omitempty"`
-	JWT            string      `json:"jwt,omitempty"`
-	IssuerKey      string      `json:"issuer_key,omitempty"`
-	NameTag        string      `json:"name_tag,omitempty"`
-	Tags           jwt.TagList `json:"tags,omitempty"`
-	MQTTClient     string      `json:"mqtt_client,omitempty"` // This is the MQTT client id
+	Cid            uint64         `json:"cid"`
+	Kind           string         `json:"kind,omitempty"`
+	Type           string         `json:"type,omitempty"`
+	IP             string         `json:"ip"`
+	Port           int            `json:"port"`
+	Start          time.Time      `json:"start"`
+	LastActivity   time.Time      `json:"last_activity"`
+	Stop           *time.Time     `json:"stop,omitempty"`
+	Reason         string         `json:"reason,omitempty"`
+	RTT            string         `json:"rtt,omitempty"`
+	Uptime         string         `json:"uptime"`
+	Idle           string         `json:"idle"`
+	Pending        int            `json:"pending_bytes"`
+	InMsgs         int64          `json:"in_msgs"`
+	OutMsgs        int64          `json:"out_msgs"`
+	InBytes        int64          `json:"in_bytes"`
+	OutBytes       int64          `json:"out_bytes"`
+	NumSubs        uint32         `json:"subscriptions"`
+	Name           string         `json:"name,omitempty"`
+	Lang           string         `json:"lang,omitempty"`
+	Version        string         `json:"version,omitempty"`
+	TLSVersion     string         `json:"tls_version,omitempty"`
+	TLSCipher      string         `json:"tls_cipher_suite,omitempty"`
+	TLSPeerCerts   []*TLSPeerCert `json:"tls_peer_certs,omitempty"`
+	AuthorizedUser string         `json:"authorized_user,omitempty"`
+	Account        string         `json:"account,omitempty"`
+	Subs           []string       `json:"subscriptions_list,omitempty"`
+	SubsDetail     []SubDetail    `json:"subscriptions_list_detail,omitempty"`
+	JWT            string         `json:"jwt,omitempty"`
+	IssuerKey      string         `json:"issuer_key,omitempty"`
+	NameTag        string         `json:"name_tag,omitempty"`
+	Tags           jwt.TagList    `json:"tags,omitempty"`
+	MQTTClient     string         `json:"mqtt_client,omitempty"` // This is the MQTT client id
+}
+
+// TLSPeerCert contains basic information about a TLS peer certificate
+type TLSPeerCert struct {
+	Subject          string `json:"subject,omitempty"`
+	SubjectPKISha256 string `json:"spki_sha256,omitempty"`
+	CertSha256       string `json:"cert_sha256,omitempty"`
 }
 
 // DefaultConnListSize is the default size of the connection list.
@@ -394,7 +405,7 @@ func (s *Server) Connz(opts *ConnzOptions) (*Connz, error) {
 	for _, client := range openClients {
 		client.mu.Lock()
 		ci := &conns[i]
-		ci.fill(client, client.nc, c.Now)
+		ci.fill(client, client.nc, c.Now, auth)
 		// Fill in subscription data if requested.
 		if len(client.subs) > 0 {
 			if subsDet {
@@ -524,7 +535,7 @@ func (s *Server) Connz(opts *ConnzOptions) (*Connz, error) {
 
 // Fills in the ConnInfo from the client.
 // client should be locked.
-func (ci *ConnInfo) fill(client *client, nc net.Conn, now time.Time) {
+func (ci *ConnInfo) fill(client *client, nc net.Conn, now time.Time, auth bool) {
 	ci.Cid = client.cid
 	ci.MQTTClient = client.getMQTTClientID()
 	ci.Kind = client.kindString()
@@ -554,6 +565,9 @@ func (ci *ConnInfo) fill(client *client, nc net.Conn, now time.Time) {
 		cs := conn.ConnectionState()
 		ci.TLSVersion = tlsVersion(cs.Version)
 		ci.TLSCipher = tlsCipher(cs.CipherSuite)
+		if auth && len(cs.PeerCertificates) > 0 {
+			ci.TLSPeerCerts = makePeerCerts(cs.PeerCertificates)
+		}
 	}
 
 	if client.port != 0 {
@@ -562,6 +576,18 @@ func (ci *ConnInfo) fill(client *client, nc net.Conn, now time.Time) {
 	}
 }
 
+func makePeerCerts(pc []*x509.Certificate) []*TLSPeerCert {
+	res := make([]*TLSPeerCert, len(pc))
+	for i, c := range pc {
+		tmp := sha256.Sum256(c.RawSubjectPublicKeyInfo)
+		ssha := hex.EncodeToString(tmp[:])
+		tmp = sha256.Sum256(c.Raw)
+		csha := hex.EncodeToString(tmp[:])
+		res[i] = &TLSPeerCert{Subject: c.Subject.String(), SubjectPKISha256: ssha, CertSha256: csha}
+	}
+	return res
+}
+
 // Assume lock is held
 func (c *client) getRTT() time.Duration {
 	if c.rtt == 0 {
@@ -1869,7 +1895,7 @@ func createOutboundRemoteGatewayz(c *client, opts *GatewayzOptions, now time.Tim
 			rgw.IsConfigured = !c.gw.cfg.isImplicit()
 		}
 		rgw.Connection = &ConnInfo{}
-		rgw.Connection.fill(c, c.nc, now)
+		rgw.Connection.fill(c, c.nc, now, false)
 		name = c.gw.name
 	}
 	c.mu.Unlock()
@@ -1953,7 +1979,7 @@ func (s *Server) createInboundsRemoteGatewayz(opts *GatewayzOptions, now time.Ti
 				rgw.Accounts = createInboundAccountsGatewayz(opts, c.gw)
 			}
 			rgw.Connection = &ConnInfo{}
-			rgw.Connection.fill(c, c.nc, now)
+			rgw.Connection.fill(c, c.nc, now, false)
 			igws = append(igws, rgw)
 			m[c.gw.name] = igws
 		}
diff --git a/server/server.go b/server/server.go
index 3515daf4..e7beb92d 100644
--- a/server/server.go
+++ b/server/server.go
@@ -2655,7 +2655,7 @@ func (s *Server) saveClosedClient(c *client, nc net.Conn, reason ClosedState) {
 	c.mu.Lock()
 
 	cc := &closedClient{}
-	cc.fill(c, nc, now)
+	cc.fill(c, nc, now, false)
 	cc.Stop = &now
 	cc.Reason = reason.String()
 


Test patch:
diff --git a/server/monitor_test.go b/server/monitor_test.go
index d6f756f0..8641ebaf 100644
--- a/server/monitor_test.go
+++ b/server/monitor_test.go
@@ -2186,6 +2186,62 @@ func TestConnzTLSCfg(t *testing.T) {
 	}
 }
 
+func TestConnzTLSPeerCerts(t *testing.T) {
+	resetPreviousHTTPConnections()
+
+	tc := &TLSConfigOpts{}
+	tc.CertFile = "../test/configs/certs/server-cert.pem"
+	tc.KeyFile = "../test/configs/certs/server-key.pem"
+	tc.CaFile = "../test/configs/certs/ca.pem"
+	tc.Verify = true
+	tc.Timeout = 2.0
+
+	var err error
+	opts := DefaultMonitorOptions()
+	opts.TLSConfig, err = GenTLSConfig(tc)
+	require_NoError(t, err)
+
+	s := RunServer(opts)
+	defer s.Shutdown()
+
+	nc := natsConnect(t, s.ClientURL(),
+		nats.ClientCert("../test/configs/certs/client-cert.pem", "../test/configs/certs/client-key.pem"),
+		nats.RootCAs("../test/configs/certs/ca.pem"))
+	defer nc.Close()
+
+	endpoint := fmt.Sprintf("http://%s:%d/connz", opts.HTTPHost, s.MonitorAddr().Port)
+	for mode := 0; mode < 2; mode++ {
+		// Without "auth" option, we should not get the details
+		connz := pollConz(t, s, mode, endpoint, nil)
+		require_True(t, len(connz.Conns) == 1)
+		c := connz.Conns[0]
+		if c.TLSPeerCerts != nil {
+			t.Fatalf("Did not expect TLSPeerCerts when auth is not specified: %+v", c.TLSPeerCerts)
+		}
+		// Now specify "auth" option
+		connz = pollConz(t, s, mode, endpoint+"?auth=1", &ConnzOptions{Username: true})
+		require_True(t, len(connz.Conns) == 1)
+		c = connz.Conns[0]
+		if c.TLSPeerCerts == nil {
+			t.Fatal("Expected TLSPeerCerts to be set, was not")
+		} else if len(c.TLSPeerCerts) != 1 {
+			t.Fatalf("Unexpected peer certificates: %+v", c.TLSPeerCerts)
+		} else {
+			for _, d := range c.TLSPeerCerts {
+				if d.Subject != "CN=localhost,OU=nats.io,O=Synadia,ST=California,C=US" {
+					t.Fatalf("Unexpected subject: %s", d.Subject)
+				}
+				if len(d.SubjectPKISha256) != 64 {
+					t.Fatalf("Unexpected spki_sha256: %s", d.SubjectPKISha256)
+				}
+				if len(d.CertSha256) != 64 {
+					t.Fatalf("Unexpected cert_sha256: %s", d.CertSha256)
+				}
+			}
+		}
+	}
+}
+
 func TestServerIDs(t *testing.T) {
 	s := runMonitorServer()
 	defer s.Shutdown()