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 ¶ms.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 }