DEV Community

Julien Andrieux
Julien Andrieux

Posted on

How we use gRPC to build a client/server system in Go

This was originally posted on Medium

How we use gRPC to build a client/server system in Go


Photo by Igor Ovsyannykov on Unsplash

This post is a technical presentation on how we use gRPC (and Protobuf) to build a robust client/server system.

I won’t go into the details of why we chose gRPC as the main communication protocol between our client and server, a lot of great posts already cover the subject (like this one, this one or this one).
But just to get the big picture: we are building a client-server system, using Go, that needs to be fast, reliable, and that scales (thus we chose gRPC). We wanted the communication between the client and the server to be as small as possible, as secure as possible and fully compliant with both ends (thus we chose Protobuf).

We also wanted to expose another interface on the server side, just in case we add a client that has no compatible gRPC implementation: a traditional REST interface. And we wanted that at (almost) no cost.

Context

We will start building a very simple client/server system, in Go, that will just exchange dummy messages. Once both communicate and understand each other, we’ll add extra features, such as TLS support, authentication, and a REST API.

The rest of this articles assumes you understand basic Go programming. It also assumes that you have protobuf package installed, and the protoc command available (once again, many posts cover that topic, and there’s the official documentation).

You will also need to install Go dependencies, such as the go implementation for protobuf, and grpc-gateway.

All the code shown in this post is available at https://gitlab.com/pantomath-io/demo-grpc. So feel free to get the repository, and use the tags to navigate in it. The repository should be placed in the src folder of your $GOPATH:

$ go get -v -d gitlab.com/pantomath-io/demo-grpc
$ cd $GOPATH/src/gitlab.com/pantomath-io/demo-grpc
Enter fullscreen mode Exit fullscreen mode

Defining the protocol

git tag: init-protobuf-definition


Photo by Mark Rasmuson on Unsplash

First of all, you need to define the protocol, i.e. to define what can be said between client and server, and how. This is where Protobuf comes into play. It allows you to define two things: Services and Messages. A service is a collection of actions the server can perform at the client’s request, a message is the content of this request. To simplify, you can say that service defines actions, while message defines objects.

Write the following in api/api.proto:

syntax = "proto3";
package api;

message PingMessage {
  string greeting = 1;
}

service Ping {
  rpc SayHello(PingMessage) returns (PingMessage) {}
}
Enter fullscreen mode Exit fullscreen mode

So, you defined 2 things: a service called Ping that exposes a function called SayHello with an incoming PingMessage and returns a PingMessage ; and a message called PingMessage that consists in a single field called greeting which is a string.
You also specified that you are using the proto3 syntax, as opposed to proto2(see documentation).

This file is not usable like this: it needs to get compiled. Compiling the proto file means generating code for your chosen language, that your application will actually call.
In a shell, cd to the root directory of your project, and run the following command:

$ protoc -I api/ \
    -I${GOPATH}/src \
    --go_out=plugins=grpc:api \
    api/api.proto
Enter fullscreen mode Exit fullscreen mode

This command generates the file api/api.pb.go, a Go source file that implements the gRPC code your application will use. You can look at it, but you shouldn’t change it (as it will be overwritten every time you run protoc).

You also need to define the function called by the service Ping, so create a file named api/handler.go:

package api

import (
  "log"

  "golang.org/x/net/context"
)

// Server represents the gRPC server
type Server struct {
}

// SayHello generates response to a Ping request
func (s *Server) SayHello(ctx context.Context, in *PingMessage) (*PingMessage, error) {
  log.Printf("Receive message %s", in.Greeting)
  return &PingMessage{Greeting: "bar"}, nil
}
Enter fullscreen mode Exit fullscreen mode
  • the Server struct is just an abstraction of the server. It allows to “attach some resources to your server, making them available during the RPC calls;
  • the SayHello function is the one defined in the Protobuf file, as the rpc call for the Ping service. If you don’t define it, you won’t be able to create the gRPC server;
  • SayHello takes a PingMessage as parameter, and returns a PingMessage. The PingMessage struct is defined in the api.pb.go file auto-generated from the api.proto definition. The function also has a Context parameter (see further presentation in the official blog post). You’ll see later what use you can do of the Context. On the other side, it also returns an error, in case something bad happens.

Creating the simplest server

git tag: init-server


Photo by Nathan Dumlao on Unsplash

