pkg/hbone/hbonec.go (157 lines of code) (raw):
// Copyright 2021 Google LLC
//
// 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
//
// https://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"
"errors"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"golang.org/x/net/http2"
)
// Client for mTLS-over-HTTP/2.
// Primarily for testing and for the CLI debug helper.
type HBoneClient struct {
hb *HBone
}
func (c HBoneClient) NewEndpoint(url string) *Endpoint {
ep := c.hb.NewEndpoint(url)
return ep
}
// Endpoint is a client for a specific destination.
type Endpoint struct {
hb *HBone
// URL used to reach the H2 endpoint providing the proxy.
URL string
// SNI name to use - defaults to service name
SNI string
// SNIGate is the endpoint address of a SNI gate. It can be a normal Istio SNI, a SNI to HBone or other protocols,
// or a H2R gate.
// If empty, the endpoint will use the URL and HBone protocol directly.
// If set, the endpoint will use the nomal in-cluster Istio protocol.
SNIGate string
// H2Gate is the endpoint of a HTTP/2 gateway. Will be used to dial.
// It is expected to have a spiffee identity, and request client certs -
// similar with an egress gateway.
H2Gate string
tlsCon net.Conn
rt *http2.ClientConn // http.RoundTripper
}
func (hb *HBone) NewClient() *HBoneClient {
return &HBoneClient{hb: hb}
}
// NewEndpoint creates a client for connecting to a specific service:port
//
// The service is mapped to an endpoint URL, protocol, etc. using a config callback,
// to isolate XDS or discovery dependency.
//
func (hb *HBone) NewEndpoint(urlOrHost string) *Endpoint {
hc := &Endpoint{hb: hb}
if !strings.HasPrefix(urlOrHost, "https://") {
// TODO: for host and port - assume mTLS, using system certs for the 'external' tunnel
// TODO: resolver call, to map to endpoint (including SNI routers or gateway)
h, p, err := net.SplitHostPort(urlOrHost)
if err == nil {
urlOrHost = "https://" + h + "/_hbone/" + p
}
hc.URL = urlOrHost
} else {
hc.URL = urlOrHost
}
return hc
}
// Proxy will proxy in/out (plain text) to a remote service, using mTLS tunnel over H2 POST.
// used for testing.
func (hb *HBone) Proxy(svc string, hbURL string, stdin io.ReadCloser, stdout io.WriteCloser) error {
c := hb.NewEndpoint(hbURL)
return c.Proxy(context.Background(), stdin, stdout)
}
func (hc *Endpoint) Proxy(ctx context.Context, stdin io.Reader, stdout io.WriteCloser) error {
if hc.SNIGate != "" {
return hc.sniProxy(ctx, stdin, stdout)
}
t0 := time.Now()
// It is usually possible to pass stdin directly to NewRequest.
// Using a pipe allows getting stats.
i, o := io.Pipe()
defer stdout.Close()
r, err := http.NewRequest("POST", hc.URL, i)
if err != nil {
return err
}
var rt = hc.rt
if hc.hb.TokenCallback != nil {
h := r.URL.Host
if strings.Contains(h, ":") && h[0] != '[' {
hn, _, _ := net.SplitHostPort(h)
h = hn
}
t, err := hc.hb.TokenCallback(ctx, "https://"+h)
if err != nil {
log.Println("Failed to get token, attempt unauthenticated", err)
} else {
r.Header.Set("Authorization", "Bearer "+t)
}
}
if hc.rt == nil {
/* Alternative, using http.Client.
ug = &http.Client{
Transport: &http2.Transport{
// So http2.Transport doesn't complain the URL scheme isn't 'https'
AllowHTTP: true,
// Pretend we are dialing a TLS endpoint.
// Note, we ignore the passed tls.Config
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
return net.Dial(network, addr)
},
},
}
*/
h := r.URL.Host
if hc.H2Gate != "" {
h = hc.H2Gate
}
if Debug {
rd, _ := httputil.DumpRequest(r, false)
log.Println("HB req: ", h, string(rd))
}
host, port, _ := net.SplitHostPort(h)
if r.URL.Scheme == "http" {
d := &net.Dialer{}
dialHost := r.URL.Host
if port == "" {
dialHost = net.JoinHostPort(dialHost, "80")
}
nConn, err := d.DialContext(ctx, "tcp", dialHost)
if err != nil {
return err
}
hc.tlsCon = nConn
} else {
// Expect system certificates.
d := tls.Dialer{
Config: &tls.Config{
NextProtos: []string{"h2"},
},
NetDialer: &net.Dialer{},
}
dialHost := r.URL.Host
if port == "" {
dialHost = net.JoinHostPort(dialHost, "443")
}
if hc.H2Gate != "" {
dialHost = hc.H2Gate
}
nConn, err := d.DialContext(ctx, "tcp", dialHost)
if err != nil {
return err
}
tlsCon := nConn.(*tls.Conn)
tlsCon.VerifyHostname(host)
if err != nil {
return err
}
if tlsCon.ConnectionState().NegotiatedProtocol != "h2" {
log.Println("Failed to negotiate h2", tlsCon.ConnectionState().NegotiatedProtocol)
return errors.New("invalid ALPN protocol")
}
hc.tlsCon = tlsCon
}
rt, err = hc.hb.h2t.NewClientConn(hc.tlsCon)
if err != nil {
return err
}
hc.rt = rt
}
res, err := rt.RoundTrip(r)
if err != nil {
return err
}
t1 := time.Now()
ch := make(chan int)
var s1, s2 Stream
s1 = Stream{
ID: "client-o",
Dst: o,
Src: stdin,
}
go s1.CopyBuffered(ch, true)
s2 = Stream{
ID: "client-i",
Dst: stdout,
Src: res.Body,
}
s2.CopyBuffered(nil, true)
<-ch
log.Println("HBoneC-done", "url", r.URL, "status", res.Status, "conTime", t1.Sub(t0), "dur", time.Since(t1))
if s2.Err != nil || s1.Err != nil || s2.InError || s1.InError {
log.Println("HboneC close errors", s2.Err, s1.Err, s2.InError, s1.InError)
}
return s2.Err
}