From 0d2f3fa6a09d18755c8901d42c3fc64af6c45a9e Mon Sep 17 00:00:00 2001 From: SimoneDutto Date: Thu, 19 Sep 2024 11:19:34 +0200 Subject: [PATCH] Css 10676/add http proxy (#1370) * add httpproxy * move utils methods from http to util --- internal/jimmhttp/{http.go => utils.go} | 0 .../jimmhttp/{http_test.go => utils_test.go} | 0 internal/rpc/httpproxy.go | 103 ++++++++++++++++ internal/rpc/httpproxy_test.go | 113 ++++++++++++++++++ 4 files changed, 216 insertions(+) rename internal/jimmhttp/{http.go => utils.go} (100%) rename internal/jimmhttp/{http_test.go => utils_test.go} (100%) create mode 100644 internal/rpc/httpproxy.go create mode 100644 internal/rpc/httpproxy_test.go diff --git a/internal/jimmhttp/http.go b/internal/jimmhttp/utils.go similarity index 100% rename from internal/jimmhttp/http.go rename to internal/jimmhttp/utils.go diff --git a/internal/jimmhttp/http_test.go b/internal/jimmhttp/utils_test.go similarity index 100% rename from internal/jimmhttp/http_test.go rename to internal/jimmhttp/utils_test.go diff --git a/internal/rpc/httpproxy.go b/internal/rpc/httpproxy.go new file mode 100644 index 000000000..a714ef483 --- /dev/null +++ b/internal/rpc/httpproxy.go @@ -0,0 +1,103 @@ +// Copyright 2024 Canonical. + +package rpc + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/juju/zaputil" + "github.com/juju/zaputil/zapctx" + + "github.com/canonical/jimm/v3/internal/dbmodel" +) + +type httpOptions struct { + TLSConfig *tls.Config + URL url.URL +} + +// ProxyHTTP proxies the request to the controller using the info contained in dbmodel.Controller. +func ProxyHTTP(ctx context.Context, ctl *dbmodel.Controller, w http.ResponseWriter, req *http.Request) { + var tlsConfig *tls.Config + if ctl.CACertificate != "" { + cp := x509.NewCertPool() + ok := cp.AppendCertsFromPEM([]byte(ctl.CACertificate)) + if !ok { + zapctx.Warn(ctx, "no CA certificates added") + } + tlsConfig = &tls.Config{ + RootCAs: cp, + ServerName: ctl.TLSHostname, + MinVersion: tls.VersionTLS12, + } + } + + if ctl.PublicAddress != "" { + err := doRequest(ctx, w, req, httpOptions{ + TLSConfig: tlsConfig, + URL: createURLWithNewHost(*req.URL, ctl.PublicAddress), + }) + if err == nil { + return + } + } + for _, hps := range ctl.Addresses { + for _, hp := range hps { + err := doRequest(ctx, w, req, httpOptions{ + TLSConfig: tlsConfig, + URL: createURLWithNewHost(*req.URL, fmt.Sprintf("%s:%d", hp.Value, hp.Port)), + }) + if err == nil { + return + } else { + zapctx.Error(ctx, "failed to proxy request: continue to next addr", zaputil.Error(err)) + } + } + } + + zapctx.Error(ctx, "couldn't find a valid address for controller") + http.Error(w, "Gateway timeout", http.StatusGatewayTimeout) +} + +func doRequest(ctx context.Context, w http.ResponseWriter, req *http.Request, opt httpOptions) error { + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: opt.TLSConfig, + }, + } + req = req.Clone(ctx) + req.RequestURI = "" + req.URL = &opt.URL + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // copy headers + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + // copy body + _, err = io.Copy(w, resp.Body) + if err != nil { + return err + } + return nil +} + +// createURLWithNewHost takes a url.URL as parameter and return a url.URL with new host set and https enforced. +func createURLWithNewHost(reqUrl url.URL, host string) url.URL { + reqUrl.Scheme = "https" + reqUrl.Host = host + return reqUrl +} diff --git a/internal/rpc/httpproxy_test.go b/internal/rpc/httpproxy_test.go new file mode 100644 index 000000000..6d2ec4eb0 --- /dev/null +++ b/internal/rpc/httpproxy_test.go @@ -0,0 +1,113 @@ +// Copyright 2024 Canonical. + +package rpc_test + +import ( + "context" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/juju/juju/core/network" + jujuparams "github.com/juju/juju/rpc/params" + + "github.com/canonical/jimm/v3/internal/dbmodel" + "github.com/canonical/jimm/v3/internal/rpc" +) + +func TestProxyHTTP(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + // we expect the controller to respond with TLS + fakeController := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.String(), "unauth") { + w.WriteHeader(401) + return + } + _, err := w.Write([]byte("OK")) + c.Assert(err, qt.IsNil) + })) + defer fakeController.Close() + controller := dbmodel.Controller{} + pemData := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: fakeController.Certificate().Raw, + }) + controller.CACertificate = string(pemData) + + tests := []struct { + description string + setup func() + path string + statusExpected int + }{ + { + description: "good", + setup: func() { + newURL, _ := url.Parse(fakeController.URL) + controller.PublicAddress = newURL.Host + }, + statusExpected: http.StatusOK, + }, + { + description: "controller no public address, only addresses", + setup: func() { + hp, err := network.ParseMachineHostPort(fakeController.Listener.Addr().String()) + c.Assert(err, qt.Equals, nil) + controller.Addresses = append(make([][]jujuparams.HostPort, 0), []jujuparams.HostPort{{ + Address: jujuparams.FromMachineAddress(hp.MachineAddress), + Port: hp.Port(), + }}) + controller.Addresses = append(controller.Addresses, []jujuparams.HostPort{}) + controller.PublicAddress = "" + }, + statusExpected: http.StatusOK, + }, + { + description: "controller no public address, only addresses", + setup: func() { + hp, err := network.ParseMachineHostPort(fakeController.Listener.Addr().String()) + c.Assert(err, qt.Equals, nil) + controller.Addresses = append(make([][]jujuparams.HostPort, 0), []jujuparams.HostPort{{ + Address: jujuparams.FromMachineAddress(hp.MachineAddress), + Port: hp.Port(), + }}) + controller.Addresses = append(controller.Addresses, []jujuparams.HostPort{}) + controller.PublicAddress = "" + }, + statusExpected: http.StatusOK, + }, + { + description: "controller responds unauthorized", + setup: func() { + newURL, _ := url.Parse(fakeController.URL) + controller.PublicAddress = newURL.Host + }, + path: "/unauth", + statusExpected: http.StatusUnauthorized, + }, + { + description: "controller not reachable", + setup: func() { + controller.Addresses = nil + controller.PublicAddress = "localhost-not-found:61213" + }, + statusExpected: http.StatusGatewayTimeout, + }, + } + + for _, test := range tests { + test.setup() + req, err := http.NewRequest("POST", test.path, nil) + c.Assert(err, qt.IsNil) + recorder := httptest.NewRecorder() + rpc.ProxyHTTP(ctx, &controller, recorder, req) + resp := recorder.Result() + defer resp.Body.Close() + c.Assert(resp.StatusCode, qt.Equals, test.statusExpected) + } +}