Now you have a protocol in place, you can create a simple server that implements the service and understands the message. Take your favorite editor and create the file server/main.go:

package main

import (
  "fmt"
  "log"
  "net"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "google.golang.org/grpc"
)

// main start a gRPC server and waits for connection
func main() {
  // create a listener on TCP port 7777
  lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 7777))
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }

  // create a server instance
  s := api.Server{}

  // create a gRPC server object
  grpcServer := grpc.NewServer()

  // attach the Ping service to the server
  api.RegisterPingServer(grpcServer, &s)

  // start the server
  if err := grpcServer.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %s", err)
  }
}
Enter fullscreen mode Exit fullscreen mode

Let me break down to code to make it clearer:

  • note that you import the api package, so that the Protobuf service handlers and the Server struct are available;
  • the main function starts by creating a TCP listener on the port you want to bind your gRPC server to;
  • then the rest is pretty straight forward: you create an instance of your Server, create an instance of a gRPC server, register the service, and start the gRPC server.

You can compile your code to get a server binary:

$ go build -i -v -o bin/server gitlab.com/pantomath-io/demo-grpc/server
Enter fullscreen mode Exit fullscreen mode

Creating the simplest client

git tag: init-client


Photo by Clem Onojeghuo on Unsplash

The client also imports the api package, so that the message and the service are available. So create the file client/main.go:

package main

import (
  "log"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "golang.org/x/net/context"
  "google.golang.org/grpc"
)

func main() {
  var conn *grpc.ClientConn

  conn, err := grpc.Dial(":7777", grpc.WithInsecure())
  if err != nil {
    log.Fatalf("did not connect: %s", err)
  }
  defer conn.Close()

  c := api.NewPingClient(conn)

  response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
  if err != nil {
    log.Fatalf("Error when calling SayHello: %s", err)
  }
  log.Printf("Response from server: %s", response.Greeting)
}
Enter fullscreen mode Exit fullscreen mode

Once again, the break down is pretty straight forward:

  • the main function instantiates a client connection, on the TCP port the server is bound to;
  • note the defer call to properly close the connection when the function returns;
  • the c variable is a client for the the Ping service, that calls the SayHello function, passing a PingMessage to it.

You can compile your code to get a client binary:

$ go build -i -v -o bin/client gitlab.com/pantomath-io/demo-grpc/client
Enter fullscreen mode Exit fullscreen mode

Make them talk

You’ve just built a client and a server, so fire them in two terminals to test them:

$ bin/server
2006/01/02 15:04:05 Receive message foo
Enter fullscreen mode Exit fullscreen mode
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Enter fullscreen mode Exit fullscreen mode

Tool to ease your life

git tag: init-makefile

Now the API, the client and the server are working, you may prefer to have a Makefile to compile the code, clean your folder, manage dependencies, etc.

So create this Makefile at the root of the project folder. Explaining this file is beyond the scope of this post, and it mostly uses compile command you already spawned previously.

To use the Makefile , try calling the following:

$ make help
api                            Auto-generate grpc go sources
build_client                   Build the binary file for client
build_server                   Build the binary file for server
clean                          Remove previous builds
dep                            Get the dependencies
help                           Display this help screen
Enter fullscreen mode Exit fullscreen mode

Secure the communication

git tag: add-ssl


Photo by Nathaniel Tetteh on Unsplash

The client and the servers talk to each other, over HTTP/2 (transport layer on gRPC). The messages are binary data(thanks to Protobuf), but the communication is in plaintext. Fortunately, gRPC has SSL/TLS integration, that can be used to authenticate the server (from the client’s perspective), and to encrypt message exchanges.

You don’t need to change anything to the protocol: it remains the same. The changes take place in the gRPC object creation, on both client and server side. Note that if you change only one side, the connection won’t work.

Before you change anything in the code, you need to create a self-signed SSL certificate. The purpose of this post is not to explain how to do that, but the official OpenSSL documentation (genrsa, req, x509) can answer your question about it (DigitalOcean also has a nice and complete tutorial about it). Meanwhile, you can just use the files provided in the cert folder. The following commands have been used to generate the files:

$ openssl genrsa -out cert/server.key 2048
$ openssl req -new -x509 -sha256 -key cert/server.key -out cert/server.crt -days 3650
$ openssl req -new -sha256 -key cert/server.key -out cert/server.csr
$ openssl x509 -req -sha256 -in cert/server.csr -signkey cert/server.key -out cert/server.crt -days 3650
Enter fullscreen mode Exit fullscreen mode

