gRPC API Server for .NET on Raspberry Pi

gRPC API Server for .NET on Raspberry Pi
gRPC Servers us allow to setup APIs that communicate more efficiently overall. Image Source - (Generative)

Setting up a gRPC API Server on an edge device such as a Raspberry Pi can be useful to offload compute responsibilities elsewhere, maintaining a small footprint computationally and power-wise. When scaled up, the gRPC framework compared to using REST, gives us advantages of native HTTP/2 support with more efficiency and performance improvements. These improvements come in the form of smaller message sizes and persistent connections and streams.

In this short blog post, I experiment with setting up a gRPC Server on a Raspberry Pi 4 with .NET 8, and sending requests to it from a Windows client using an https connection. Let's see what this looks like.

Setup Raspberry Pi gRPC Server Code

Using a ASP.NET Web API Project template, we can use the following server code.

using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using System.Net;
using WebApplication10;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddGrpc();

//You can define the server options here or appsettings
builder.WebHost.UseKestrel(options =>
{

    options.Listen(["YOUR_RPI_IPADDRESS"], 5001, listenOptions =>
    {
        listenOptions.Protocols = HttpProtocols.Http2;
        //define the path of the certificate on the server,
        //which we can create on our own. This can also
        //be alternatively defined in the appsettings file
        listenOptions.UseHttps("/pathToYour/certificate.pfx",
            "yourCertificatePassword");
    });

});
var app = builder.Build(); 
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error"); 
}

app.MapGrpcService<GreeterService>();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();

Server code

Alternatively you can configure the kestrel server from the appsettings file as follows:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
 //your Pi's IP address and a chosen port number, this is my setup
  "Kestrel": {
    "Endpoints": {
      "gRPC": {
        "Url": "https://192.168.1.27:5001",
        "Protocols": "Http2"
      }
    },
    "Certificates": {
      "Default": {
        "Path": "/pathToYour/certificate.pfx",
        "Password": "yourCertificatePassword"
      }
    }
  }
}

Server appsettings file

The service definition and definition of available gRPC methods is done through the use of .proto files like the following. In this blog, I will be demonstrating a single gRPC endpoint for simplicity and brevity but multiple services can be defined:

syntax = "proto3";
option csharp_namespace = "GrpcGreeter";
package greet;

service Greeter {
  rpc InspectAquarium(FishRequest) returns(FishReply);
}

message FishRequest{
  string name = 1;
}

message FishReply {
  string BionomialName = 1;
  string waterType = 2;
  string Diet = 3;
  int32 AverageLifespanYears = 4;
  bool IsEndangered = 5;
}

proto file defining the gRPC service contract

The concrete implementation of an available can be the following:

using Grpc.Core;
using GrpcGreeter;
using System.Xml.Linq;

public class GreeterService : Greeter.GreeterBase
 {
     private readonly ILogger<GreeterService> _logger;
     //Fish class defined below :)
     private List<Fish> fishLibrary { get; set; }

     public GreeterService(ILogger<GreeterService> logger)
     {
      _logger = logger;

      fishLibrary = new List<Fish>()
     {
         new Fish() {
             FishName = "Bass",
             BionomialName = "Micropterus salmoides",
             WaterType = "Freshwater",
             Diet = "Small fish and crayfish",
             AverageLifespanYears = "12",
             IsEndangered = "No"
         },
         new Fish() {
             FishName = "Cat fish",
             BionomialName = "Siluriformes",
             WaterType = "Freshwater",
             Diet = "Small fish, plants, insects",
             AverageLifespanYears = "15",
             IsEndangered = "No"
         },
         new Fish() {
             FishName = "Tuna",
             BionomialName = "Thunnus",
             WaterType = "Saltwater",
             Diet = "Fish, squid, crustaceans",
             AverageLifespanYears = "22",
             IsEndangered = "No"
         },
         new Fish() {
             FishName = "Sword fish",
             BionomialName = "Xiphias gladius",
             WaterType = "Saltwater",
             Diet = "Fish, squid, crustaceans",
             AverageLifespanYears = "8",
             IsEndangered = "No"
         },
         new Fish() {
             FishName = "Tilapia",
             BionomialName = "Oreochromis",
             WaterType = "Freshwater",
             Diet = "Plants, algae, insects",
             AverageLifespanYears = "9",
             IsEndangered = "No"
         }
         //Add more here if you like
      };
}

public override Task<FishReply> InspectAquarium(FishRequest request,
            ServerCallContext context)
   {
       var record = 
             fishLibrary.Where(x => x.FishName.ToLower() == request.Name).FirstOrDefault();
       if(record != null)
       {
           return Task.FromResult(new FishReply
           {
               BionomialName = record?.BionomialName,
               WaterType = record?.WaterType,
               AverageLifespanYears = int.Parse(record?.AverageLifespanYears),
               Diet = record?.Diet,
               IsEndangered = record.IsEndangered == "Yes" ? true : false
           }) ;
       }
       else
       {
           return Task.FromResult(new FishReply
           {
               BionomialName = "[Couldnt find this, can you try something else!!]"
           });
       }
     }
   }
 }

Implementation of gRPC service method

Fish Class:

 public class Fish
 {
     public string FishName { get; set; }
     public string BionomialName { get; set; }
     public string WaterType { get; set; }
     public string Diet { get; set; }
     public string AverageLifespanYears { get; set; }
     public string IsEndangered { get; set; }
 }

