mirror of
https://github.com/hanzoai/grpc-web.git
synced 2026-10-30 18:35:37 +00:00
310 lines
11 KiB
Go
310 lines
11 KiB
Go
//Copyright 2017 Improbable. All Rights Reserved.
|
|
// See LICENSE for licensing terms.
|
|
|
|
package grpcweb
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/cors"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/grpclog"
|
|
"nhooyr.io/websocket"
|
|
)
|
|
|
|
var (
|
|
internalRequestHeadersWhitelist = []string{
|
|
"U-A", // for gRPC-Web User Agent indicator.
|
|
}
|
|
)
|
|
|
|
// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md#protocol-differences-vs-grpc-over-http2
|
|
const grpcContentType = "application/grpc"
|
|
const grpcWebContentType = "application/grpc-web"
|
|
const grpcWebTextContentType = "application/grpc-web-text"
|
|
|
|
type WrappedGrpcServer struct {
|
|
handler http.Handler
|
|
opts *options
|
|
corsWrapper *cors.Cors
|
|
originFunc func(origin string) bool
|
|
enableWebsockets bool
|
|
websocketOriginFunc func(req *http.Request) bool
|
|
websocketReadLimit int64
|
|
allowedHeaders []string
|
|
endpointFunc func(req *http.Request) string
|
|
endpointsFunc func() []string
|
|
}
|
|
|
|
// WrapServer takes a gRPC Server in Go and returns a *WrappedGrpcServer that provides gRPC-Web Compatibility.
|
|
//
|
|
// The internal implementation fakes out a http.Request that carries standard gRPC, and performs the remapping inside
|
|
// http.ResponseWriter, i.e. mostly the re-encoding of Trailers (that carry gRPC status).
|
|
//
|
|
// You can control the behaviour of the wrapper (e.g. modifying CORS behaviour) using `With*` options.
|
|
func WrapServer(server *grpc.Server, options ...Option) *WrappedGrpcServer {
|
|
return wrapGrpc(options, server, func() []string {
|
|
return ListGRPCResources(server)
|
|
})
|
|
}
|
|
|
|
// WrapHandler takes a http.Handler (such as a http.Mux) and returns a *WrappedGrpcServer that provides gRPC-Web
|
|
// Compatibility.
|
|
//
|
|
// This behaves nearly identically to WrapServer except when the WithCorsForRegisteredEndpointsOnly setting is true.
|
|
// Then a WithEndpointsFunc option must be provided or all CORS requests will NOT be handled.
|
|
func WrapHandler(handler http.Handler, options ...Option) *WrappedGrpcServer {
|
|
return wrapGrpc(options, handler, func() []string {
|
|
return []string{}
|
|
})
|
|
}
|
|
|
|
func wrapGrpc(options []Option, handler http.Handler, endpointsFunc func() []string) *WrappedGrpcServer {
|
|
opts := evaluateOptions(options)
|
|
allowedHeaders := append(opts.allowedRequestHeaders, internalRequestHeadersWhitelist...)
|
|
corsWrapper := cors.New(cors.Options{
|
|
AllowOriginFunc: opts.originFunc,
|
|
AllowedHeaders: allowedHeaders,
|
|
ExposedHeaders: nil, // make sure that this is *nil*, otherwise the WebResponse overwrite will not work.
|
|
AllowCredentials: true, // always allow credentials, otherwise :authorization headers won't work
|
|
MaxAge: int(opts.corsMaxAge.Seconds()),
|
|
})
|
|
websocketOriginFunc := opts.websocketOriginFunc
|
|
if websocketOriginFunc == nil {
|
|
websocketOriginFunc = defaultWebsocketOriginFunc
|
|
}
|
|
|
|
endpointFunc := func(req *http.Request) string {
|
|
return req.URL.Path
|
|
}
|
|
|
|
if opts.allowNonRootResources {
|
|
endpointFunc = getGRPCEndpoint
|
|
}
|
|
|
|
if opts.endpointsFunc != nil {
|
|
endpointsFunc = *opts.endpointsFunc
|
|
}
|
|
|
|
return &WrappedGrpcServer{
|
|
handler: handler,
|
|
opts: opts,
|
|
corsWrapper: corsWrapper,
|
|
originFunc: opts.originFunc,
|
|
enableWebsockets: opts.enableWebsockets,
|
|
websocketOriginFunc: websocketOriginFunc,
|
|
websocketReadLimit: opts.websocketReadLimit,
|
|
allowedHeaders: allowedHeaders,
|
|
endpointFunc: endpointFunc,
|
|
endpointsFunc: endpointsFunc,
|
|
}
|
|
}
|
|
|
|
// ServeHTTP takes a HTTP request and if it is a gRPC-Web request wraps it with a compatibility layer to transform it to
|
|
// a standard gRPC request for the wrapped gRPC server and transforms the response to comply with the gRPC-Web protocol.
|
|
//
|
|
// The gRPC-Web compatibility is only invoked if the request is a gRPC-Web request as determined by IsGrpcWebRequest or
|
|
// the request is a pre-flight (CORS) request as determined by IsAcceptableGrpcCorsRequest.
|
|
//
|
|
// You can control the CORS behaviour using `With*` options in the WrapServer function.
|
|
func (w *WrappedGrpcServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
|
if w.enableWebsockets && w.IsGrpcWebSocketRequest(req) {
|
|
if w.websocketOriginFunc(req) {
|
|
if !w.opts.corsForRegisteredEndpointsOnly || w.isRequestForRegisteredEndpoint(req) {
|
|
w.HandleGrpcWebsocketRequest(resp, req)
|
|
return
|
|
}
|
|
}
|
|
resp.WriteHeader(http.StatusForbidden)
|
|
_, _ = resp.Write(make([]byte, 0))
|
|
return
|
|
}
|
|
|
|
if w.IsAcceptableGrpcCorsRequest(req) || w.IsGrpcWebRequest(req) {
|
|
w.corsWrapper.Handler(http.HandlerFunc(w.HandleGrpcWebRequest)).ServeHTTP(resp, req)
|
|
return
|
|
}
|
|
w.handler.ServeHTTP(resp, req)
|
|
}
|
|
|
|
// IsGrpcWebSocketRequest determines if a request is a gRPC-Web request by checking that the "Sec-Websocket-Protocol"
|
|
// header value contains "grpc-websockets"
|
|
func (w *WrappedGrpcServer) IsGrpcWebSocketRequest(req *http.Request) bool {
|
|
if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" {
|
|
return false
|
|
}
|
|
|
|
for _, subproto := range req.Header.Values("Sec-Websocket-Protocol") {
|
|
for _, token := range strings.Split(subproto, ",") {
|
|
token = strings.TrimSpace(token)
|
|
if strings.EqualFold(token, "grpc-websockets") {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HandleGrpcWebRequest takes a HTTP request that is assumed to be a gRPC-Web request and wraps it with a compatibility
|
|
// layer to transform it to a standard gRPC request for the wrapped gRPC server and transforms the response to comply
|
|
// with the gRPC-Web protocol.
|
|
func (w *WrappedGrpcServer) HandleGrpcWebRequest(resp http.ResponseWriter, req *http.Request) {
|
|
intReq, isTextFormat := hackIntoNormalGrpcRequest(req)
|
|
intResp := newGrpcWebResponse(resp, isTextFormat)
|
|
intReq.URL.Path = w.endpointFunc(intReq)
|
|
w.handler.ServeHTTP(intResp, intReq)
|
|
intResp.finishRequest(req)
|
|
}
|
|
|
|
// HandleGrpcWebsocketRequest takes a HTTP request that is assumed to be a gRPC-Websocket request and wraps it with a
|
|
// compatibility layer to transform it to a standard gRPC request for the wrapped gRPC server and transforms the
|
|
// response to comply with the gRPC-Web protocol.
|
|
func (w *WrappedGrpcServer) HandleGrpcWebsocketRequest(resp http.ResponseWriter, req *http.Request) {
|
|
|
|
wsConn, err := websocket.Accept(resp, req, &websocket.AcceptOptions{
|
|
InsecureSkipVerify: true, // managed by ServeHTTP
|
|
Subprotocols: []string{"grpc-websockets"},
|
|
CompressionMode: w.opts.websocketCompressionMode,
|
|
})
|
|
if err != nil {
|
|
grpclog.Errorf("Unable to upgrade websocket request: %v", err)
|
|
return
|
|
}
|
|
|
|
if w.websocketReadLimit > 0 {
|
|
wsConn.SetReadLimit(w.websocketReadLimit)
|
|
}
|
|
|
|
headers := make(http.Header)
|
|
for _, name := range w.allowedHeaders {
|
|
if values, exist := req.Header[name]; exist {
|
|
headers[name] = values
|
|
}
|
|
}
|
|
|
|
ctx, cancelFunc := context.WithCancel(req.Context())
|
|
defer cancelFunc()
|
|
|
|
messageType, readBytes, err := wsConn.Read(ctx)
|
|
if err != nil {
|
|
grpclog.Errorf("Unable to read first websocket message: %v %v %v", messageType, readBytes, err)
|
|
return
|
|
}
|
|
|
|
if messageType != websocket.MessageBinary {
|
|
grpclog.Errorf("First websocket message is non-binary")
|
|
return
|
|
}
|
|
|
|
wsHeaders, err := parseHeaders(string(readBytes))
|
|
if err != nil {
|
|
grpclog.Errorf("Unable to parse websocket headers: %v", err)
|
|
return
|
|
}
|
|
|
|
respWriter := newWebSocketResponseWriter(ctx, wsConn)
|
|
if w.opts.websocketPingInterval >= time.Second {
|
|
respWriter.enablePing(w.opts.websocketPingInterval)
|
|
}
|
|
wrappedReader := newWebsocketWrappedReader(ctx, wsConn, respWriter, cancelFunc)
|
|
|
|
for name, values := range wsHeaders {
|
|
headers[name] = values
|
|
}
|
|
req.Body = wrappedReader
|
|
req.Method = http.MethodPost
|
|
req.Header = headers
|
|
req.URL.Path = w.endpointFunc(req)
|
|
|
|
interceptedRequest, isTextFormat := hackIntoNormalGrpcRequest(req.WithContext(ctx))
|
|
if isTextFormat {
|
|
grpclog.Errorf("web socket text format requests not yet supported")
|
|
}
|
|
|
|
w.handler.ServeHTTP(respWriter, interceptedRequest)
|
|
}
|
|
|
|
// IsGrpcWebRequest determines if a request is a gRPC-Web request by checking that the "content-type" is
|
|
// "application/grpc-web" and that the method is POST.
|
|
func (w *WrappedGrpcServer) IsGrpcWebRequest(req *http.Request) bool {
|
|
return req.Method == http.MethodPost && strings.HasPrefix(req.Header.Get("content-type"), grpcWebContentType)
|
|
}
|
|
|
|
// IsAcceptableGrpcCorsRequest determines if a request is a CORS pre-flight request for a gRPC-Web request and that this
|
|
// request is acceptable for CORS.
|
|
//
|
|
// You can control the CORS behaviour using `With*` options in the WrapServer function.
|
|
func (w *WrappedGrpcServer) IsAcceptableGrpcCorsRequest(req *http.Request) bool {
|
|
accessControlHeaders := strings.ToLower(req.Header.Get("Access-Control-Request-Headers"))
|
|
if req.Method == http.MethodOptions && strings.Contains(accessControlHeaders, "x-grpc-web") {
|
|
if w.opts.corsForRegisteredEndpointsOnly {
|
|
return w.isRequestForRegisteredEndpoint(req)
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (w *WrappedGrpcServer) isRequestForRegisteredEndpoint(req *http.Request) bool {
|
|
registeredEndpoints := w.endpointsFunc()
|
|
requestedEndpoint := w.endpointFunc(req)
|
|
for _, v := range registeredEndpoints {
|
|
if v == requestedEndpoint {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// readerCloser combines an io.Reader and an io.Closer into an io.ReadCloser.
|
|
type readerCloser struct {
|
|
reader io.Reader
|
|
closer io.Closer
|
|
}
|
|
|
|
func (r *readerCloser) Read(dest []byte) (int, error) {
|
|
return r.reader.Read(dest)
|
|
}
|
|
func (r *readerCloser) Close() error {
|
|
return r.closer.Close()
|
|
}
|
|
|
|
func hackIntoNormalGrpcRequest(req *http.Request) (*http.Request, bool) {
|
|
// Hack, this should be a shallow copy, but let's see if this works
|
|
req.ProtoMajor = 2
|
|
req.ProtoMinor = 0
|
|
|
|
contentType := req.Header.Get("content-type")
|
|
incomingContentType := grpcWebContentType
|
|
isTextFormat := strings.HasPrefix(contentType, grpcWebTextContentType)
|
|
if isTextFormat {
|
|
// body is base64-encoded: decode it; Wrap it in readerCloser so Body is still closed
|
|
decoder := base64.NewDecoder(base64.StdEncoding, req.Body)
|
|
req.Body = &readerCloser{reader: decoder, closer: req.Body}
|
|
incomingContentType = grpcWebTextContentType
|
|
}
|
|
req.Header.Set("content-type", strings.Replace(contentType, incomingContentType, grpcContentType, 1))
|
|
|
|
// Remove content-length header since it represents http1.1 payload size, not the sum of the h2
|
|
// DATA frame payload lengths. https://http2.github.io/http2-spec/#malformed This effectively
|
|
// switches to chunked encoding which is the default for h2
|
|
req.Header.Del("content-length")
|
|
req.ContentLength = -1
|
|
|
|
return req, isTextFormat
|
|
}
|
|
|
|
func defaultWebsocketOriginFunc(req *http.Request) bool {
|
|
origin, err := WebsocketRequestOrigin(req)
|
|
if err != nil {
|
|
grpclog.Warning(err)
|
|
return false
|
|
}
|
|
return origin == req.Host
|
|
}
|