You can proceed and update the server definition to use the certificate and the key:

package main

import (
  "fmt"
  "log"
  "net"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
)

// main starts a gRPC server and waits for connection
func main() {
  // create a listener on TCP port 7777
  lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 7777))
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }

  // create a server instance
  s := api.Server{}

  // Create the TLS credentials
  creds, err := credentials.NewServerTLSFromFile("cert/server.crt", "cert/server.key")
  if err != nil {
    log.Fatalf("could not load TLS keys: %s", err)
  }

  // Create an array of gRPC options with the credentials
  opts := []grpc.ServerOption{grpc.Creds(creds)}

  // create a gRPC server object
  grpcServer := grpc.NewServer(opts...)

  // attach the Ping service to the server
  api.RegisterPingServer(grpcServer, &s)

  // start the server
  if err := grpcServer.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %s", err)
  }
}
Enter fullscreen mode Exit fullscreen mode

So what changed?

  • you created a credentials object (called creds) from your certificate and key files;
  • you created a grpc.ServerOption array and placed your credentials object in it;
  • when creating the grpc server, you provided the constructor with you array of grpc.ServerOption;
  • you must have noticed that you need to precisely specify the IP you bind your server to, so that the IP matches the FQDN used in the certificate.

Note that grpc.NewServer() is a variadic function, so you can pass it any number of trailing arguments. You created an array of options so that we can add other options later on.

If you compile your server now, and use the client you already have, the connection won’t work, and both sides will throw an error.

  • the server report the client is not handshaking with TLS:
2006/01/02 15:04:05 grpc: Server.Serve failed to complete security handshake from "localhost:64018": tls: first record does not look like a TLS handshake
Enter fullscreen mode Exit fullscreen mode
  • the client has its connection closed before it can do anything:
2006/01/02 15:04:05 transport: http2Client.notifyError got notified that the client transport was broken read tcp localhost:64018->127.0.0.1:7777: read: connection reset by peer.
2006/01/02 15:04:05 Error when calling SayHello: rpc error: code = Internal desc = transport is closing
Enter fullscreen mode Exit fullscreen mode

You need to use the exact same certificate file on the client side. So edit the client/main.go file:

package main

import (
  "log"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "golang.org/x/net/context"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
)

func main() {
  var conn *grpc.ClientConn

// Create the client TLS credentials
  creds, err := credentials.NewClientTLSFromFile("cert/server.crt", "")
  if err != nil {
    log.Fatalf("could not load tls cert: %s", err)
  }

  // Initiate a connection with the server
  conn, err = grpc.Dial("localhost:7777", grpc.WithTransportCredentials(creds))
  if err != nil {
    log.Fatalf("did not connect: %s", err)
  }
  defer conn.Close()

  c := api.NewPingClient(conn)

  response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
  if err != nil {
    log.Fatalf("error when calling SayHello: %s", err)
  }
  log.Printf("Response from server: %s", response.Greeting)
}
Enter fullscreen mode Exit fullscreen mode

The changes on the client side are pretty much the same as on the server:

  • you created a credentials object with the certificate file. Note that the client do not use the certificate key, the key is private to the server;
  • you added an option to the grpc.Dial() function, using your credentials object. Note that the grpc.Dial() function is also a variadic function, so it accepts any number of options;
  • same server note applies for the client: you need to use the same FQDN to connect to the server as the one used in the certificate, or the transport authentication handshake will fail.

Both sides use credentials, so they should be able to talk just as before, but in an encrypted way. You can compile the code:

$ make
Enter fullscreen mode Exit fullscreen mode

And run both sides in separate terminals:

$ bin/server
2006/01/02 15:04:05 Receive message foo
Enter fullscreen mode Exit fullscreen mode
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Enter fullscreen mode Exit fullscreen mode

Identify the client

git tag: add-auth


Photo by chuttersnap on Unsplash

Another interesting feature of the gRPC server is the ability to intercept a request from the client. The client can inject information on the transport layer. You can use that feature to identify your client, because the SSL implementation authenticates the server (via the certificate), but not the client (all your clients are using the same certificate).

So you’ll update the client side to inject metadata on every call (like a login and password), and the server side to check these credentials for every incoming call.

