// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package http import ( "context" "encoding/json" "fmt" "net/http" "path" "strings" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/anypb" "github.com/hashicorp/go-hclog" "github.com/hashicorp/consul/internal/resource" "github.com/hashicorp/consul/proto-public/pbresource" ) const ( HeaderConsulToken = "x-consul-token" HeaderConsistencyMode = "x-consul-consistency-mode" ) // NewHandler creates a new HTTP handler for the resource service. // httpPathPrefix is the prefix to be used for all HTTP endpoints. Should start with "/" and // end without a trailing "/". // client is the gRPC client to be used to communicate with the resource service. // registry is the resource registry to be used to determine the resource types. // parseToken is a function that will be called to parse the Consul token from the request. func NewHandler( httpPathPrefix string, client pbresource.ResourceServiceClient, registry resource.Registry, parseToken func(req *http.Request, token *string), logger hclog.Logger) http.Handler { mux := http.NewServeMux() for _, t := range registry.Types() { // List Endpoint base := strings.ToLower(fmt.Sprintf("/%s/%s/%s", t.Type.Group, t.Type.GroupVersion, t.Type.Kind)) mux.Handle(base, http.StripPrefix(base, &listHandler{t, client, parseToken, logger})) logger.Info("Registered resource endpoint", "endpoint", fmt.Sprintf("%s%s", httpPathPrefix, base)) // Individual Resource Endpoints prefix := strings.ToLower(fmt.Sprintf("%s/", base)) mux.Handle(prefix, http.StripPrefix(prefix, &resourceHandler{t, client, parseToken, logger})) } return mux } type writeRequest struct { Metadata map[string]string `json:"metadata"` Data json.RawMessage `json:"data"` Owner *pbresource.ID `json:"owner"` } type resourceHandler struct { reg resource.Registration client pbresource.ResourceServiceClient parseToken func(req *http.Request, token *string) logger hclog.Logger } func (h *resourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var token string h.parseToken(r, &token) ctx := metadata.AppendToOutgoingContext(r.Context(), HeaderConsulToken, token) switch r.Method { case http.MethodPut: h.handleWrite(w, r, ctx) case http.MethodGet: h.handleRead(w, r, ctx) case http.MethodDelete: h.handleDelete(w, r, ctx) default: w.WriteHeader(http.StatusMethodNotAllowed) return } } func (h *resourceHandler) handleWrite(w http.ResponseWriter, r *http.Request, ctx context.Context) { var req writeRequest // convert req body to writeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Error("Failed to decode request body", "error", err) w.WriteHeader(http.StatusBadRequest) w.Write([]byte("Request body format is invalid")) return } // convert data struct to proto message data := h.reg.Proto.ProtoReflect().New().Interface() if err := protojson.Unmarshal(req.Data, data); err != nil { h.logger.Error("Failed to unmarshal to proto message", "error", err) w.WriteHeader(http.StatusBadRequest) w.Write([]byte("Request body didn't follow the resource schema")) return } // proto message to any anyProtoMsg, err := anypb.New(data) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.logger.Error("Failed to convert proto message to any type", "error", err) return } tenancyInfo, params := parseParams(r) rsp, err := h.client.Write(ctx, &pbresource.WriteRequest{ Resource: &pbresource.Resource{ Id: &pbresource.ID{ Type: h.reg.Type, Tenancy: tenancyInfo, Name: params["resourceName"], }, Owner: req.Owner, Version: params["version"], Metadata: req.Metadata, Data: anyProtoMsg, }, }) if err != nil { handleResponseError(err, w, h.logger) return } output, err := jsonMarshal(rsp.Resource) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.logger.Error("Failed to unmarshal GRPC resource response", "error", err) return } w.Write(output) } func (h *resourceHandler) handleRead(w http.ResponseWriter, r *http.Request, ctx context.Context) { tenancyInfo, params := parseParams(r) if params["consistent"] != "" { ctx = metadata.AppendToOutgoingContext(ctx, "x-consul-consistency-mode", "consistent") } rsp, err := h.client.Read(ctx, &pbresource.ReadRequest{ Id: &pbresource.ID{ Type: h.reg.Type, Tenancy: tenancyInfo, Name: params["resourceName"], }, }) if err != nil { handleResponseError(err, w, h.logger) return } output, err := jsonMarshal(rsp.Resource) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.logger.Error("Failed to unmarshal GRPC resource response", "error", err) return } w.Write(output) } // Note: The HTTP endpoints do not accept UID since it is quite unlikely that the user will have access to it func (h *resourceHandler) handleDelete(w http.ResponseWriter, r *http.Request, ctx context.Context) { tenancyInfo, params := parseParams(r) _, err := h.client.Delete(ctx, &pbresource.DeleteRequest{ Id: &pbresource.ID{ Type: h.reg.Type, Tenancy: tenancyInfo, Name: params["resourceName"], }, Version: params["version"], }) if err != nil { handleResponseError(err, w, h.logger) return } w.WriteHeader(http.StatusNoContent) w.Write([]byte("{}")) } func parseParams(r *http.Request) (tenancy *pbresource.Tenancy, params map[string]string) { query := r.URL.Query() namespace := query.Get("namespace") if namespace == "" { namespace = query.Get("ns") } tenancy = &pbresource.Tenancy{ Partition: query.Get("partition"), Namespace: namespace, } // TODO(peering/v2) handle parsing peer tenancy resourceName := path.Base(r.URL.Path) if resourceName == "." || resourceName == "/" { resourceName = "" } params = make(map[string]string) params["resourceName"] = resourceName params["version"] = query.Get("version") params["namePrefix"] = query.Get("name_prefix") // coming from command line params["consistent"] = query.Get("RequireConsistent") // coming from http client if _, ok := query["consistent"]; ok { params["consistent"] = "true" } return tenancy, params } func jsonMarshal(res *pbresource.Resource) ([]byte, error) { output, err := protojson.Marshal(res) if err != nil { return nil, err } var stuff map[string]any if err := json.Unmarshal(output, &stuff); err != nil { return nil, err } delete(stuff["data"].(map[string]any), "@type") return json.MarshalIndent(stuff, "", " ") } func handleResponseError(err error, w http.ResponseWriter, logger hclog.Logger) { if e, ok := status.FromError(err); ok { switch e.Code() { case codes.InvalidArgument: w.WriteHeader(http.StatusBadRequest) logger.Info("User has mal-formed request", "error", err) case codes.NotFound: w.WriteHeader(http.StatusNotFound) logger.Info("Received error from resource service: Not found", "error", err) case codes.PermissionDenied: w.WriteHeader(http.StatusForbidden) logger.Info("Received error from resource service: User not authenticated", "error", err) case codes.Aborted: w.WriteHeader(http.StatusConflict) logger.Info("Received error from resource service: the request conflict with the current state of the target resource", "error", err) default: w.WriteHeader(http.StatusInternalServerError) logger.Error("Received error from resource service", "error", err) } } else { w.WriteHeader(http.StatusInternalServerError) logger.Error("Received error from resource service: not able to parse error returned", "error", err) } w.Write([]byte(err.Error())) } type listHandler struct { reg resource.Registration client pbresource.ResourceServiceClient parseToken func(req *http.Request, token *string) logger hclog.Logger } func (h *listHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } var token string h.parseToken(r, &token) ctx := metadata.AppendToOutgoingContext(r.Context(), HeaderConsulToken, token) tenancyInfo, params := parseParams(r) if params["consistent"] == "true" { ctx = metadata.AppendToOutgoingContext(ctx, HeaderConsistencyMode, "consistent") } rsp, err := h.client.List(ctx, &pbresource.ListRequest{ Type: h.reg.Type, Tenancy: tenancyInfo, NamePrefix: params["namePrefix"], }) if err != nil { handleResponseError(err, w, h.logger) return } output := make([]json.RawMessage, len(rsp.Resources)) for idx, res := range rsp.Resources { b, err := jsonMarshal(res) if err != nil { w.WriteHeader(http.StatusInternalServerError) h.logger.Error("Failed to unmarshal GRPC resource response", "error", err) return } output[idx] = b } b, err := json.MarshalIndent(struct { Resources []json.RawMessage `json:"resources"` }{output}, "", " ") if err != nil { w.WriteHeader(http.StatusInternalServerError) h.logger.Error("Failed to correctly format the list response", "error", err) return } w.Write(b) }