github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/api/httpclient.go (about)

     1  // Copyright 2014 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package api
     5  
     6  import (
     7  	"bytes"
     8  	"encoding/base64"
     9  	"encoding/json"
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"net/url"
    14  
    15  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
    16  	"github.com/go-macaroon-bakery/macaroon-bakery/v3/httpbakery"
    17  	"github.com/juju/errors"
    18  	"gopkg.in/httprequest.v1"
    19  	"gopkg.in/macaroon.v2"
    20  
    21  	"github.com/juju/juju/rpc/params"
    22  	jujuversion "github.com/juju/juju/version"
    23  )
    24  
    25  // HTTPClient implements Connection.APICaller.HTTPClient and returns an HTTP
    26  // client pointing to the API server "/model/:uuid/" path.
    27  func (s *state) HTTPClient() (*httprequest.Client, error) {
    28  	baseURL, err := s.apiEndpoint("/", "")
    29  	if err != nil {
    30  		return nil, errors.Trace(err)
    31  	}
    32  	return s.httpClient(baseURL)
    33  }
    34  
    35  // RootHTTPClient implements Connection.APICaller.HTTPClient and returns an HTTP
    36  // client pointing to the API server root path.
    37  func (s *state) RootHTTPClient() (*httprequest.Client, error) {
    38  	return s.httpClient(&url.URL{
    39  		Scheme: s.serverScheme,
    40  		Host:   s.Addr(),
    41  	})
    42  }
    43  
    44  func (s *state) httpClient(baseURL *url.URL) (*httprequest.Client, error) {
    45  	if !s.isLoggedIn() {
    46  		return nil, errors.New("no HTTP client available without logging in")
    47  	}
    48  	return &httprequest.Client{
    49  		BaseURL: baseURL.String(),
    50  		Doer: httpRequestDoer{
    51  			st: s,
    52  		},
    53  		UnmarshalError: unmarshalHTTPErrorResponse,
    54  	}, nil
    55  }
    56  
    57  // httpRequestDoer implements httprequest.Doer and httprequest.DoerWithBody
    58  // by using httpbakery and the state to make authenticated requests to
    59  // the API server.
    60  type httpRequestDoer struct {
    61  	st *state
    62  }
    63  
    64  var _ httprequest.Doer = httpRequestDoer{}
    65  
    66  // Do implements httprequest.Doer.Do.
    67  func (doer httpRequestDoer) Do(req *http.Request) (*http.Response, error) {
    68  	if err := authHTTPRequest(
    69  		req,
    70  		doer.st.tag,
    71  		doer.st.password,
    72  		doer.st.nonce,
    73  		doer.st.macaroons,
    74  	); err != nil {
    75  		return nil, errors.Trace(err)
    76  	}
    77  	return doer.st.bakeryClient.DoWithCustomError(req, func(resp *http.Response) error {
    78  		// At this point we are only interested in errors that
    79  		// the bakery cares about, and the CodeDischargeRequired
    80  		// error is the only one, and that always comes with a
    81  		// response code StatusUnauthorized.
    82  		if resp.StatusCode != http.StatusUnauthorized {
    83  			return nil
    84  		}
    85  		return bakeryError(unmarshalHTTPErrorResponse(resp))
    86  	})
    87  }
    88  
    89  // AuthHTTPRequest adds Juju auth info (username, password, nonce, macaroons)
    90  // to the given HTTP request, suitable for sending to a Juju API server.
    91  func AuthHTTPRequest(req *http.Request, info *Info) error {
    92  	var tag string
    93  	if info.Tag != nil {
    94  		tag = info.Tag.String()
    95  	}
    96  	return authHTTPRequest(req, tag, info.Password, info.Nonce, info.Macaroons)
    97  }
    98  
    99  func authHTTPRequest(req *http.Request, tag, password, nonce string, macaroons []macaroon.Slice) error {
   100  	if tag != "" {
   101  		// Note that password may be empty here; we still
   102  		// want to pass the tag along. An empty password
   103  		// indicates that we're using macaroon authentication.
   104  		req.SetBasicAuth(tag, password)
   105  	}
   106  	if nonce != "" {
   107  		req.Header.Set(params.MachineNonceHeader, nonce)
   108  	}
   109  	req.Header.Set(params.JujuClientVersion, jujuversion.Current.String())
   110  	req.Header.Set(httpbakery.BakeryProtocolHeader, fmt.Sprint(bakery.LatestVersion))
   111  	for _, ms := range macaroons {
   112  		encoded, err := encodeMacaroonSlice(ms)
   113  		if err != nil {
   114  			return errors.Trace(err)
   115  		}
   116  		req.Header.Add(httpbakery.MacaroonsHeader, encoded)
   117  	}
   118  	return nil
   119  }
   120  
   121  // encodeMacaroonSlice base64-JSON-encodes a slice of macaroons.
   122  func encodeMacaroonSlice(ms macaroon.Slice) (string, error) {
   123  	data, err := json.Marshal(ms)
   124  	if err != nil {
   125  		return "", errors.Trace(err)
   126  	}
   127  	return base64.StdEncoding.EncodeToString(data), nil
   128  }
   129  
   130  func isJSONMediaType(header http.Header) bool {
   131  	return header.Get("Content-Type") == "application/json"
   132  }
   133  
   134  // unmarshalHTTPErrorResponse unmarshals an error response from
   135  // an HTTP endpoint. For historical reasons, these endpoints
   136  // return several different incompatible error response formats.
   137  // We cope with this by accepting all of the possible formats
   138  // and unmarshaling accordingly.
   139  //
   140  // It always returns a non-nil error.
   141  func unmarshalHTTPErrorResponse(resp *http.Response) error {
   142  	if !isJSONMediaType(resp.Header) {
   143  		// response body is not JSON. This is probably a response
   144  		// from the underlying webserver
   145  		body, err := io.ReadAll(resp.Body)
   146  		if err != nil {
   147  			return errors.Trace(err)
   148  		}
   149  		switch resp.StatusCode {
   150  		case 401:
   151  			return errors.Unauthorizedf(string(body))
   152  		case 403:
   153  			return errors.Forbiddenf(string(body))
   154  		case 404:
   155  			return errors.NotFoundf(string(body))
   156  		case 405:
   157  			return errors.MethodNotAllowedf(string(body))
   158  		default:
   159  			return errors.Errorf(string(body))
   160  		}
   161  	}
   162  
   163  	var body json.RawMessage
   164  	if err := httprequest.UnmarshalJSONResponse(resp, &body); err != nil {
   165  		return errors.Trace(err)
   166  	}
   167  	// genericErrorResponse defines a struct that is compatible with all the
   168  	// known error types, so that we can know which of the
   169  	// possible error types has been returned.
   170  	//
   171  	// Another possible approach might be to look at resp.Request.URL.Path
   172  	// and determine the expected error type from that, but that
   173  	// seems more fragile than this approach.
   174  	type genericErrorResponse struct {
   175  		Error json.RawMessage `json:"error"`
   176  	}
   177  	var generic genericErrorResponse
   178  	if err := json.Unmarshal(body, &generic); err != nil {
   179  		return errors.Annotatef(err, "incompatible error response")
   180  	}
   181  	if bytes.HasPrefix(generic.Error, []byte(`"`)) {
   182  		// The error message is in a string, which means that
   183  		// the error must be in a params.CharmsResponse
   184  		var resp params.CharmsResponse
   185  		if err := json.Unmarshal(body, &resp); err != nil {
   186  			return errors.Annotatef(err, "incompatible error response")
   187  		}
   188  		return &params.Error{
   189  			Message: resp.Error,
   190  			Code:    resp.ErrorCode,
   191  			Info:    resp.ErrorInfo,
   192  		}
   193  	}
   194  	var errorBody []byte
   195  	if len(generic.Error) > 0 {
   196  		// We have an Error field, therefore the error must be in that.
   197  		// (it's a params.ErrorResponse)
   198  		errorBody = generic.Error
   199  	} else {
   200  		// There wasn't an Error field, so the error must be directly
   201  		// in the body of the response.
   202  		errorBody = body
   203  	}
   204  	var perr params.Error
   205  	if err := json.Unmarshal(errorBody, &perr); err != nil {
   206  		return errors.Annotatef(err, "incompatible error response")
   207  	}
   208  	if perr.Message == "" {
   209  		return errors.Errorf("error response with no message")
   210  	}
   211  	return &perr
   212  }
   213  
   214  // bakeryError translates any discharge-required error into
   215  // an error value that the httpbakery package will recognize.
   216  // Other errors are returned unchanged.
   217  func bakeryError(err error) error {
   218  	if params.ErrCode(err) != params.CodeDischargeRequired {
   219  		return err
   220  	}
   221  	errResp := errors.Cause(err).(*params.Error)
   222  	if errResp.Info == nil {
   223  		return errors.Annotate(err, "no error info found in discharge-required response error")
   224  	}
   225  	// It's a discharge-required error, so make an appropriate httpbakery
   226  	// error from it.
   227  	var info params.DischargeRequiredErrorInfo
   228  	if errUnmarshal := errResp.UnmarshalInfo(&info); errUnmarshal != nil {
   229  		return errors.Annotate(err, "unable to extract macaroon details from discharge-required response error")
   230  	}
   231  
   232  	bakeryErr := &httpbakery.Error{
   233  		Message: err.Error(),
   234  		Code:    httpbakery.ErrDischargeRequired,
   235  		Info: &httpbakery.ErrorInfo{
   236  			MacaroonPath: info.MacaroonPath,
   237  		},
   238  	}
   239  	if info.Macaroon != nil || info.BakeryMacaroon != nil {
   240  		// Prefer the newer bakery.v2 macaroon.
   241  		dcMac := info.BakeryMacaroon
   242  		if dcMac == nil {
   243  			dcMac, err = bakery.NewLegacyMacaroon(info.Macaroon)
   244  			if err != nil {
   245  				return errors.Annotate(err, "unable to create legacy macaroon details from discharge-required response error")
   246  			}
   247  		}
   248  		bakeryErr.Info.Macaroon = dcMac
   249  	}
   250  	return bakeryErr
   251  }