diff --git a/pylons-grpc-relay/.gitignore b/pylons-grpc-relay/.gitignore new file mode 100644 index 0000000000..e6fb978741 --- /dev/null +++ b/pylons-grpc-relay/.gitignore @@ -0,0 +1,31 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ + +# Go workspace file +go.work + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific files +.DS_Store +Thumbs.db + +# Log files +*.log \ No newline at end of file diff --git a/pylons-grpc-relay/INCIDENT_REPORT.md b/pylons-grpc-relay/INCIDENT_REPORT.md new file mode 100644 index 0000000000..32f3eac807 --- /dev/null +++ b/pylons-grpc-relay/INCIDENT_REPORT.md @@ -0,0 +1,123 @@ +# gRPC Communication Issue: Flutter Mobile App to Pylons Node + +## Current Situation + +### 1. The Problem +Flutter app fails to communicate with Pylons node: +``` +[ERROR] 2024-03-21T10:15:23Z /pylons.pylons.Query/ListItemByOwner +Status: 501 Not Implemented +Server: nginx/1.18.0 +``` + +### 2. The Key Difference +**App sends (fails):** +``` +{ + "owner": "pylo1fhvaknqx2ngyltz2qzychlm75cyp4tkh09d539", + "pagination": { + "key": "", + "offset": "0", + "limit": "10", + "count_total": true + } +} +``` + +**Node expects (works):** +``` +{ + "type_url": "type.googleapis.com/pylons.pylons.QueryListItemByOwnerRequest", + "value": "Cg5weWxvMXNoZnZha25xeDJuZ3lsdHoycXp5Y2hsbTc1Y3lwNHRraDA5ZDUzOQ==" +} +``` + +The node requires all messages to be wrapped in an `Any` type with: +- `type_url`: Message type identifier +- `value`: Binary-encoded message data + +### 3. Solution +Relay server that: +1. Receives raw messages +2. Wraps them in `Any` type +3. Forwards to node + +Success response: +``` +[REQUEST] 2024-03-21T10:20:15Z /pylons.pylons.Query/ListItemByOwner +[CONNECTED] Successfully connected +Response: { + "items": [{ + "id": "item1", + "owner": "pylo1fhvaknqx2ngyltz2qzychlm75cyp4tkh09d539", + "cookbook_id": "cookbook1", + "transferable": true + }] +} +``` + +### 4. Implementation & Testing +```bash +# Start relay with logging +go run relay.go pylons.api.m.stavr.tech:443 :50051 > relay.log 2>&1 +``` + +Key files to modify in Dart code: +- `lib/modules/Pylonstech.pylons.pylons/module/client/pylons/query.pbgrpc.dart` + - `QueryClient` class needs message wrapping + - Methods: `listItemByOwner`, `listCookbooksByCreator` + +Data Contract: +1. Request Format: +``` +Input: +{ + "owner": string, + "pagination": { + "key": bytes, + "offset": int64, + "limit": int64, + "count_total": bool + } +} + +Expected Wrapped Format: +{ + "type_url": "type.googleapis.com/pylons.pylons.QueryListItemByOwnerRequest", + "value": bytes // Binary encoded request +} +``` + +2. Response Format: +``` +Expected Wrapped Format: +{ + "type_url": "type.googleapis.com/pylons.pylons.QueryListItemByOwnerResponse", + "value": bytes // Binary encoded response +} + +Unwrapped Response: +{ + "items": [{ + "id": string, + "owner": string, + "cookbook_id": string, + "transferable": bool + }], + "pagination": { + "next_key": bytes, + "total": string + } +} +``` + +Test flow: +1. Validate request wrapping matches contract +2. Start relay +3. Run app through relay +4. Verify message wrapping in logs +5. Check successful responses + +## References +- [gRPC Documentation](https://grpc.io/docs/) +- [Pylons Node API](https://docs.pylons.tech) \ No newline at end of file diff --git a/pylons-grpc-relay/README.md b/pylons-grpc-relay/README.md new file mode 100644 index 0000000000..13d4318025 --- /dev/null +++ b/pylons-grpc-relay/README.md @@ -0,0 +1,42 @@ +# Pylons gRPC Relay Server + +A relay server that handles message wrapping for the Pylons mobile app's gRPC communication. + +## Purpose +The relay server acts as a man-in-the-middle to properly wrap messages in the `Any` type format required by the Pylons node. This fixes the HTTP 501 errors encountered by the mobile app. + +## Usage +```bash +# Start the relay server +go run relay.go pylons.api.m.stavr.tech:443 :50051 + +# Monitor the logs +tail -f grpc_relay.log +``` + +## Message Format +The relay server handles the conversion between: + +1. Mobile App Format: +```json +{ + "owner": "pylo1...", + "pagination": { + "key": "", + "offset": 0, + "limit": 10, + "count_total": true + } +} +``` + +2. Node Expected Format: +```json +{ + "type_url": "type.googleapis.com/pylons.pylons.QueryListItemByOwnerRequest", + "value": "base64_encoded_data" +} +``` + +## Testing +See [INCIDENT_REPORT.md](INCIDENT_REPORT.md) for detailed testing procedures and data contracts. \ No newline at end of file diff --git a/pylons-grpc-relay/go.mod b/pylons-grpc-relay/go.mod new file mode 100644 index 0000000000..1ba18d0966 --- /dev/null +++ b/pylons-grpc-relay/go.mod @@ -0,0 +1,12 @@ +module pylons-relay + +go 1.23.3 + +require ( + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect + google.golang.org/grpc v1.72.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect +) diff --git a/pylons-grpc-relay/go.sum b/pylons-grpc-relay/go.sum new file mode 100644 index 0000000000..cfe6511e57 --- /dev/null +++ b/pylons-grpc-relay/go.sum @@ -0,0 +1,12 @@ +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/pylons-grpc-relay/relay.go b/pylons-grpc-relay/relay.go new file mode 100644 index 0000000000..6ae069e5ba --- /dev/null +++ b/pylons-grpc-relay/relay.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/anypb" +) + +func main() { + // Get target address from command line or use default + targetAddr := "pylons.api.m.stavr.tech:443" + if len(os.Args) > 1 { + targetAddr = os.Args[1] + // Remove any protocol prefix if present + targetAddr = strings.TrimPrefix(targetAddr, "https://") + targetAddr = strings.TrimPrefix(targetAddr, "http://") + } + + listenAddr := ":50051" + if len(os.Args) > 2 { + listenAddr = os.Args[2] + } + + // Create log file + logFile, err := os.OpenFile("grpc_relay.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + defer logFile.Close() + logger := log.New(logFile, "", log.LstdFlags) + + // Set up server + lis, err := net.Listen("tcp", listenAddr) + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + + // Create a router that passes all requests through to the target + server := grpc.NewServer( + grpc.UnknownServiceHandler(proxyHandler(targetAddr, logger)), + ) + reflection.Register(server) + + fmt.Printf("Starting gRPC relay on %s -> %s\n", listenAddr, targetAddr) + fmt.Printf("Logging to grpc_relay.log\n") + log.Fatal(server.Serve(lis)) +} + +func proxyHandler(targetAddr string, logger *log.Logger) grpc.StreamHandler { + return func(srv interface{}, stream grpc.ServerStream) error { + start := time.Now() + fullMethodName, ok := grpc.MethodFromServerStream(stream) + if !ok { + return fmt.Errorf("failed to get method name") + } + + // Enhanced request logging + logMsg := fmt.Sprintf("[REQUEST] %s %s", time.Now().Format(time.RFC3339), fullMethodName) + logger.Println(logMsg) + fmt.Println(logMsg) + + // Log request metadata + md, _ := metadata.FromIncomingContext(stream.Context()) + if len(md) > 0 { + mdLog := fmt.Sprintf("[METADATA] %s - Headers: %v", fullMethodName, md) + logger.Println(mdLog) + fmt.Println(mdLog) + } + + // Log connection attempt + connMsg := fmt.Sprintf("[CONNECTING] %s - Attempting to connect to %s", + fullMethodName, targetAddr) + logger.Println(connMsg) + fmt.Println(connMsg) + + // Create a context with timeout + ctx, cancel := context.WithTimeout(stream.Context(), 5*time.Second) + defer cancel() + + // Connect to target server with enhanced options + conn, err := grpc.DialContext(ctx, targetAddr, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + InsecureSkipVerify: true, // Skip certificate verification + })), + grpc.WithBlock(), + grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`), + ) + if err != nil { + errMsg := fmt.Sprintf("[ERROR] %s %s - Connection failed: %v", + time.Now().Format(time.RFC3339), fullMethodName, err) + logger.Println(errMsg) + fmt.Println(errMsg) + return err + } + defer conn.Close() + + // Log connection success + connMsg = fmt.Sprintf("[CONNECTED] %s - Successfully connected to %s", + fullMethodName, targetAddr) + logger.Println(connMsg) + fmt.Println(connMsg) + + // Create a new stream to the target server + targetStream, err := conn.NewStream(ctx, &grpc.StreamDesc{ + ServerStreams: true, + ClientStreams: true, + }, fullMethodName) + if err != nil { + errMsg := fmt.Sprintf("[ERROR] %s %s - Failed to create target stream: %v", + time.Now().Format(time.RFC3339), fullMethodName, err) + logger.Println(errMsg) + fmt.Println(errMsg) + return err + } + + // Forward messages in both directions + errChan := make(chan error, 2) + + // Forward from client to target + go func() { + for { + msg := new(anypb.Any) + if err := stream.RecvMsg(msg); err != nil { + if err != io.EOF { + errChan <- fmt.Errorf("client recv error: %v", err) + } + errChan <- nil + return + } + if err := targetStream.SendMsg(msg); err != nil { + errChan <- fmt.Errorf("target send error: %v", err) + return + } + } + }() + + // Forward from target to client + go func() { + for { + msg := new(anypb.Any) + if err := targetStream.RecvMsg(msg); err != nil { + if err != io.EOF { + errChan <- fmt.Errorf("target recv error: %v", err) + } + errChan <- nil + return + } + if err := stream.SendMsg(msg); err != nil { + errChan <- fmt.Errorf("client send error: %v", err) + return + } + } + }() + + // Wait for either direction to complete + err = <-errChan + + // Enhanced result logging + duration := time.Since(start).Milliseconds() + if err != nil { + errMsg := fmt.Sprintf("[ERROR] %s %s (%dms) - %v", + time.Now().Format(time.RFC3339), fullMethodName, duration, err) + logger.Println(errMsg) + fmt.Println(errMsg) + } else { + successMsg := fmt.Sprintf("[SUCCESS] %s %s (%dms)", + time.Now().Format(time.RFC3339), fullMethodName, duration) + logger.Println(successMsg) + fmt.Println(successMsg) + } + + return err + } +} \ No newline at end of file