Skip to content

hbone: initial echo server/client implementation #39645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/config/protocol/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ const (
Redis Instance = "Redis"
// MySQL declares that the port carries MySQL traffic.
MySQL Instance = "MySQL"
// HBONE declares that the port carries HBONE traffic.
// This cannot be declared by Services, but is used for some internal code that uses Protocol
HBONE Instance = "HBONE"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a dedicated port ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or 2 ports actually - one for HBone and one for 'plain text/trusted network' hbone, i.e. h2 or h2c

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not something a user can configure by portname on Service -- but used for internal. Not by convention, this is enforced.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will HBONE always be over h2? If we ever supported h3, would it be considered a different protocol?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future we may make it support h3. TBD if we consider that another protocol here.

This is just internal details here, not API, so we can change

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not add it here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would have to update all of our tests and echo codebase. Any concerns ? It cannot be used by real Service (which I plan to add a comment and test asserting this)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is test code should not couple with main. Could be confused at a new protocol?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think "experimental" is more accurate than "test". This only exists in this file. Seems that an adequate "proceed at your own risk" banner should be sufficient, no?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it cannot be used by users at all, its purely an internal thing

// Unsupported - value to signify that the protocol is unsupported.
Unsupported Instance = "UnsupportedProtocol"
)
Expand Down
1 change: 1 addition & 0 deletions pkg/config/protocol/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func TestParse(t *testing.T) {
{"MySQL", protocol.MySQL},
{"", protocol.Unsupported},
{"SMTP", protocol.Unsupported},
{"HBONE", protocol.Unsupported},
}

for _, testPair := range testPairs {
Expand Down
71 changes: 71 additions & 0 deletions pkg/hbone/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# HTTP Based Overlay Network (HBONE)

HTTP Based Overlay Network (HBONE) is the protocol used by Istio for communication between workloads in the mesh.
At a high level, the protocol consists of tunneling TCP connections over HTTP/2 CONNECT, over mTLS.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would skip 'over mTLS', and add 'over HTTP/2 and H3 CONNECT' ( not clear if QUIC is mTLS, or if in a trusted networks we must have outer mTLS or H2C is allowed )

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, isn't it mTLS? I'd rather keep the text representative of what has been built rather than what might be built in the future.


## Specification

TODO
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My doc on gateway improvements has several links, you can add them here ( RFCs, BeyondCorp, browser specs)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 ... could you fill out this section?


## Implementations

### Clients

#### CLI

A CLI client is available using the `client` binary.

Usage examples:

```shell
go install ./pkg/test/echo/cmd/client
# Send request to 127.0.0.1:8080 (Note only IPs are supported) via an HBONE proxy on port 15008
client --hbone-client-cert tests/testdata/certs/cert.crt --hbone-client-key tests/testdata/certs/cert.key \
http://127.0.0.1:8080 \
--hbone 127.0.0.1:15008
```

#### Golang

An (unstable) library to make HBONE connections is available at `pkg/hbone`.

Usage example:

```go
d := hbone.NewDialer(hbone.Config{
ProxyAddress: "1.2.3.4:15008",
Headers: map[string][]string{
"some-addition-metadata": {"test-value"},
},
TLS: nil, // TLS is strongly recommended in real world
})
client, _ := d.Dial("tcp", testAddr)
client.Write([]byte("hello world"))
```

### Server

#### Server CLI

A CLI client is available using the `server` binary.

Usage examples:

```shell
go install ./pkg/test/echo/cmd/server
# Serve on port 15008 (default) with TLS
server --tls 15008 --crt tests/testdata/certs/cert.crt --key tests/testdata/certs/cert.key
```

#### Server Golang Library

An (unstable) library to run an HBONE server is available at `pkg/hbone`.

Usage example:

```go
s := hbone.NewServer()
// TLS is strongly recommended in real world
l, _ := net.Listen("tcp", "0.0.0.0:15008")
s.Serve(l)
```
184 changes: 184 additions & 0 deletions pkg/hbone/dialer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright Istio Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package hbone

import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"

"golang.org/x/net/http2"
"golang.org/x/net/proxy"

"istio.io/istio/security/pkg/pki/util"
istiolog "istio.io/pkg/log"
)

var log = istiolog.RegisterScope("hbone", "", 0)

// Config defines the configuration for a given dialer. All fields other than ProxyAddress are optional
type Config struct {
// ProxyAddress defines the address of the HBONE proxy we are connecting to
ProxyAddress string
Headers http.Header
TLS *tls.Config
}

type Dialer interface {
proxy.Dialer
proxy.ContextDialer
}

// NewDialer creates a Dialer that proxies connections over HBONE to the configured proxy.
func NewDialer(cfg Config) Dialer {
var transport *http2.Transport
if cfg.TLS != nil {
transport = &http2.Transport{
TLSClientConfig: cfg.TLS,
}
} else {
transport = &http2.Transport{
// For h2c
AllowHTTP: true,
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
return net.Dial(network, addr)
},
}
}
return &dialer{
cfg: cfg,
transport: transport,
}
}

type dialer struct {
cfg Config
transport *http2.Transport
}

