2026-08-11 18:47:27 +08:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
"google.golang.org/grpc"
|
|
|
|
|
"google.golang.org/grpc/codes"
|
|
|
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
|
"google.golang.org/grpc/metadata"
|
|
|
|
|
reflectionv1 "google.golang.org/grpc/reflection/grpc_reflection_v1"
|
|
|
|
|
"google.golang.org/grpc/status"
|
|
|
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
|
"google.golang.org/protobuf/reflect/protodesc"
|
|
|
|
|
"google.golang.org/protobuf/reflect/protoreflect"
|
|
|
|
|
"google.golang.org/protobuf/reflect/protoregistry"
|
|
|
|
|
"google.golang.org/protobuf/types/descriptorpb"
|
|
|
|
|
"google.golang.org/protobuf/types/dynamicpb"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const maxDynamicRPCBody = 4 << 20
|
|
|
|
|
|
|
|
|
|
type dynamicGateway struct {
|
|
|
|
|
conn *grpc.ClientConn
|
|
|
|
|
allow map[string]struct{}
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
cache map[string]protoreflect.MethodDescriptor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type dynamicRPCResponse struct {
|
|
|
|
|
Code int32 `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
|
|
|
Details []json.RawMessage `json:"details,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newDynamicGateway(grpcAddr string, allow []string) (*dynamicGateway, error) {
|
|
|
|
|
conn, err := grpc.NewClient(reflectionTarget(grpcAddr), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create dynamic gRPC client: %w", err)
|
|
|
|
|
}
|
|
|
|
|
allowed := make(map[string]struct{}, len(allow))
|
|
|
|
|
for _, item := range allow {
|
|
|
|
|
if item = strings.TrimSpace(item); item != "" {
|
|
|
|
|
allowed[item] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return &dynamicGateway{conn: conn, allow: allowed, cache: make(map[string]protoreflect.MethodDescriptor)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func reflectionTarget(addr string) string {
|
|
|
|
|
host, port, err := net.SplitHostPort(addr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return addr
|
|
|
|
|
}
|
|
|
|
|
if host == "" || host == "0.0.0.0" || host == "::" {
|
|
|
|
|
host = "127.0.0.1"
|
|
|
|
|
}
|
|
|
|
|
return net.JoinHostPort(host, port)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g *dynamicGateway) Close() error { return g.conn.Close() }
|
|
|
|
|
|
|
|
|
|
func (g *dynamicGateway) handle(c *gin.Context) {
|
2026-08-11 18:55:36 +08:00
|
|
|
moduleName := strings.TrimSpace(c.Param("module"))
|
|
|
|
|
serviceShortName := strings.TrimSpace(c.Param("service"))
|
|
|
|
|
methodName := strings.TrimSpace(c.Param("method"))
|
|
|
|
|
if moduleName == "" || serviceShortName == "" || methodName == "" {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.InvalidArgument, "path must be /rpc/{module}/{service}/{method}"))
|
2026-08-11 18:47:27 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-08-11 18:55:36 +08:00
|
|
|
serviceName := moduleName + "." + serviceShortName
|
|
|
|
|
fullMethod := serviceName + "." + methodName
|
2026-08-11 18:47:27 +08:00
|
|
|
if !g.isAllowed(serviceName, fullMethod) {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.PermissionDenied, "dynamic RPC method is not allowed"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
descriptor, err := g.resolveMethod(c.Request.Context(), serviceName, methodName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeDynamicError(c, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if descriptor.IsStreamingClient() || descriptor.IsStreamingServer() {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.Unimplemented, "streaming RPC methods are not supported"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDynamicRPCBody+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.InvalidArgument, "read request body: "+err.Error()))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(body) > maxDynamicRPCBody {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.ResourceExhausted, "request body exceeds 4 MiB"))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request := dynamicpb.NewMessage(descriptor.Input())
|
|
|
|
|
if err := (protojson.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(body, request); err != nil {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.InvalidArgument, "invalid protobuf JSON: "+err.Error()))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
response := dynamicpb.NewMessage(descriptor.Output())
|
|
|
|
|
ctx := outgoingMetadata(c.Request)
|
|
|
|
|
grpcMethod := "/" + serviceName + "/" + methodName
|
|
|
|
|
if err := g.conn.Invoke(ctx, grpcMethod, request, response); err != nil {
|
|
|
|
|
writeDynamicError(c, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := (protojson.MarshalOptions{UseProtoNames: false}).Marshal(response)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeDynamicError(c, status.Error(codes.Internal, "marshal protobuf response: "+err.Error()))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.JSON(http.StatusOK, dynamicRPCResponse{Code: int32(codes.OK), Message: codes.OK.String(), Data: data})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g *dynamicGateway) isAllowed(serviceName, fullMethod string) bool {
|
|
|
|
|
for _, key := range []string{"*", serviceName, fullMethod} {
|
|
|
|
|
if _, ok := g.allow[key]; ok {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (g *dynamicGateway) resolveMethod(ctx context.Context, serviceName, methodName string) (protoreflect.MethodDescriptor, error) {
|
|
|
|
|
cacheKey := serviceName + "." + methodName
|
|
|
|
|
g.mu.RLock()
|
|
|
|
|
method := g.cache[cacheKey]
|
|
|
|
|
g.mu.RUnlock()
|
|
|
|
|
if method != nil {
|
|
|
|
|
return method, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stream, err := reflectionv1.NewServerReflectionClient(g.conn).ServerReflectionInfo(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, status.Error(codes.Unavailable, "open gRPC reflection stream: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
if err := stream.Send(&reflectionv1.ServerReflectionRequest{
|
|
|
|
|
MessageRequest: &reflectionv1.ServerReflectionRequest_FileContainingSymbol{FileContainingSymbol: serviceName},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return nil, status.Error(codes.Unavailable, "query gRPC reflection: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
reflectionResponse, err := stream.Recv()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, status.Error(codes.Unavailable, "read gRPC reflection response: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
fileResponse := reflectionResponse.GetFileDescriptorResponse()
|
|
|
|
|
if fileResponse == nil {
|
|
|
|
|
if reflectionErr := reflectionResponse.GetErrorResponse(); reflectionErr != nil {
|
|
|
|
|
return nil, status.Error(codes.Code(reflectionErr.ErrorCode), reflectionErr.ErrorMessage)
|
|
|
|
|
}
|
|
|
|
|
return nil, status.Error(codes.NotFound, "service descriptor not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set := &descriptorpb.FileDescriptorSet{}
|
|
|
|
|
for _, encoded := range fileResponse.FileDescriptorProto {
|
|
|
|
|
file := &descriptorpb.FileDescriptorProto{}
|
|
|
|
|
if err := proto.Unmarshal(encoded, file); err != nil {
|
|
|
|
|
return nil, status.Error(codes.Internal, "decode reflected descriptor: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
set.File = append(set.File, file)
|
|
|
|
|
}
|
|
|
|
|
files, err := protodesc.NewFiles(set)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, status.Error(codes.Internal, "build reflected descriptors: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
descriptor, err := files.FindDescriptorByName(protoreflect.FullName(serviceName))
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == protoregistry.NotFound {
|
|
|
|
|
return nil, status.Error(codes.NotFound, "service not found")
|
|
|
|
|
}
|
|
|
|
|
return nil, status.Error(codes.Internal, "resolve service descriptor: "+err.Error())
|
|
|
|
|
}
|
|
|
|
|
service, ok := descriptor.(protoreflect.ServiceDescriptor)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, status.Error(codes.NotFound, "symbol is not a gRPC service")
|
|
|
|
|
}
|
|
|
|
|
method = service.Methods().ByName(protoreflect.Name(methodName))
|
|
|
|
|
if method == nil {
|
|
|
|
|
return nil, status.Error(codes.NotFound, "method not found")
|
|
|
|
|
}
|
|
|
|
|
g.mu.Lock()
|
|
|
|
|
g.cache[cacheKey] = method
|
|
|
|
|
g.mu.Unlock()
|
|
|
|
|
return method, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func outgoingMetadata(request *http.Request) context.Context {
|
|
|
|
|
pairs := make([]string, 0)
|
|
|
|
|
for name, values := range request.Header {
|
|
|
|
|
lower := strings.ToLower(name)
|
|
|
|
|
if lower != "authorization" && lower != "x-request-id" && !strings.HasPrefix(lower, "x-") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
pairs = append(pairs, lower, value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return metadata.NewOutgoingContext(request.Context(), metadata.Pairs(pairs...))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeDynamicError(c *gin.Context, err error) {
|
|
|
|
|
grpcStatus := status.Convert(err)
|
|
|
|
|
details := make([]json.RawMessage, 0, len(grpcStatus.Details()))
|
|
|
|
|
for _, detail := range grpcStatus.Details() {
|
|
|
|
|
message, ok := detail.(proto.Message)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if encoded, marshalErr := protojson.Marshal(message); marshalErr == nil {
|
|
|
|
|
details = append(details, encoded)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
c.JSON(http.StatusOK, dynamicRPCResponse{
|
|
|
|
|
Code: int32(grpcStatus.Code()),
|
|
|
|
|
Message: grpcStatus.Message(),
|
|
|
|
|
Details: details,
|
|
|
|
|
})
|
|
|
|
|
}
|