On the client side, you just need to specify a DialOption on your grpc.Dial() call. But that DialOptionhas some constraints. Edit your client/main.go file:

package main

import (
  "log"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "golang.org/x/net/context"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
)

// Authentication holds the login/password
type Authentication struct {
  Login    string
  Password string
}

// GetRequestMetadata gets the current request metadata
func (a *Authentication) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
  return map[string]string{
    "login":    a.Login,
    "password": a.Password,
  }, nil
}

// RequireTransportSecurity indicates whether the credentials requires transport security
func (a *Authentication) RequireTransportSecurity() bool {
  return true
}

func main() {
  var conn *grpc.ClientConn

// Create the client TLS credentials
  creds, err := credentials.NewClientTLSFromFile("cert/server.crt", "")
  if err != nil {
    log.Fatalf("could not load tls cert: %s", err)
 }

  // Setup the login/pass
  auth := Authentication{
    Login:    "john",
    Password: "doe",
  }

  // Initiate a connection with the server
  conn, err = grpc.Dial("localhost:7777", grpc.WithTransportCredentials(creds), grpc.WithPerRPCCredentials(&auth))
  if err != nil {
    log.Fatalf("did not connect: %s", err)
   }
   defer conn.Close()

  c := api.NewPingClient(conn)

  response, err := c.SayHello(context.Background(), &api.PingMessage{Greeting: "foo"})
  if err != nil {
    log.Fatalf("error when calling SayHello: %s", err)
  }
  log.Printf("Response from server: %s", response.Greeting)
}
Enter fullscreen mode Exit fullscreen mode
  • You define a struct to hold the collection on fields you want to inject in your rcp calls. In our case, just a login and password, but you can imagine any fields you want;
  • The auth variable holds the values you’ll be using;
  • You use grpc.WithPerRPCCredentials() function to create a DialOption object to the grpc.Dial() function;
  • Note that the grpc.WithPerRPCCredentials() function takes an interface as parameter, so your Authentication structure should comply to that interface. From the documentation, you know you should implement 2 methods on your structure: GetRequestMetadata and RequireTransportSecurity.
  • So you define GetRequestMetadata function that just returns a map of your Authentication structure;
  • And finally, you define RequireTransportSecurity function, that tells your grpc client if it should inject metadata at the transport level. In our current case, it always returns true, but you could have it return the value of a configuration boolean, for instance.

The client is up to push extra data during its calls to the server, but the server does not care, right now. So you need to tell him to check these metadata. Open server/main.go and update it:

package main

import (
  "fmt"
  "log"
  "net"
  "strings"

  "golang.org/x/net/context"

  "gitlab.com/pantomath-io/demo-grpc/api"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc/metadata"
)

// private type for Context keys
type contextKey int

const (
  clientIDKey contextKey = iota
)

// authenticateAgent check the client credentials
func authenticateClient(ctx context.Context, s *api.Server) (string, error) {
  if md, ok := metadata.FromIncomingContext(ctx); ok {
    clientLogin := strings.Join(md["login"], "")
    clientPassword := strings.Join(md["password"], "")

    if clientLogin != "john" {
      return "", fmt.Errorf("unknown user %s", clientLogin)
    }
    if clientPassword != "doe" {
      return "", fmt.Errorf("bad password %s", clientPassword)
    }

    log.Printf("authenticated client: %s", clientLogin)
    return "42", nil
  }

  return "", fmt.Errorf("missing credentials")
}

// unaryInterceptor calls authenticateClient with current context
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
  s, ok := info.Server.(*api.Server)
  if !ok {
    return nil, fmt.Errorf("unable to cast server")
  }
  clientID, err := authenticateClient(ctx, s)
  if err != nil {
    return nil, err
  }

  ctx = context.WithValue(ctx, clientIDKey, clientID)
  return handler(ctx, req)
}

// main start a gRPC server and waits for connection
func main() {
  // create a listener on TCP port 7777
  lis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", "localhost", 7777))
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }

  // create a server instance
  s := api.Server{}

  // Create the TLS credentials
  creds, err := credentials.NewServerTLSFromFile("cert/server.crt", "cert/server.key")
  if err != nil {
    log.Fatalf("could not load TLS keys: %s", err)
  }

  // Create an array of gRPC options with the credentials
  opts := []grpc.ServerOption{grpc.Creds(creds),
    grpc.UnaryInterceptor(unaryInterceptor)}

  // create a gRPC server object
  grpcServer := grpc.NewServer(opts...)

  // attach the Ping service to the server
  api.RegisterPingServer(grpcServer, &s)

  // start the server
  if err := grpcServer.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %s", err)
  }
}
Enter fullscreen mode Exit fullscreen mode

