diff --git a/cmd/server/main.go b/cmd/server/main.go index 014278f4..4fd48d2c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,7 @@ func printHelp() { fmt.Println("\t--tls\t\t\tEnable TLS on socket (ssh/http over TLS)") fmt.Println("\t--tlscert\t\tTLS certificate path") fmt.Println("\t--tlskey\t\tTLS key path") + fmt.Println("\t--trusted-proxy-cidr\tTrust X-Forwarded-For/X-Real-IP only from this CIDR; may be repeated or comma-separated") fmt.Println("\t--webserver\t\t(Depreciated) Enable webserver on the listen_address port") fmt.Println("\t--enable-client-downloads\t\tEnable webserver and raw TCP to download clients") fmt.Println("\t--external_address\tIf the external IP and port of the RSSH server is different from the listening address, set that here") @@ -46,6 +47,7 @@ func main() { "tls": true, "tlscert": true, "tlskey": true, + "trusted-proxy-cidr": true, "external_address": true, "fingerprint": true, "webserver": true, // deprecated @@ -164,6 +166,12 @@ func main() { tls := options.IsSet("tls") tlscert, _ := options.GetArgString("tlscert") tlskey, _ := options.GetArgString("tlskey") + trustedProxyCIDRs, err := parseTrustedProxyCIDRs(options) + if err != nil { + fmt.Println(err) + printHelp() + return + } enabledDownloads := options.IsSet("webserver") || options.IsSet("enable-client-downloads") @@ -211,5 +219,57 @@ func main() { log.Println("connect back: ", connectBackAddress) - server.Run(listenAddress, dataDir, connectBackAddress, autogeneratedConnectBack, tlscert, tlskey, insecure, enabledDownloads, tls, openproxy, timeout) + server.Run(listenAddress, dataDir, connectBackAddress, autogeneratedConnectBack, tlscert, tlskey, insecure, enabledDownloads, tls, openproxy, timeout, trustedProxyCIDRs) +} + +func parseTrustedProxyCIDRs(options terminal.ParsedLine) ([]*net.IPNet, error) { + values, err := options.GetArgsString("trusted-proxy-cidr") + if err == terminal.ErrFlagNotSet { + return nil, nil + } + if err != nil { + return nil, err + } + return parseTrustedProxyCIDRValues(values) +} + +func parseTrustedProxyCIDRValues(values []string) ([]*net.IPNet, error) { + var cidrs []*net.IPNet + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + cidr, err := parseTrustedProxyCIDR(part) + if err != nil { + return nil, err + } + cidrs = append(cidrs, cidr) + } + } + return cidrs, nil +} + +func parseTrustedProxyCIDR(value string) (*net.IPNet, error) { + _, cidr, err := net.ParseCIDR(value) + if err == nil { + return cidr, nil + } + + ip := net.ParseIP(value) + if ip == nil { + return nil, fmt.Errorf("invalid --trusted-proxy-cidr %q", value) + } + if ip.To4() != nil { + value += "/32" + } else { + value += "/128" + } + + _, cidr, err = net.ParseCIDR(value) + if err != nil { + return nil, fmt.Errorf("invalid --trusted-proxy-cidr %q", value) + } + return cidr, nil } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 00000000..e384970c --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "sort" + "strings" + "testing" + + "github.com/NHAS/reverse_ssh/internal/terminal" +) + +func TestParseTrustedProxyCIDRs(t *testing.T) { + line := terminal.ParseLine("--trusted-proxy-cidr 192.0.2.0/24,198.51.100.10 --trusted-proxy-cidr 2001:db8::1", 0) + + got, err := parseTrustedProxyCIDRs(line) + if err != nil { + t.Fatal(err) + } + + want := []string{"192.0.2.0/24", "198.51.100.10/32", "2001:db8::1/128"} + if len(got) != len(want) { + t.Fatalf("got %d CIDRs, want %d", len(got), len(want)) + } + gotStrings := make([]string, 0, len(got)) + for _, cidr := range got { + gotStrings = append(gotStrings, cidr.String()) + } + sort.Strings(gotStrings) + sort.Strings(want) + for i := range want { + if gotStrings[i] != want[i] { + t.Fatalf("CIDR %d = %q, want %q", i, gotStrings[i], want[i]) + } + } +} + +func TestParseTrustedProxyCIDRsUnset(t *testing.T) { + line := terminal.ParseLine(":2222", 0) + + got, err := parseTrustedProxyCIDRs(line) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestParseTrustedProxyCIDRsInvalid(t *testing.T) { + _, err := parseTrustedProxyCIDRValues([]string{"not-an-ip"}) + if err == nil { + t.Fatal("expected invalid CIDR error") + } + if !strings.Contains(err.Error(), "invalid --trusted-proxy-cidr") { + t.Fatalf("error = %q", err) + } +} diff --git a/internal/server/observers/clientstate.go b/internal/server/observers/clientstate.go index 52729028..b0485ebd 100644 --- a/internal/server/observers/clientstate.go +++ b/internal/server/observers/clientstate.go @@ -9,12 +9,15 @@ import ( ) type ClientState struct { - Status string - ID string - IP string - HostName string - Version string - Timestamp time.Time + Status string + ID string + IP string + HostName string + Version string + Timestamp time.Time + Transport string `json:",omitempty"` + PublicKeyFingerprint string `json:",omitempty"` + ProxySourceIP string `json:",omitempty"` } func (cs ClientState) Summary() string { diff --git a/internal/server/observers/clientstate_test.go b/internal/server/observers/clientstate_test.go new file mode 100644 index 00000000..16aebaa6 --- /dev/null +++ b/internal/server/observers/clientstate_test.go @@ -0,0 +1,56 @@ +package observers + +import ( + "strings" + "testing" + "time" +) + +func TestClientStateJSONIncludesOptionalTransportFields(t *testing.T) { + payload, err := ClientState{ + Status: "connected", + ID: "id1", + IP: "198.51.100.10:0", + HostName: "host", + Version: "SSH-test", + Timestamp: time.Unix(0, 0).UTC(), + Transport: "wss", + PublicKeyFingerprint: "fp", + ProxySourceIP: "192.0.2.10", + }.Json() + if err != nil { + t.Fatal(err) + } + + body := string(payload) + for _, want := range []string{ + `"Transport":"wss"`, + `"PublicKeyFingerprint":"fp"`, + `"ProxySourceIP":"192.0.2.10"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("payload missing %s: %s", want, body) + } + } +} + +func TestClientStateJSONOmitsEmptyOptionalTransportFields(t *testing.T) { + payload, err := ClientState{ + Status: "connected", + ID: "id1", + IP: "198.51.100.10:0", + HostName: "host", + Version: "SSH-test", + Timestamp: time.Unix(0, 0).UTC(), + }.Json() + if err != nil { + t.Fatal(err) + } + + body := string(payload) + for _, field := range []string{"Transport", "PublicKeyFingerprint", "ProxySourceIP"} { + if strings.Contains(body, field) { + t.Fatalf("payload should omit %s: %s", field, body) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9a8626f7..7ed46742 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -47,7 +47,7 @@ func CreateOrLoadServerKeys(privateKeyPath string) (ssh.Signer, error) { return private, nil } -func Run(addr, dataDir, connectBackAddress string, autogeneratedConnectBack bool, TLSCertPath, TLSKeyPath string, insecure, enabledDownloads, enabletTLS, openproxy bool, timeout int) { +func Run(addr, dataDir, connectBackAddress string, autogeneratedConnectBack bool, TLSCertPath, TLSKeyPath string, insecure, enabledDownloads, enabletTLS, openproxy bool, timeout int, trustedProxyCIDRs []*net.IPNet) { c := mux.MultiplexerConfig{ Control: true, Downloads: enabledDownloads, @@ -56,6 +56,7 @@ func Run(addr, dataDir, connectBackAddress string, autogeneratedConnectBack bool TLSKeyPath: TLSKeyPath, AutoTLSCommonName: connectBackAddress, TcpKeepAlive: timeout, + TrustedProxyCIDRs: trustedProxyCIDRs, PollingAuthChecker: func(key string, addr net.Addr) bool { authorizedKey, err := hex.DecodeString(key) diff --git a/internal/server/sshd.go b/internal/server/sshd.go index 1438940f..56b8c100 100644 --- a/internal/server/sshd.go +++ b/internal/server/sshd.go @@ -18,6 +18,7 @@ import ( "github.com/NHAS/reverse_ssh/internal/server/observers" "github.com/NHAS/reverse_ssh/internal/server/users" "github.com/NHAS/reverse_ssh/pkg/logger" + "github.com/NHAS/reverse_ssh/pkg/mux" "github.com/fatih/color" "golang.org/x/crypto/ssh" ) @@ -389,6 +390,8 @@ func getIP(ip string) net.IP { func acceptConn(c net.Conn, config *ssh.ServerConfig, timeout int, dataDir string) { + connMetadata := mux.Metadata(c) + //Initially set the timeout high, so people who type in their ssh key password can actually use rssh realConn := &internal.TimeoutConn{Conn: c, Timeout: time.Duration(timeout) * time.Minute} @@ -470,24 +473,30 @@ func acceptConn(c net.Conn, config *ssh.ServerConfig, timeout int, dataDir strin users.DisassociateClient(id, sshConn) observers.ConnectionState.Notify(observers.ClientState{ - Status: "disconnected", - ID: id, - IP: sshConn.RemoteAddr().String(), - HostName: username, - Version: string(sshConn.ClientVersion()), - Timestamp: time.Now(), + Status: "disconnected", + ID: id, + IP: sshConn.RemoteAddr().String(), + HostName: username, + Version: string(sshConn.ClientVersion()), + Timestamp: time.Now(), + Transport: connMetadata.Transport, + PublicKeyFingerprint: sshConn.Permissions.Extensions["pubkey-fp"], + ProxySourceIP: connMetadata.ProxySourceIP, }) }() clientLog.Info("New controllable connection from %s with id %s", color.BlueString(username), color.YellowString(id)) observers.ConnectionState.Notify(observers.ClientState{ - Status: "connected", - ID: id, - IP: sshConn.RemoteAddr().String(), - HostName: username, - Version: string(sshConn.ClientVersion()), - Timestamp: time.Now(), + Status: "connected", + ID: id, + IP: sshConn.RemoteAddr().String(), + HostName: username, + Version: string(sshConn.ClientVersion()), + Timestamp: time.Now(), + Transport: connMetadata.Transport, + PublicKeyFingerprint: sshConn.Permissions.Extensions["pubkey-fp"], + ProxySourceIP: connMetadata.ProxySourceIP, }) case "proxy": diff --git a/pkg/mux/bufferedConn.go b/pkg/mux/bufferedConn.go index 1b88b8ff..3b32127a 100644 --- a/pkg/mux/bufferedConn.go +++ b/pkg/mux/bufferedConn.go @@ -46,6 +46,10 @@ func (bc *bufferedConn) RemoteAddr() net.Addr { return bc.conn.RemoteAddr() } +func (bc *bufferedConn) Metadata() ConnectionMetadata { + return Metadata(bc.conn) +} + func (bc *bufferedConn) SetDeadline(t time.Time) error { return bc.conn.SetDeadline(t) } diff --git a/pkg/mux/fragmentedConn.go b/pkg/mux/fragmentedConn.go index c72f2f06..7d75b3a3 100644 --- a/pkg/mux/fragmentedConn.go +++ b/pkg/mux/fragmentedConn.go @@ -21,13 +21,14 @@ type fragmentedConnection struct { localAddr net.Addr remoteAddr net.Addr + metadata ConnectionMetadata isDead *time.Timer onClose func() } -func NewFragmentCollector(localAddr net.Addr, remoteAddr net.Addr, onClosed func()) (*fragmentedConnection, string, error) { +func NewFragmentCollector(localAddr net.Addr, remoteAddr net.Addr, metadata ConnectionMetadata, onClosed func()) (*fragmentedConnection, string, error) { fc := &fragmentedConnection{ done: make(chan interface{}), @@ -36,6 +37,7 @@ func NewFragmentCollector(localAddr net.Addr, remoteAddr net.Addr, onClosed func writeBuffer: NewSyncBuffer(maxBuffer), localAddr: localAddr, remoteAddr: remoteAddr, + metadata: metadata, onClose: onClosed, } @@ -112,6 +114,10 @@ func (fc *fragmentedConnection) RemoteAddr() net.Addr { return fc.remoteAddr } +func (fc *fragmentedConnection) Metadata() ConnectionMetadata { + return fc.metadata +} + func (fc *fragmentedConnection) SetDeadline(t time.Time) error { return nil } diff --git a/pkg/mux/metadata.go b/pkg/mux/metadata.go new file mode 100644 index 00000000..922d3209 --- /dev/null +++ b/pkg/mux/metadata.go @@ -0,0 +1,110 @@ +package mux + +import ( + "net" + "net/http" + "strings" +) + +type ConnectionMetadata struct { + Transport string + RealClientIP string + ProxySourceIP string +} + +type metadataConn struct { + net.Conn + metadata ConnectionMetadata + remoteAddr net.Addr +} + +func (c *metadataConn) RemoteAddr() net.Addr { + if c.remoteAddr != nil { + return c.remoteAddr + } + return c.Conn.RemoteAddr() +} + +func (c *metadataConn) Metadata() ConnectionMetadata { + return c.metadata +} + +func Metadata(conn net.Conn) ConnectionMetadata { + if conn == nil { + return ConnectionMetadata{} + } + if carrier, ok := conn.(interface{ Metadata() ConnectionMetadata }); ok { + return carrier.Metadata() + } + return ConnectionMetadata{} +} + +func withMetadata(conn net.Conn, metadata ConnectionMetadata) net.Conn { + if conn == nil { + return nil + } + remoteAddr := conn.RemoteAddr() + if metadata.RealClientIP != "" { + if realAddr := tcpAddrForIP(metadata.RealClientIP); realAddr != nil { + remoteAddr = realAddr + } + } + return &metadataConn{Conn: conn, metadata: metadata, remoteAddr: remoteAddr} +} + +func (m *Multiplexer) metadataFromRequest(req *http.Request, source net.Addr, transportName string) ConnectionMetadata { + metadata := ConnectionMetadata{Transport: transportName} + sourceIP := addrIP(source) + if sourceIP == nil || len(m.config.TrustedProxyCIDRs) == 0 || !ipInCIDRs(sourceIP, m.config.TrustedProxyCIDRs) { + return metadata + } + + realIP := net.ParseIP(strings.TrimSpace(req.Header.Get("X-Real-IP"))) + if realIP == nil { + realIP = firstValidForwardedIP(req.Header.Get("X-Forwarded-For")) + } + if realIP == nil { + return metadata + } + + metadata.ProxySourceIP = sourceIP.String() + metadata.RealClientIP = realIP.String() + return metadata +} + +func addrIP(addr net.Addr) net.IP { + if addr == nil { + return nil + } + host, _, err := net.SplitHostPort(addr.String()) + if err != nil { + host = addr.String() + } + return net.ParseIP(strings.Trim(host, "[]")) +} + +func firstValidForwardedIP(header string) net.IP { + for _, part := range strings.Split(header, ",") { + if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil { + return ip + } + } + return nil +} + +func ipInCIDRs(ip net.IP, cidrs []*net.IPNet) bool { + for _, cidr := range cidrs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +func tcpAddrForIP(ip string) net.Addr { + parsed := net.ParseIP(ip) + if parsed == nil { + return nil + } + return &net.TCPAddr{IP: parsed, Port: 0} +} diff --git a/pkg/mux/multiplexer.go b/pkg/mux/multiplexer.go index d0c97eb3..aed13cef 100644 --- a/pkg/mux/multiplexer.go +++ b/pkg/mux/multiplexer.go @@ -38,6 +38,7 @@ type MultiplexerConfig struct { TcpKeepAlive int PollingAuthChecker func(key string, addr net.Addr) bool + TrustedProxyCIDRs []*net.IPNet tlsConfig *tls.Config } @@ -221,13 +222,23 @@ func (m *Multiplexer) collector(localAddr net.Addr) http.HandlerFunc { return } - if !m.config.PollingAuthChecker(key, realConn.RemoteAddr()) { + transportName := Metadata(realConn).Transport + if transportName == "" { + transportName = "http" + } + metadata := m.metadataFromRequest(req, realConn.RemoteAddr(), transportName) + remoteAddr := realConn.RemoteAddr() + if metadata.RealClientIP != "" { + remoteAddr = tcpAddrForIP(metadata.RealClientIP) + } + + if !m.config.PollingAuthChecker(key, remoteAddr) { log.Println("client connected but the key for starting a new polling session was wrong") http.Error(w, "Bad Request", http.StatusBadRequest) return } - c, id, err = NewFragmentCollector(localAddr, realConn.RemoteAddr(), func() { + c, id, err = NewFragmentCollector(localAddr, remoteAddr, metadata, func() { cleanupConnection(id, nil) }) if err != nil { @@ -515,7 +526,9 @@ func (m *Multiplexer) unwrapTransports(conn net.Conn) (net.Conn, protocols.Type, conn.SetDeadline(time.Time{}) // Unwrap any outer tls if required + tlsWrapped := false if m.config.TLS && proto == "tls" { + tlsWrapped = true if m.config.tlsConfig == nil { @@ -564,41 +577,72 @@ func (m *Multiplexer) unwrapTransports(conn net.Conn) (net.Conn, protocols.Type, switch proto { case protocols.Websockets: - return m.unwrapWebsockets(conn) + return m.unwrapWebsockets(conn, protocolTransportName(proto, tlsWrapped)) case protocols.HTTP: // This will get passed off to a golang stdlib http server to do further unwrapping/feeding to the ssh component. // Unlike the other connections this isnt a single stream, its multiple connections composed into one blob, so it has to be a lil non-standard - return conn, protocols.HTTP, nil + return withMetadata(conn, ConnectionMetadata{Transport: protocolTransportName(proto, tlsWrapped)}), protocols.HTTP, nil default: // If the initial unwrapping was enough and left us with download or ssh, we can just quit if protocols.FullyUnwrapped(proto) { - return conn, proto, nil + return withMetadata(conn, ConnectionMetadata{Transport: protocolTransportName(proto, tlsWrapped)}), proto, nil } } return nil, protocols.Invalid, fmt.Errorf("after unwrapping transports, nothing useable was found: %s", proto) } -func (m *Multiplexer) unwrapWebsockets(conn net.Conn) (net.Conn, protocols.Type, error) { +func protocolTransportName(proto protocols.Type, tlsWrapped bool) string { + switch proto { + case protocols.Websockets: + if tlsWrapped { + return "wss" + } + return "ws" + case protocols.HTTP: + if tlsWrapped { + return "https" + } + return "http" + case protocols.C2: + if tlsWrapped { + return "tls" + } + return "tcp" + default: + return string(proto) + } +} + +func (m *Multiplexer) unwrapWebsockets(conn net.Conn, transportName string) (net.Conn, protocols.Type, error) { wsHttp := http.NewServeMux() wsConnChan := make(chan net.Conn, 1) + var request *http.Request wsServer := websocket.Server{ Config: websocket.Config{}, // Disable origin validation because.... its ssh we dont need it - Handshake: nil, + Handshake: func(_ *websocket.Config, req *http.Request) error { + request = req + return nil + }, Handler: func(c *websocket.Conn) { // Pain and suffering https://github.com/golang/go/issues/7350 c.PayloadType = websocket.BinaryFrame + metadata := ConnectionMetadata{Transport: transportName} + if request != nil { + metadata = m.metadataFromRequest(request, conn.RemoteAddr(), transportName) + } + wsW := websocketWrapper{ wsConn: c, tcpConn: conn, done: make(chan interface{}), } - wsConnChan <- &wsW + wsConnChan <- withMetadata(&wsW, metadata) <-wsW.done }, diff --git a/pkg/mux/multiplexer_test.go b/pkg/mux/multiplexer_test.go new file mode 100644 index 00000000..830774d6 --- /dev/null +++ b/pkg/mux/multiplexer_test.go @@ -0,0 +1,114 @@ +package mux + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMetadataFromRequestTrustsConfiguredProxy(t *testing.T) { + _, trusted, err := net.ParseCIDR("192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + m := &Multiplexer{config: MultiplexerConfig{TrustedProxyCIDRs: []*net.IPNet{trusted}}} + req := httptest.NewRequest(http.MethodGet, "/ws", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.99, 198.51.100.10") + req.Header.Set("X-Real-IP", "198.51.100.10") + + metadata := m.metadataFromRequest(req, &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 4444}, "wss") + if metadata.Transport != "wss" { + t.Fatalf("transport = %q", metadata.Transport) + } + if metadata.RealClientIP != "198.51.100.10" { + t.Fatalf("real client ip = %q", metadata.RealClientIP) + } + if metadata.ProxySourceIP != "192.0.2.10" { + t.Fatalf("proxy source ip = %q", metadata.ProxySourceIP) + } +} + +func TestMetadataFromRequestFallsBackToForwardedFor(t *testing.T) { + _, trusted, err := net.ParseCIDR("192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + m := &Multiplexer{config: MultiplexerConfig{TrustedProxyCIDRs: []*net.IPNet{trusted}}} + req := httptest.NewRequest(http.MethodGet, "/push", nil) + req.Header.Set("X-Forwarded-For", "bad-ip, 198.51.100.10") + + metadata := m.metadataFromRequest(req, &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 4444}, "http") + if metadata.RealClientIP != "198.51.100.10" { + t.Fatalf("real client ip = %q", metadata.RealClientIP) + } +} + +func TestMetadataFromRequestIgnoresUntrustedProxy(t *testing.T) { + _, trusted, err := net.ParseCIDR("192.0.2.0/24") + if err != nil { + t.Fatal(err) + } + m := &Multiplexer{config: MultiplexerConfig{TrustedProxyCIDRs: []*net.IPNet{trusted}}} + req := httptest.NewRequest(http.MethodGet, "/ws", nil) + req.Header.Set("X-Real-IP", "198.51.100.10") + + metadata := m.metadataFromRequest(req, &net.TCPAddr{IP: net.ParseIP("203.0.113.10"), Port: 4444}, "wss") + if metadata.Transport != "wss" { + t.Fatalf("transport = %q", metadata.Transport) + } + if metadata.RealClientIP != "" { + t.Fatalf("untrusted real client ip = %q", metadata.RealClientIP) + } + if metadata.ProxySourceIP != "" { + t.Fatalf("untrusted proxy source ip = %q", metadata.ProxySourceIP) + } +} + +func TestBufferedConnCarriesMetadata(t *testing.T) { + conn := withMetadata(&testConn{remoteAddr: &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 4444}}, ConnectionMetadata{ + Transport: "wss", + RealClientIP: "198.51.100.10", + ProxySourceIP: "192.0.2.10", + }) + buffered := &bufferedConn{conn: conn} + + metadata := Metadata(buffered) + if metadata.Transport != "wss" || metadata.ProxySourceIP != "192.0.2.10" { + t.Fatalf("metadata lost: %+v", metadata) + } + if got := buffered.RemoteAddr().String(); got != "198.51.100.10:0" { + t.Fatalf("remote addr = %q", got) + } +} + +func TestFragmentedConnCarriesMetadata(t *testing.T) { + metadata := ConnectionMetadata{ + Transport: "http", + RealClientIP: "198.51.100.10", + ProxySourceIP: "192.0.2.10", + } + conn, _, err := NewFragmentCollector( + &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 3232}, + &net.TCPAddr{IP: net.ParseIP("198.51.100.10"), Port: 0}, + metadata, + nil, + ) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + if got := Metadata(conn); got != metadata { + t.Fatalf("metadata = %+v, want %+v", got, metadata) + } +} + +type testConn struct { + net.Conn + remoteAddr net.Addr +} + +func (c *testConn) RemoteAddr() net.Addr { + return c.remoteAddr +}