Server Project package references:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.1" />
		<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
	</ItemGroup>
	<ItemGroup>
		<PackageReference Include="Grpc.AspNetCore" Version="2.60.0" />
		<PackageReference Include="Grpc.Tools" Version="2.60.0">
			<PrivateAssets>
				all
			</PrivateAssets>
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
		</PackageReference>
	</ItemGroup>
	<ItemGroup>
		<Protobuf Include="Protos\greet.proto" />
	</ItemGroup>
</Project>

Server project packages references

Setup the client code

The Windows client must also have the certificate that we used on the server as a one of the trusted certificates within the list of Trusted Root Certificate Authorities. Doing this will mean that our Windows client does not face a Root Certificate validation error when carrying out certificate validation. There is a quick, great Windows guide on doing this here within the Accepted answer.

The Windows Client console app example here will set up a perpetual text input for the user that uses the input as the body of the gRPC request. When the server (raspberry Pi 4) responds, the Windows client has to carry out checks on the validity of the server certificate in order for the https handshake to complete successfully (or this can be completely ignored too). The checks include key checks, such as making sure the request that the client makes will access the server through the value defined in the certificate's Common Name definition or secondarily the Subject Alternative Name. We are then able to check for validation errors:

using Grpc.Net.Client;
using GrpcGreeter;
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;

namespace ConsoleApp
{
    public class Program
    {
        public async static Task Main(string[] args)
        {
           var fish = new Aqua();
           await fish.QueryFish();
           Console.ReadKey();
        }

        public string Endangered(bool isEndangered)
        {
            return isEndangered ? "endagered" : "not endangered";
        }

        public class Aqua
        {
            public bool ValidateServerCertificate(
                          object sender,
                          X509Certificate certificate,
                          X509Chain chain,
                          SslPolicyErrors sslPolicyErrors)
            {
                if (sslPolicyErrors == SslPolicyErrors.None)
                    return true;
                Console.WriteLine("Certificate error: {0}", sslPolicyErrors);
                return false;
            }

            public async Task<string> QueryFish()
            {
                try
                {
                    Func<object,X509Certificate,X509Chain,SslPolicyErrors, bool>
                          checkCertificateValidation = ValidateServerCertificate;

                    var httpHandler = new HttpClientHandler();

                    //if using a certificate on the server, check for any errors
                    //through a validation process
                    httpHandler.ServerCertificateCustomValidationCallback =
                            checkCertificateValidation;

                  // to skip certificate validation for dev purposes, use this instead 
                  //  httpHandler.ServerCertificateCustomValidationCallback = 
                  // HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
                  
            //use the RPI4's local IP address and port.
            //The name/IP used here must be the CN or SAN value in your certificate
                    var channel = GrpcChannel.ForAddress("https://192.168.1.27:5001",
                        new GrpcChannelOptions { HttpHandler = httpHandler });

                    var client = new Greeter.GreeterClient(channel);
                    
                    while (true)
                    {
                        Console.Write("Send Message: ");
                        string text = Console.ReadLine();

                        var reply =
                           await client.InspectAquariumAsync(new FishRequest
                           {
                               Name = text
                           });

                       if (text.Equals("exit", StringComparison.OrdinalIgnoreCase))
                         {
                             break;
                         }
                      Console.WriteLine($"Reply: A {reply.BionomialName} is naturally" +
              $" a habitant of {reply.WaterType}," +
              $" and feeds on {reply.Diet} with an average lifespan of" +
              $" {reply.AverageLifespanYears} years." +
              $" It is currently {Endangered(reply.IsEndangered)}");
                    }
                }
                catch (Exception e)
                {
                    Console.WriteLine("went wrong" + e.Message);
                }
                return "done";
            }

        }
    }
}

Client Project Package References:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

<ItemGroup> <PackageReference Include="Google.Protobuf" Version="3.25.2" /> 
<PackageReference Include="Grpc.AspNetCore" Version="2.60.0" /> 
<PackageReference Include="Grpc.Net.Client" Version="2.60.0" />
<PackageReference Include="Grpc.Tools" Version="2.60.0"> 
	<PrivateAssets> all</PrivateAssets>     
	<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> 
	<Protobuf Include="Protos\greet.proto" /> 
</ItemGroup>

<ItemGroup>
  <Folder Include="Protos\" />
</ItemGroup>

</Project>

Client Console App project package References

Before running the client code, we must make sure that our client can trust the Certificate it sees from our server by adding the Certificate Authority to the Windows Trust Store, since the Windows client has never interacted with any services using the custom certificate issued by the custom Certificate Authority. I added my very roughly made certificate authority (using this handy tool https://certificatetools.com/ which should give you a .pfx) in my trusted Root CAs that will be trusted until 2025. (For quick Instructions to do this in Windows follow this quick guide)

Certificate Authorities in Windows

Publish Server App and run the code

Publish the Web Server Code and deploy to the Raspberry Pi 4 (I simply used a web publish in Visual Studio and copying the folder to the RPI4), ending up with a folder on your Raspberry Pi with the published web app files alongside your ready .pfx file.

With the .NET Framework already installed on the Raspberry Pi 4, from the root of your deployed Web app folder, run:

dotnet [YourWebAppName.dll]

The server should start running without any issues:

Running the webserver on Raspberry Pi

On the Windows machine, run the console client code and you can now communicate with the server running from the Raspberry Pi 4 using gRPC!!. As you can see below we can send a message to server with the simple name of the fish we want and we get some details back from the gRPC Server. Neat:

gRPC Server responding to requests

Using Postman and it's new support for making gRPC requests (compared to standard HTTP requests), we can do the same with good results:

Postman using the gRPC endpoint with the help of the service Import through our .proto file.