Once again, let me break down this for you:

  • you add a new grpc.ServerOption to the array you created before (see why it’s an array, now?): grpc.UnaryInterceptor. And you pass a reference to a function to that function, so it knows who to call. The rest of the main code does not change;
  • you have to define the unaryInterceptor function, considering it gets a bunch of parameters:
  1. a context.Context object, containing your data, and that will exist during all the lifetime of the request;
  2. an interface{} which is the inbound parameter of the RPC call;
  3. a UnaryServerInfo struct which contains a bunch of information about the call (such as the Server abstraction object, and the method call by the client);
  4. a UnaryHandler struct which is the handler invoked by UnaryServerInterceptor to complete the normal execution of a unary RPC (i.e. an handler to what happens when the UnaryInterceptor returns).
  • the unaryInterceptor function makes sure the grpc.UnaryServerInfo has the right server abstraction, and call the authentication function, authenticateClient;
  • you define the authenticateClient function with your authentication logic — very very simple in this example. Note that it receives the context.Context as parameter, and extract the metadata from it. It checks the user, and returns its ID (in the form of a string, with a hypothetical error.
  • if the unaryInterceptor gets no error from the authenticateClient function, it pushes the clientID in the context.Context object, so that the rest of the execution chain can use it (remember the handler gets the context.Context object as parameter?);
  • Note that you created your type and const to reference the clientID in the context.Context map. This is just an handy way to avoid naming conflict and to allow constant reference.

You can compile the code:

$ make
Enter fullscreen mode Exit fullscreen mode

And run both sides in separate terminals:

$ bin/server
2006/01/02 15:04:05 authenticated client: john
2006/01/02 15:04:05 Receive message foo
Enter fullscreen mode Exit fullscreen mode
$ bin/client
2006/01/02 15:04:05 Response from server: bar
Enter fullscreen mode Exit fullscreen mode

Obviously, your authentication logic will probably be smarter, comparing credentials against a database. The easy part of it is: your authentication function gets your abstraction of a Server, and this structure can hold your database handler.

Open to REST

git tag: add-rest


Photo by Rio Hodges on Unsplash

One last thing: you have a pretty neat server, client and protocol; serialized, encrypted and authenticated. But there is a important limit: your client needs to be gRPC compliant, that is be in the list of supported platforms. To avoid that limit, we can open the server to a REST gateway, allowing REST clients to perform requests it. Luckily, there is a gRPC protoc plugin to generate a reverse-proxy server which translates a RESTful JSON API into gRPC. We can use a few line of pure Go code to serve that reverse-proxy.

So edit your api/api.proto file to add some extra information:

syntax = "proto3";
package api;

import "google/api/annotations.proto";

message PingMessage {
  string greeting = 1;
}

service Ping {
  rpc SayHello(PingMessage) returns (PingMessage) {
    option (google.api.http) = {
      post: "/1/ping"
      body: "*"
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The annotations.proto import allows protoc to understand the option set later in the file. And the option defines that method and the path to the endpoint.

Update the Makefile to add a target for this new Protobuf compilation:

SERVER_OUT := "bin/server"
CLIENT_OUT := "bin/client"
API_OUT := "api/api.pb.go"
API_REST_OUT := "api/api.pb.gw.go"
PKG := "gitlab.com/pantomath-io/demo-grpc"
SERVER_PKG_BUILD := "${PKG}/server"
CLIENT_PKG_BUILD := "${PKG}/client"
PKG_LIST := $(shell go list ${PKG}/... | grep -v /vendor/)

.PHONY: all api server client

all: server client

api/api.pb.go: api/api.proto

 -I api/ \
    -I${GOPATH}/src \
    -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
    --go_out=plugins=grpc:api \
    api/api.proto

api/api.pb.gw.go: api/api.proto

 -I api/ \
    -I${GOPATH}/src \
    -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
    --grpc-gateway_out=logtostderr=true:api \
    api/api.proto

api: api/api.pb.go api/api.pb.gw.go ## Auto-generate grpc go sources

dep: ## Get the dependencies

 get -v -d ./...

server: dep api ## Build the binary file for server

 build -i -v -o $(SERVER_OUT) $(SERVER_PKG_BUILD)

client: dep api ## Build the binary file for client

 build -i -v -o $(CLIENT_OUT) $(CLIENT_PKG_BUILD)

clean: ## Remove previous builds

 $(SERVER_OUT) $(CLIENT_OUT) $(API_OUT) $(API_REST_OUT)

help: ## Display this help screen

 -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Enter fullscreen mode Exit fullscreen mode

Generate the Go code for the gateway (the file api/api.pb.gw.go will be generated — just as api/api.pb.go, don’t edit it, it will be updated by compilation):

$ make api
Enter fullscreen mode Exit fullscreen mode

The change on the server side is more important. The grpc.Serve() function is a blocking function, that returns only on error (or can get killed by a signal). As we need to start another server (the REST interface), we need this call to be non-blocking. Fortunately, we have goroutines just for that. And there is a trick on the authentication. As the REST gateway is just a reverse-proxy, it acts as a client from the gRPC perspective. Thus it needs to use a WithPerRPCCredentials option when dialing the server.

Head to your server/main.go file:

package main

import (
  "fmt"
  "log"
  "net"
  "net/http"
  "strings"

  "github.com/grpc-ecosystem/grpc-gateway/runtime"
  "golang.org/x/net/context"
  "gitlab.com/pantomath-io/demo-grpc/api"
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials"
  "google.golang.org/grpc/metadata"
)

// private type for Context keys
type contextKey int

const (
  clientIDKey contextKey = iota
)

func credMatcher(headerName string) (mdName string, ok bool) {
  if headerName == "Login" || headerName == "Password" {
    return headerName, true
  }
  return "", false
}

// authenticateAgent check the client credentials
func authenticateClient(ctx context.Context, s *api.Server) (string, error) {
  if md, ok := metadata.FromIncomingContext(ctx); ok {
    clientLogin := strings.Join(md["login"], "")
    clientPassword := strings.Join(md["password"], "")

    if clientLogin != "john" {
      return "", fmt.Errorf("unknown user %s", clientLogin)
    }
    if clientPassword != "doe" {
      return "", fmt.Errorf("bad password %s", clientPassword)
    }

    log.Printf("authenticated client: %s", clientLogin)
    return "42", nil
  }
  return "", fmt.Errorf("missing credentials")
}

// unaryInterceptor call authenticateClient with current context
func unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
  s, ok := info.Server.(*api.Server)
  if !ok {
    return nil, fmt.Errorf("unable to cast server")
  }
  clientID, err := authenticateClient(ctx, s)
  if err != nil {
    return nil, err
  }

  ctx = context.WithValue(ctx, clientIDKey, clientID)
  return handler(ctx, req)
}

func startGRPCServer(address, certFile, keyFile string) error {
  // create a listener on TCP port
  lis, err := net.Listen("tcp", address)
  if err != nil {
    return fmt.Errorf("failed to listen: %v", err)
  }

  // create a server instance
  s := api.Server{}

  // Create the TLS credentials
  creds, err := credentials.NewServerTLSFromFile(certFile, keyFile)
  if err != nil {
    return fmt.Errorf("could not load TLS keys: %s", err)
  }

  // Create an array of gRPC options with the credentials
  opts := []grpc.ServerOption{grpc.Creds(creds),
    grpc.UnaryInterceptor(unaryInterceptor)}

  // create a gRPC server object
  grpcServer := grpc.NewServer(opts...)

  // attach the Ping service to the server
  api.RegisterPingServer(grpcServer, &s)

  // start the server
  log.Printf("starting HTTP/2 gRPC server on %s", address)
  if err := grpcServer.Serve(lis); err != nil {
    return fmt.Errorf("failed to serve: %s", err)
  }

  return nil
}

func startRESTServer(address, grpcAddress, certFile string) error {
  ctx := context.Background()
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()

  mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(credMatcher))

  creds, err := credentials.NewClientTLSFromFile(certFile, "")
  if err != nil {
    return fmt.Errorf("could not load TLS certificate: %s", err)
  }

  // Setup the client gRPC options
  opts := []grpc.DialOption{grpc.WithTransportCredentials(creds)}

  // Register ping
  err = api.RegisterPingHandlerFromEndpoint(ctx, mux, grpcAddress, opts)
  if err != nil {
    return fmt.Errorf("could not register service Ping: %s", err)
  }

  log.Printf("starting HTTP/1.1 REST server on %s", address)
  http.ListenAndServe(address, mux)

  return nil
}

// main start a gRPC server and waits for connection
func main() {
  grpcAddress := fmt.Sprintf("%s:%d", "localhost", 7777)
  restAddress := fmt.Sprintf("%s:%d", "localhost", 7778)
  certFile := "cert/server.crt"
  keyFile := "cert/server.key"

  // fire the gRPC server in a goroutine
  go func() {
    err := startGRPCServer(grpcAddress, certFile, keyFile)
    if err != nil {
      log.Fatalf("failed to start gRPC server: %s", err)
    }
  }()

  // fire the REST server in a goroutine
  go func() {
    err := startRESTServer(restAddress, grpcAddress, certFile)
    if err != nil {
      log.Fatalf("failed to start gRPC server: %s", err)
    }
  }()

  // infinite loop
  log.Printf("Entering infinite loop")
  select {}
}
Enter fullscreen mode Exit fullscreen mode

So what happened?

  • you moved all the code for the gRPC server creation in a goroutine with a dedicated function (startGRPCServer), so it does not block the main;
  • you create a new goroutine with a dedicated function (startRESTServer) where you create an HTTP/1.1 server;
  • in startRESTServer where you create the REST gateway, you start by getting the context.Context background object (i.e. the root of the context tree). Then, you create a request multiplexer object, mux, with an option: runtime.WithIncomingHeaderMatcher. This option takes a function reference as parameter, credMatch, and is called for every HTTP header from the incoming request. The function evaluates whether or not the HTTP header should be passed to the gRPC context;
  • you defined the credMatch function to match the credentials header, allowing them to be metadata in the gRPC context. This is how you have your authentication working, because the reverse-proxy uses the HTTP headers it receives when it connects to the gRPC server;
  • you also create a credentials.NewClientTLSFromFile, to be used as a grpc.DialOption, just like you did in the client side;
  • you register your api endpoint, i.e. you make the link between your multiplexer, you gRPC server, using the context and the gRPC options;
  • and finally, you start an HTTP/1.1 server, and wait for incoming connections;
  • aside to your goroutine, you use a blocking select call, so that your program does not end right away.

Now build the whole project, so you can test the REST interface:

$ make
Enter fullscreen mode Exit fullscreen mode

And run both sides in separate terminals:

$ bin/server
2006/01/02 15:04:05 Entering infinite loop
2006/01/02 15:04:05 starting HTTP/1.1 REST server on localhost:7778
2006/01/02 15:04:05 starting HTTP/2 gRPC server on localhost:7777
2006/01/02 15:04:05 authenticated client: john
2006/01/02 15:04:05 Receive message foo

$ curl -H "login:john" -H "password:doe" -X POST -d '{"greeting":"foo"}' '
{"greeting":"bar"}
Enter fullscreen mode Exit fullscreen mode

One last swag…

git tag: add-swagger


Photo by Lorenzo Castagnone on Unsplash

The REST gateway is cool, but it would be even cooler to generate documentation from it, right?

You can do that for free, using a protoc plugin to generate a swagger json file:

protoc -I api/ \
  -I${GOPATH}/src \
  -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --swagger_out=logtostderr=true:api \
  api/api.proto
Enter fullscreen mode Exit fullscreen mode

This will generate a api/api.swagger.json file. As all generated code from Protobuf compilation, you should not edit it, but you can use it, and it can update it when you change your definition file.

You can add the compilation command in the Makefile.

Conclusion

You have a fully functional gRPC client and server, with SSL encryption & authentication, client identification, and a REST gateway (with its swagger file). Where to go from here?

You can push a little on the REST gateway, to make it HTTPS instead of HTTP. You can obviously add more complex data structure on your Protobuf, alongside with more service. You can benefit from HTTP/2 features, such as streaming, either from client to server, or from server to client, or bidirectional (but that’s only for gRPC, not for the REST, based on HTTP/1.1).

Many thanks to Charles Francoise who co-wrote this paper and https://gitlab.com/pantomath-io/demo-grpc.


We are currently working on Pantomath. Pantomath is a modern, open-source monitoring solution, built for performance, that bridges the gaps across all levels of your company. The well-being of your infrastructure is everyone’s business. Keep up with the project

Top comments (0)