// DialContext connects to `address` via the HBONE proxy.
func (d *dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
if network != "tcp" {
return net.Dial(network, address)
}
// TODO: use context
c, s := net.Pipe()
err := d.proxyTo(s, d.cfg, address)
if err != nil {
return nil, err
}
return c, nil
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to work on this - see my previous implementation, the HTTP RT should probably be done part of Dial, with proxy remaining in background. So we can return errors properly ( similar with how normal Dial works - i.e. a real connection/stream will be established by dial)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed and also made it reuse the transport within a dialer

}

func (d dialer) Dial(network, address string) (c net.Conn, err error) {
return d.DialContext(context.Background(), network, address)
}

func (d *dialer) proxyTo(conn io.ReadWriteCloser, req Config, address string) error {
t0 := time.Now()

url := "http://" + req.ProxyAddress
if req.TLS != nil {
url = "https://" + req.ProxyAddress
}
// Setup a pipe. We could just pass `conn` to `http.NewRequest`, but this has a few issues:
// * Less visibility into i/o
// * http will call conn.Close, which will close before we want to (finished writing response).
pr, pw := io.Pipe()
r, err := http.NewRequest(http.MethodConnect, url, pr)
if err != nil {
return fmt.Errorf("new request: %v", err)
}
r.Host = address

// Initiate CONNECT.
log.Infof("initiate CONNECT to %v via %v", r.Host, url)

resp, err := d.transport.RoundTrip(r)
if err != nil {
return fmt.Errorf("round trip: %v", err)
}
var remoteID string
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
ids, _ := util.ExtractIDs(resp.TLS.PeerCertificates[0].Extensions)
if len(ids) > 0 {
remoteID = ids[0]
}
}
log.WithLabels("host", r.Host, "remote", remoteID).Info("CONNECT established")
go func() {
defer conn.Close()
defer resp.Body.Close()

wg := sync.WaitGroup{}
wg.Add(1)
go func() {
// handle upstream (hbone server) --> downstream (app)
copyBuffered(conn, resp.Body, log.WithLabels("name", "body to conn"))
wg.Done()
}()
// Copy from conn into the pipe, which will then be sent as part of the request
// handle upstream (hbone server) <-- downstream (app)
copyBuffered(pw, conn, log.WithLabels("name", "conn to pipe"))

wg.Wait()
log.Info("stream closed in ", time.Since(t0))
}()

return nil
}

// TLSDialWithDialer is an implementation of tls.DialWithDialer that accepts a generic Dialer
func TLSDialWithDialer(dialer Dialer, network, addr string, config *tls.Config) (*tls.Conn, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this ? Normal pattern is create Dialer with config and call DialContext/Dial

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure another way to add TLS to a Dialer?

return tlsDial(context.Background(), dialer, network, addr, config)
}

func tlsDial(ctx context.Context, netDialer Dialer, network, addr string, config *tls.Config) (*tls.Conn, error) {
rawConn, err := netDialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}

colonPos := strings.LastIndex(addr, ":")
if colonPos == -1 {
colonPos = len(addr)
}
hostname := addr[:colonPos]

if config == nil {
config = &tls.Config{}
}
// If no ServerName is set, infer the ServerName
// from the hostname we're connecting to.
if config.ServerName == "" {
// Make a copy to avoid polluting argument or default.
c := config.Clone()
c.ServerName = hostname
config = c
}

conn := tls.Client(rawConn, config)
if err := conn.HandshakeContext(ctx); err != nil {
rawConn.Close()
return nil, err
}
return conn, nil
}
110 changes: 110 additions & 0 deletions pkg/hbone/dialer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright Istio Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package hbone

import (
"net"
"testing"
)

func newTCPServer(t testing.TB, data string) string {
n, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Logf("opened listener on %v", n.Addr().String())
go func() {
for {
c, err := n.Accept()
if err != nil {
t.Log(err)
return
}
t.Log("accepted connection")
c.Write([]byte(data))
c.Close()
}
}()
t.Cleanup(func() {
n.Close()
})
return n.Addr().String()
}

func TestDialerError(t *testing.T) {
d := NewDialer(Config{
ProxyAddress: "127.0.0.10:1", // Random address that should fail to dial
Headers: map[string][]string{
"some-addition-metadata": {"test-value"},
},
TLS: nil, // No TLS for simplification
})
_, err := d.Dial("tcp", "fake")
if err == nil {
t.Fatal("expected error, got none.")
}
}

func TestDialer(t *testing.T) {
testAddr := newTCPServer(t, "hello")
proxy := newHBONEServer(t)
d := NewDialer(Config{
ProxyAddress: proxy,
Headers: map[string][]string{
"some-addition-metadata": {"test-value"},
},
TLS: nil, // No TLS for simplification
})
send := func() {
client, err := d.Dial("tcp", testAddr)
if err != nil {
t.Fatal(err)
}
defer client.Close()

go func() {
n, err := client.Write([]byte("hello world"))
t.Logf("wrote %v/%v", n, err)
}()

buf := make([]byte, 8)
n, err := client.Read(buf)
if err != nil {
t.Fatalf("err with %v: %v", n, err)
}
if string(buf[:n]) != "hello" {
t.Fatalf("got unexpected buffer: %v", string(buf[:n]))
}
t.Logf("Read %v", string(buf[:n]))
}
// Make sure we can create multiple connections
send()
send()
}

func newHBONEServer(t *testing.T) string {
s := NewServer()
l, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatal(err)
}
go func() {
_ = s.Serve(l)
}()
t.Cleanup(func() {
_ = l.Close()
})
return l.Addr().String()
}
Loading