Skip to content

Commit

Permalink
Css 10676/add http proxy (#1370)
Browse files Browse the repository at this point in the history
* add httpproxy

* move utils methods from http to util
  • Loading branch information
SimoneDutto authored Sep 19, 2024
1 parent b201bc3 commit 0d2f3fa
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 0 deletions.
File renamed without changes.
File renamed without changes.
103 changes: 103 additions & 0 deletions internal/rpc/httpproxy.go
Original file line number Diff line number Diff line change
@@ -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
}
113 changes: 113 additions & 0 deletions internal/rpc/httpproxy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit 0d2f3fa

Please sign in to comment.