Build a gRPC Service Registry in Go
Build a hub-agent registry secured with mutual TLS using gRPC and Protocol Buffers.
Introduction
30 Second Summary
Every time you open a banking app or stream a video, dozens of backend services are talking to each other behind the scenes. Before any of them share data, they need to verify each other's identity.
In this project, you will build a hub-agent service registry secured with mutual TLS in Go. You'll define a service using Protocol Buffers, implement a gRPC server and client, and secure their communication with self-signed certificates.
What You'll Build
A miniature version of how production distributed systems handle cluster registration, with a hub server accepting agent registrations over gRPC and monitoring their health via heartbeats.
By the end of this project, you'll have:
- A working gRPC service with Protocol Buffer definitions that compiles and runs locally.
- Mutual TLS (mTLS) securing communication between a hub server and agent client, with self-signed certificates.
- A complete hub-agent registration system where agents register, send heartbeats, and the hub tracks their status.
- Secret Mission: Add gRPC interceptors for request logging and deadline enforcement.
Are there any prerequisites?
You'll need basic Go syntax knowledge (variables, functions, structs) and WSL (Ubuntu) installed on Windows. No prior experience with gRPC or TLS is required.
Before We Start
Before We Start...
Install Go and Protocol Buffers
To build a gRPC service, you need two foundational tools. Go is the programming language your hub server and agent client are written in. Protocol Buffers is the data format gRPC uses to define services and messages.
In this step, you will install Go 1.26 on your WSL Ubuntu environment, then install the Protocol Buffer compiler and the Go code generation plugins.
In this step, get ready to:
- Install Go 1.26 on WSL.
- Install the Protocol Buffer compiler and Go plugins.
- Verify all tools are working.
Install Go 1.26 on WSL
Go 1.26 is required for this project because the gRPC libraries and code generation plugins depend on it.
- Check if Go is already installed by running this command:
go version
✔️ I see go1.26 or higher
Go is ready to use. Skip ahead to the next substep where you install the Protocol Buffer tools.
ⓧ I see an older version
Your WSL environment has an older Go version installed. You need to remove it and install Go 1.26.4 to ensure compatibility with the gRPC libraries.
- Remove the existing Go installation and download the new version by running these commands:
sudo rm -rf /usr/local/go
wget https://go.dev/dl/go1.26.4.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz
- Add Go to your PATH permanently by running:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
- Verify the installation by running:
go version
You should see go version go1.26.4 linux/amd64 in the output.
- Clean up the downloaded archive by running:
rm go1.26.4.linux-amd64.tar.gz
ⓧ Command not found
Go is not installed yet. You will download and install Go 1.26.4 from the official source.
- Download and extract Go to /usr/local by running these commands:
wget https://go.dev/dl/go1.26.4.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz
- Add Go to your PATH permanently by running:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
- Verify the installation by running:
go version
You should see go version go1.26.4 linux/amd64 in the output.
- Clean up the downloaded archive by running:
rm go1.26.4.linux-amd64.tar.gz
Permission denied or wget not found?
If you see a permission error, make sure you are prefixing commands with sudo where shown.
If wget is not found, install it first with sudo apt install -y wget.
Help me troubleshoot my Go installation in WSL.
Install the Protocol Buffer compiler and Go plugins
The Protocol Buffer compiler (protoc) reads .proto files and generates code from them. The Go plugins tell protoc how to generate Go-specific code.
- Install protoc by running this command:
sudo apt install -y protobuf-compiler
What does this command do?
This uses Ubuntu's package manager (apt) to install the protobuf-compiler package, which provides the protoc binary. The -y flag automatically confirms the install.
- Install the Go code generation plugin for Protocol Buffers by running:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
- Install the Go code generation plugin for gRPC by running:
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Why two separate plugins?
The protoc-gen-go plugin generates Go structs from your Protocol Buffer messages. The protoc-gen-go-grpc plugin generates the gRPC service interfaces and client/server code.
Together, they turn a single .proto file into everything you need to build a working gRPC server and client in Go.
- Make the plugins accessible on your PATH by running:
echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.bashrc
source ~/.bashrc
What does this PATH update do?
The go install command places binaries in your Go workspace's bin directory. Adding that directory to your PATH lets protoc find the plugins when it needs them during code generation.
Seeing 'go: command not found' when running go install?
If you see this error, your Go PATH was not applied correctly. Run source ~/.bashrc to reload your shell configuration, then try again.
Help me fix my Go PATH in WSL.
Verify the full toolchain
Before moving on, confirm that all three tools are installed and accessible.
- Verify protoc is installed by running:
protoc --version
You should see a version number starting with libprotoc 3 or higher.
- Verify the Go Protocol Buffer plugin by running:
protoc-gen-go --version
You should see protoc-gen-go v1.36.11 (or a similar recent version).
- Verify the Go gRPC plugin by running:
protoc-gen-go-grpc --version
You should see protoc-gen-go-grpc 1.6.2 (or a similar recent version).
Seeing 'command not found' for the plugins?
This means your GOPATH bin directory is not on your PATH. Run source ~/.bashrc and try again.
If the issue persists, confirm the plugins exist by running ls $(go env GOPATH)/bin. You should see protoc-gen-go and protoc-gen-go-grpc listed there.
Help me fix my protoc plugin PATH issues.
Your development environment is fully set up. Next, you will define your gRPC service using Protocol Buffers and generate Go code from it.
Define the Service with Protocol Buffers
You have Go and the Protocol Buffer toolchain installed. Now you need a project to work in and a contract that defines how your hub and agent will communicate.
In gRPC, that contract is a .proto file. It specifies which RPCs exist, what data each one sends, and what data comes back. The protoc compiler then generates Go code from this file so your server and client automatically speak the same language.
In this step, get ready to:
- Create the project directory structure and initialize a Go module.
- Write a Protocol Buffer service definition with three RPCs.
- Generate Go code from the proto file using protoc.
Create the project structure
Every Go project starts with a module and a folder layout. Your service registry needs directories for the proto definition, each binary, and a shared certificates package.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir grpc-registry
cd grpc-registry
- Initialize the Go module by running:
go mod init github.com/yourname/grpc-registry
What does this command do?
go mod init creates a go.mod file that declares the module path for your project. Every Go import path in this project will start with github.com/yourname/grpc-registry.
- Create the directories for your proto definition, binaries, and shared packages by running:
mkdir -p proto cmd/hub cmd/agent cmd/certgen pkg/certs
- Confirm the directories exist by running:
ls
You should see cmd, go.mod, pkg, and proto in the output.
Don't see all four items?
Make sure you ran the mkdir -p command from inside the grpc-registry folder. Run pwd to confirm you're in ~/Desktop/grpc-registry.
help me figure out why my project directories are missing
Write the proto service definition
Protocol Buffers use a schema language to define services and messages. Your RegistryService will have three RPCs: one for agents to register, one for heartbeats, and one to list all known agents.
- Create a file called registry.proto inside the proto/ directory. Open it in your editor and paste the following code:
syntax = "proto3";
package registry;
option go_package = "github.com/yourname/grpc-registry/proto/registry";
service RegistryService {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse);
}
message RegisterRequest {
string agent_id = 1;
string hostname = 2;
string version = 3;
}
message RegisterResponse {
bool accepted = 1;
string message = 2;
}
message HeartbeatRequest {
string agent_id = 1;
int64 timestamp_unix = 2;
}
message HeartbeatResponse {
bool acknowledged = 1;
}
message ListAgentsRequest {}
message ListAgentsResponse {
repeated AgentInfo agents = 1;
}
message AgentInfo {
string agent_id = 1;
string hostname = 2;
string version = 3;
int64 last_heartbeat_unix = 4;
string status = 5;
}
What does this code do?
syntax = "proto3" declares you're using the latest Protocol Buffer syntax version.
go_package tells protoc the Go import path for the generated package. This is how your hub and agent code will import the generated types.
The service block defines three RPCs. Register lets agents announce themselves. Heartbeat proves an agent is still alive. ListAgents returns all known agents and their health status.
Each message block defines a data structure with numbered fields. The numbers are wire-format identifiers that let Protocol Buffers serialize data compactly.
- Save proto/registry.proto.
Generate Go code from the proto file
With the .proto file written, you can now run protoc to generate Go structs and a gRPC service interface. The module flag tells the code generator to strip your module prefix from the output path so the generated files land in the correct directory within your project.
- Generate the Go code by running this command:
protoc --go_out=. --go_opt=module=github.com/yourname/grpc-registry \
--go-grpc_out=. --go-grpc_opt=module=github.com/yourname/grpc-registry \
proto/registry.proto
What does this command do?
--go_out=. tells protoc to generate the message types (structs, serialization) in the current directory tree.
--go-grpc_out=. generates the gRPC service interface and client stubs alongside the messages.
The module=github.com/yourname/grpc-registry option strips your module path from the go_package value. Since go_package is github.com/yourname/grpc-registry/proto/registry, the generated files land in proto/registry/ inside your project.
- Confirm the generated files exist by running:
ls proto/registry/
You should see registry.pb.go and registry_grpc.pb.go in the output.
Don't see the generated files?
Check that protoc-gen-go and protoc-gen-go-grpc are on your PATH. Run which protoc-gen-go to verify. If not found, make sure $(go env GOPATH)/bin is in your PATH.
Also check that the go_package option in your .proto file exactly matches github.com/yourname/grpc-registry/proto/registry. A typo here causes protoc to fail silently or place files in the wrong directory.
help me fix my protoc code generation
- Now pull the gRPC and protobuf dependencies into your module by running:
go mod tidy
What does this command do?
go mod tidy scans the generated Go files, finds their import statements, and downloads the required modules. It adds google.golang.org/grpc and google.golang.org/protobuf to your go.mod file automatically.
- Verify your go.mod has the dependencies by running:
cat go.mod
You should see google.golang.org/grpc and google.golang.org/protobuf listed in the require block.
Seeing 'no Go files' or missing dependencies?
go mod tidy only finds dependencies in .go files. If the generated .pb.go files don't exist yet (or landed in the wrong directory), go mod tidy won't find anything to download. Go back and confirm proto/registry/registry.pb.go exists.
help me fix go mod tidy not finding dependencies
✔️ Awesome, I've got everything!
Great. Double check you've saved your proto/registry.proto file and that go.mod shows both dependencies.
ⓧ I'd like to double check the full code
Here is the complete proto/registry.proto file:
syntax = "proto3";
package registry;
option go_package = "github.com/yourname/grpc-registry/proto/registry";
service RegistryService {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse);
}
message RegisterRequest {
string agent_id = 1;
string hostname = 2;
string version = 3;
}
message RegisterResponse {
bool accepted = 1;
string message = 2;
}
message HeartbeatRequest {
string agent_id = 1;
int64 timestamp_unix = 2;
}
message HeartbeatResponse {
bool acknowledged = 1;
}
message ListAgentsRequest {}
message ListAgentsResponse {
repeated AgentInfo agents = 1;
}
message AgentInfo {
string agent_id = 1;
string hostname = 2;
string version = 3;
int64 last_heartbeat_unix = 4;
string status = 5;
}
Here is the complete go.mod file:
module github.com/yourname/grpc-registry
go 1.26
require (
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
Your service contract is locked in and the Go code is generated. Next up, you'll implement the hub server that brings this contract to life.
Build the Hub Server
Your proto file defines the contract. Now you need a Go program that fulfills that contract by implementing the three RPCs: Register, Heartbeat, and ListAgents.
The hub server is the central point that agents connect to. It needs to track which agents are registered, when they last checked in, and whether they are healthy. In this step, you will implement the full server with thread-safe agent tracking and mTLS configuration.
In this step, get ready to:
- Implement the RegistryService server interface with in-memory agent tracking.
- Wire up the gRPC server with mTLS credentials on port :50051.
- Verify the server compiles successfully.
Implement the service interface with in-memory agent tracking
The generated registry_grpc.pb.go file contains an interface your server must satisfy. Go's gRPC framework provides an Unimplemented base struct you can embed, then override only the methods you need.
- Create the file cmd/hub/main.go by running:
touch cmd/hub/main.go
- Open cmd/hub/main.go in your editor and add the package declaration, imports, types, and constructor:
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net"
"os"
"sync"
"time"
pb "github.com/yourname/grpc-registry/proto/registry"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// agentRecord stores metadata about a connected agent
type agentRecord struct {
ID string
Hostname string
Version string
LastHeartbeat time.Time
}
// hubServer implements the RegistryService gRPC interface
type hubServer struct {
pb.UnimplementedRegistryServiceServer
mu sync.Mutex
agents map[string]*agentRecord
}
// newHubServer creates a hub with an empty agent registry
func newHubServer() *hubServer {
return &hubServer{
agents: make(map[string]*agentRecord),
}
}
What does this code do?
The agentRecord struct holds everything the hub knows about an agent: its ID, hostname, version, and the timestamp of its last heartbeat.
The hubServer struct embeds UnimplementedRegistryServiceServer so the compiler is satisfied even if you haven't implemented every RPC yet. The sync.Mutex protects the agents map from concurrent access, since multiple goroutines may call RPCs at the same time.
Now add the Register method. This is the first RPC an agent calls when it connects to the hub.
- Below the newHubServer function, add the Register method:
func (s *hubServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Store the agent in the registry map
s.agents[req.AgentId] = &agentRecord{
ID: req.AgentId,
Hostname: req.Hostname,
Version: req.Version,
LastHeartbeat: time.Now(),
}
log.Printf("[HUB] Agent registered: id=%s hostname=%s version=%s", req.AgentId, req.Hostname, req.Version)
return &pb.RegisterResponse{
Accepted: true,
Message: fmt.Sprintf("Welcome, agent %s", req.AgentId),
}, nil
}
What does this code do?
The Register method locks the mutex, inserts the agent into the map keyed by AgentId, logs the event, and returns an acceptance response. The defer s.mu.Unlock() ensures the lock is released even if something panics.
Next, add the Heartbeat method. Agents call this periodically to prove they are still alive.
- Below the Register method, add the Heartbeat method:
func (s *hubServer) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Look up the agent and update its last heartbeat time
agent, exists := s.agents[req.AgentId]
if !exists {
return &pb.HeartbeatResponse{Acknowledged: false}, nil
}
agent.LastHeartbeat = time.Now()
log.Printf("[HUB] Heartbeat from agent: id=%s", req.AgentId)
return &pb.HeartbeatResponse{Acknowledged: true}, nil
}
What does this code do?
The hub looks up the agent by ID. If the agent never registered, it returns Acknowledged: false. Otherwise, it updates the LastHeartbeat timestamp. This timestamp is how the hub determines whether an agent is healthy or stale.
Finally, add the ListAgents method. This returns the current state of all registered agents with a health status.
- Below the Heartbeat method, add the ListAgents method:
func (s *hubServer) ListAgents(ctx context.Context, req *pb.ListAgentsRequest) (*pb.ListAgentsResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Build a list of all agents with their health status
var agents []*pb.AgentInfo
for _, a := range s.agents {
status := "healthy"
if time.Since(a.LastHeartbeat) > 15*time.Second {
status = "unhealthy"
}
agents = append(agents, &pb.AgentInfo{
AgentId: a.ID,
Hostname: a.Hostname,
Version: a.Version,
LastHeartbeatUnix: a.LastHeartbeat.Unix(),
Status: status,
})
}
return &pb.ListAgentsResponse{Agents: agents}, nil
}
What does this code do?
The method iterates over every registered agent and checks how long it has been since their last heartbeat. If more than 15 seconds have passed, the agent is marked unhealthy. This is the same heartbeat-timeout pattern used by Kubernetes to detect unresponsive nodes.
- Save cmd/hub/main.go.
Wire up the gRPC server to listen on a port
The RPC methods are ready, but they need a running server to receive connections. The main function will configure mTLS credentials, create the gRPC server, and bind it to a TCP port.
Why configure mTLS now?
The server code references certificate files that do not exist yet. That is intentional. Writing the TLS configuration now means the server is built correctly from the start. You will generate the actual certificate files in a later step, at which point the server will be fully functional.
This approach avoids rewriting the main function later. The code compiles even without the cert files on disk because Go validates file paths at runtime, not at compile time.
- Below the ListAgents method, add the main function:
func main() {
// Paths to TLS certificate files (generated in a later step)
certFile := "certs/server-cert.pem"
keyFile := "certs/server-key.pem"
caFile := "certs/ca-cert.pem"
// Load the server's certificate and private key
serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
log.Fatalf("Failed to load server cert: %v", err)
}
// Load the CA certificate to verify client certificates
caCert, err := os.ReadFile(caFile)
if err != nil {
log.Fatalf("Failed to read CA cert: %v", err)
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
// Configure mutual TLS: server presents its cert, requires client cert
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caPool,
}
// Create gRPC server with TLS credentials
creds := credentials.NewTLS(tlsConfig)
grpcServer := grpc.NewServer(grpc.Creds(creds))
// Register our service implementation
pb.RegisterRegistryServiceServer(grpcServer, newHubServer())
// Listen on TCP port 50051
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("[HUB] Server listening on :50051 with mTLS")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
What does this code do?
The main function sets up mutual TLS in three stages. First, it loads the server's own certificate and key. Second, it loads the CA certificate used to verify incoming client certificates. Third, it combines these into a tls.Config that requires clients to present a valid certificate signed by the same CA.
The grpc.Creds(creds) option wraps this TLS config into the gRPC server. Finally, grpcServer.Serve(lis) blocks and handles incoming connections on port :50051.
- Save cmd/hub/main.go.
Verify the server compiles
The server references certificate files that do not exist on disk yet. That is fine for compilation. Go only checks types and imports at build time, not file system paths.
- Download any missing dependencies and compile the hub server by running:
go mod tidy
go build ./cmd/hub/
- If the build succeeds, you will see no output at all. That silence means your code compiles cleanly.
- Confirm the compiled binary exists by running:
ls hub
You should see hub listed as an executable file in your project directory.
Seeing a compilation error?
- If you see errors about missing packages, make sure you ran go mod tidy first. This downloads the gRPC and protobuf modules.
- If you see undefined: pb.RegisterRequest or similar, check that the generated proto files exist at proto/registry/registry.pb.go and proto/registry/registry_grpc.pb.go.
- If you see syntax error messages, double-check that every opening brace has a matching closing brace and that you have no stray characters between methods.
Help me debug my Go build error for the hub server.
Can I run the server yet?
Not yet. If you try ./hub, it will exit immediately with a message about missing certificate files. You will generate those certificates in a later step, and then the server will start and accept connections.
✔️ Awesome, I've got everything!
Great. Double check you have saved cmd/hub/main.go and that go build ./cmd/hub/ produces no errors.
ⓧ I'd like to double check the full code
Here is the complete cmd/hub/main.go file. Compare it against yours:
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net"
"os"
"sync"
"time"
pb "github.com/yourname/grpc-registry/proto/registry"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
type agentRecord struct {
ID string
Hostname string
Version string
LastHeartbeat time.Time
}
type hubServer struct {
pb.UnimplementedRegistryServiceServer
mu sync.Mutex
agents map[string]*agentRecord
}
func newHubServer() *hubServer {
return &hubServer{
agents: make(map[string]*agentRecord),
}
}
func (s *hubServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.agents[req.AgentId] = &agentRecord{
ID: req.AgentId,
Hostname: req.Hostname,
Version: req.Version,
LastHeartbeat: time.Now(),
}
log.Printf("[HUB] Agent registered: id=%s hostname=%s version=%s", req.AgentId, req.Hostname, req.Version)
return &pb.RegisterResponse{
Accepted: true,
Message: fmt.Sprintf("Welcome, agent %s", req.AgentId),
}, nil
}
func (s *hubServer) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
agent, exists := s.agents[req.AgentId]
if !exists {
return &pb.HeartbeatResponse{Acknowledged: false}, nil
}
agent.LastHeartbeat = time.Now()
log.Printf("[HUB] Heartbeat from agent: id=%s", req.AgentId)
return &pb.HeartbeatResponse{Acknowledged: true}, nil
}
func (s *hubServer) ListAgents(ctx context.Context, req *pb.ListAgentsRequest) (*pb.ListAgentsResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
var agents []*pb.AgentInfo
for _, a := range s.agents {
status := "healthy"
if time.Since(a.LastHeartbeat) > 15*time.Second {
status = "unhealthy"
}
agents = append(agents, &pb.AgentInfo{
AgentId: a.ID,
Hostname: a.Hostname,
Version: a.Version,
LastHeartbeatUnix: a.LastHeartbeat.Unix(),
Status: status,
})
}
return &pb.ListAgentsResponse{Agents: agents}, nil
}
func main() {
certFile := "certs/server-cert.pem"
keyFile := "certs/server-key.pem"
caFile := "certs/ca-cert.pem"
serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
log.Fatalf("Failed to load server cert: %v", err)
}
caCert, err := os.ReadFile(caFile)
if err != nil {
log.Fatalf("Failed to read CA cert: %v", err)
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caPool,
}
creds := credentials.NewTLS(tlsConfig)
grpcServer := grpc.NewServer(grpc.Creds(creds))
pb.RegisterRegistryServiceServer(grpcServer, newHubServer())
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("[HUB] Server listening on :50051 with mTLS")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
Your hub server is built and ready to serve. Next up, you will build the agent client that connects to this hub, registers itself, and sends heartbeats to prove it is alive.
Build the Agent Client
Your hub server is listening on port :50051, but it has no one to talk to yet. In a distributed system, agents are lightweight programs that run on remote machines and phone home to a central hub to announce themselves.
Now you'll write the agent client. It connects to the hub over gRPC, calls Register once to announce itself, then enters a heartbeat loop to continuously prove it's alive.
In this step, get ready to:
- Connect to the hub server using gRPC with mTLS credentials.
- Call the Register RPC with an agent ID and metadata.
- Start a heartbeat loop that pings the hub every 5 seconds.
Connect to the hub and register the agent
The agent mirrors the hub's TLS setup but from the client's perspective. Instead of requiring client certs (that's the server's job), the agent presents its own cert and uses the CA to verify the server's identity.
- Create the agent file by running this command:
touch cmd/agent/main.go
- Confirm the file exists by running:
ls cmd/agent/
You should see main.go in the output.
- Open cmd/agent/main.go in your editor and paste the following code:
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"log"
"os"
"time"
pb "github.com/yourname/grpc-registry/proto/registry"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
// Default agent ID, overridable via command line argument
agentID := "agent-001"
if len(os.Args) > 1 {
agentID = os.Args[1]
}
// Paths to client certificate, key, and CA
certFile := "certs/client-cert.pem"
keyFile := "certs/client-key.pem"
caFile := "certs/ca-cert.pem"
// Load the client's TLS certificate and private key
clientCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
log.Fatalf("Failed to load client cert: %v", err)
}
// Load the CA certificate to verify the server
caCert, err := os.ReadFile(caFile)
if err != nil {
log.Fatalf("Failed to read CA cert: %v", err)
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
// Configure mTLS: present our cert and verify server with CA
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caPool,
ServerName: "localhost",
}
// Establish gRPC connection to the hub
creds := credentials.NewTLS(tlsConfig)
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewRegistryServiceClient(conn)
// Register with a 3-second deadline
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.Register(ctx, &pb.RegisterRequest{
AgentId: agentID,
Hostname: "worker-node-1",
Version: "v1.0.0",
})
if err != nil {
log.Fatalf("Registration failed: %v", err)
}
log.Printf("[AGENT %s] Registration response: accepted=%v message=%q", agentID, resp.Accepted, resp.Message)
}
What does this code do?
This file sets up the agent as the client side of an mTLS connection. Here's how the pieces fit together:
- agentID defaults to agent-001 but can be overridden from the command line. This lets you run multiple agents later.
- The TLS config uses RootCAs (to verify the server) instead of the hub's ClientCAs (which verifies clients). Same CA, different direction.
- grpc.NewClient creates the connection with transport credentials. It replaces the older deprecated grpc.Dial function.
- context.WithTimeout gives the Register call a 3-second deadline. If the hub doesn't respond in time, the call fails instead of hanging forever.
- Save cmd/agent/main.go.
Why context.WithTimeout on every call?
In production distributed systems, network calls can hang indefinitely if the remote service crashes or the connection drops. Wrapping every RPC in context.WithTimeout guarantees the agent won't get stuck waiting for a response that will never come.
This is the same pattern Open Cluster Management uses in its registration controller. Every network call has a bounded lifetime.
Add the heartbeat loop
Registration tells the hub the agent exists. Heartbeats tell the hub the agent is still alive. Without periodic heartbeats, the hub has no way to detect that a remote agent has crashed or lost network connectivity.
- In cmd/agent/main.go, find this line near the end of the main function:
log.Printf("[AGENT %s] Registration response: accepted=%v message=%q", agentID, resp.Accepted, resp.Message)
}
- Replace the closing } brace with the following heartbeat loop code, so the function continues instead of ending:
log.Printf("[AGENT %s] Registration response: accepted=%v message=%q", agentID, resp.Accepted, resp.Message)
// Heartbeat loop
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
log.Printf("[AGENT %s] Starting heartbeat loop (every 5s)...", agentID)
for range ticker.C {
hbCtx, hbCancel := context.WithTimeout(context.Background(), 3*time.Second)
hbResp, err := client.Heartbeat(hbCtx, &pb.HeartbeatRequest{
AgentId: agentID,
TimestampUnix: time.Now().Unix(),
})
hbCancel()
if err != nil {
log.Printf("[AGENT %s] Heartbeat failed: %v", agentID, err)
continue
}
log.Printf("[AGENT %s] Heartbeat acknowledged: %v", agentID, hbResp.Acknowledged)
}
}
What does the heartbeat loop do?
- time.NewTicker creates a channel that fires every 5 seconds. The for range ticker.C loop blocks until the next tick arrives.
- Each heartbeat gets its own 3-second timeout via context.WithTimeout. If one heartbeat fails, the agent logs the error and continues to the next tick instead of crashing.
- hbCancel() is called immediately after the RPC returns (not deferred) because the loop creates a new context every iteration. Deferring would leak contexts until the program exits.
- Save cmd/agent/main.go.
Seeing a syntax error after adding the loop?
Make sure you removed the old closing } brace of main() before pasting. The new code includes its own closing brace at the end.
Also check that the log.Printf line for registration appears only once. If it's duplicated, delete the extra copy.
Help me fix my syntax error in the heartbeat loop
Verify the agent compiles
- Confirm the agent builds successfully by running:
go build ./cmd/agent/
If the command completes with no output, the build succeeded.
Seeing import errors?
Run go mod tidy to resolve any missing dependencies. This ensures your go.sum file is up to date with the imports in cmd/agent/main.go.
If you see an error about proto/registry not found, make sure you generated the proto files in the earlier step. The proto/registry/ directory should contain registry.pb.go and registry_grpc.pb.go.
Help me debug my import errors
✔️ Awesome, I've got everything!
Great. Double check you've saved cmd/agent/main.go and the build completed without errors.
ⓧ I'd like to double check the full code
Here's the complete cmd/agent/main.go file. Make sure yours matches exactly:
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"log"
"os"
"time"
pb "github.com/yourname/grpc-registry/proto/registry"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
agentID := "agent-001"
if len(os.Args) > 1 {
agentID = os.Args[1]
}
certFile := "certs/client-cert.pem"
keyFile := "certs/client-key.pem"
caFile := "certs/ca-cert.pem"
clientCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
log.Fatalf("Failed to load client cert: %v", err)
}
caCert, err := os.ReadFile(caFile)
if err != nil {
log.Fatalf("Failed to read CA cert: %v", err)
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caPool,
ServerName: "localhost",
}
creds := credentials.NewTLS(tlsConfig)
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewRegistryServiceClient(conn)
// Register
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
resp, err := client.Register(ctx, &pb.RegisterRequest{
AgentId: agentID,
Hostname: "worker-node-1",
Version: "v1.0.0",
})
if err != nil {
log.Fatalf("Registration failed: %v", err)
}
log.Printf("[AGENT %s] Registration response: accepted=%v message=%q", agentID, resp.Accepted, resp.Message)
// Heartbeat loop
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
log.Printf("[AGENT %s] Starting heartbeat loop (every 5s)...", agentID)
for range ticker.C {
hbCtx, hbCancel := context.WithTimeout(context.Background(), 3*time.Second)
hbResp, err := client.Heartbeat(hbCtx, &pb.HeartbeatRequest{
AgentId: agentID,
TimestampUnix: time.Now().Unix(),
})
hbCancel()
if err != nil {
log.Printf("[AGENT %s] Heartbeat failed: %v", agentID, err)
continue
}
log.Printf("[AGENT %s] Heartbeat acknowledged: %v", agentID, hbResp.Acknowledged)
}
}
Your agent is ready to connect to the hub. Next up, you'll generate the mTLS certificates that let both sides verify each other's identity before any data flows.
Secure with mTLS
Your hub server and agent client already reference certificate files in a certs/ directory. Those files don't exist yet, so neither binary can actually start.
In this step, you'll write a Go program that generates a complete certificate chain. A Certificate Authority (CA) signs both the server and client certificates, creating a chain of trust that lets both sides verify each other's identity.
In this step, get ready to:
- Write a certificate generation script using Go's crypto packages.
- Walk through the mTLS configuration already in the hub and agent.
- Generate certificates and verify both binaries compile.
Write the certificate generation script
The certificate generator creates three things: a self-signed CA, a server certificate (signed by the CA), and a client certificate (also signed by the CA). Both the hub and agent trust the CA, which is how they verify each other.
- Create the cmd/certgen/ directory by running this command:
mkdir -p cmd/certgen
- Create cmd/certgen/main.go and start with the package declaration, imports, and CA generation code:
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log"
"math/big"
"net"
"os"
"time"
)
func main() {
os.MkdirAll("certs", 0755)
// Generate CA
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate CA key: %v", err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"gRPC Registry CA"},
CommonName: "Registry CA",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create CA cert: %v", err)
}
writeCert("certs/ca-cert.pem", caCertDER)
writeKey("certs/ca-key.pem", caKey)
caCert, err := x509.ParseCertificate(caCertDER)
if err != nil {
log.Fatalf("Failed to parse CA cert: %v", err)
}
What does the CA generation do?
This section creates the root of your trust chain.
- ecdsa.GenerateKey(elliptic.P256(), rand.Reader) creates a new ECDSA private key using the P-256 curve. This is fast and secure.
- The caTemplate sets IsCA: true and KeyUsageCertSign, which marks this certificate as a CA that can sign other certificates.
- x509.CreateCertificate with the same template as both parent and child creates a self-signed certificate. Production systems use an external CA, but self-signed works perfectly for local development.
- ParseCertificate re-reads the DER bytes back into a usable *x509.Certificate so we can use it to sign the server and client certs next.
- Add the server certificate generation below the CA section in the same main() function:
// Generate Server cert
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate server key: %v", err)
}
serverTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
Organization: []string{"gRPC Registry"},
CommonName: "localhost",
},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
serverCertDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create server cert: %v", err)
}
writeCert("certs/server-cert.pem", serverCertDER)
writeKey("certs/server-key.pem", serverKey)
What does the server certificate do?
The server certificate proves the hub's identity to connecting agents.
- DNSNames: []string{"localhost"} and IPAddresses with 127.0.0.1 tell clients which addresses this cert is valid for. The agent's ServerName: "localhost" must match.
- ExtKeyUsageServerAuth restricts this certificate to server-side use only. It cannot be used as a client certificate.
- Notice the parent parameter is caCert (not the template itself). This means the CA signs this certificate, creating the trust chain.
- Add the client certificate generation and the closing brace for main():
// Generate Client cert
clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate client key: %v", err)
}
clientTemplate := &x509.Certificate{
SerialNumber: big.NewInt(3),
Subject: pkix.Name{
Organization: []string{"gRPC Registry"},
CommonName: "agent",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create client cert: %v", err)
}
writeCert("certs/client-cert.pem", clientCertDER)
writeKey("certs/client-key.pem", clientKey)
log.Println("All certificates generated in certs/ directory")
}
What does the client certificate do?
The client certificate proves the agent's identity to the hub. This is the "mutual" in mTLS.
- ExtKeyUsageClientAuth marks this cert for client-side use. The hub's tls.RequireAndVerifyClientCert setting will check that connecting clients present a valid cert with this usage.
- The CommonName: "agent" identifies who this cert belongs to. In production, each agent would get its own unique certificate.
- Add the two helper functions below the closing brace of main():
func writeCert(path string, derBytes []byte) {
f, err := os.Create(path)
if err != nil {
log.Fatalf("Failed to create %s: %v", path, err)
}
defer f.Close()
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
log.Printf("Written: %s", path)
}
func writeKey(path string, key *ecdsa.PrivateKey) {
f, err := os.Create(path)
if err != nil {
log.Fatalf("Failed to create %s: %v", path, err)
}
defer f.Close()
keyBytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
log.Fatalf("Failed to marshal key: %v", err)
}
pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
log.Printf("Written: %s", path)
}
What do these helpers do?
Both functions convert raw cryptographic data into PEM format, which is the standard text encoding for certificates and keys.
- writeCert wraps DER-encoded certificate bytes in a -----BEGIN CERTIFICATE----- PEM block.
- writeKey marshals the ECDSA private key and wraps it in a -----BEGIN EC PRIVATE KEY----- PEM block.
- Save cmd/certgen/main.go.
- Confirm the file exists by running:
ls cmd/certgen/main.go
You should see cmd/certgen/main.go in the output.
File not found?
- Make sure you ran mkdir -p cmd/certgen before creating the file.
- Confirm you're inside the grpc-registry project directory.
Help me debug why my certgen file isn't showing up.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"log"
"math/big"
"net"
"os"
"time"
)
func main() {
os.MkdirAll("certs", 0755)
// Generate CA
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate CA key: %v", err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"gRPC Registry CA"},
CommonName: "Registry CA",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create CA cert: %v", err)
}
writeCert("certs/ca-cert.pem", caCertDER)
writeKey("certs/ca-key.pem", caKey)
caCert, err := x509.ParseCertificate(caCertDER)
if err != nil {
log.Fatalf("Failed to parse CA cert: %v", err)
}
// Generate Server cert
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate server key: %v", err)
}
serverTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
Organization: []string{"gRPC Registry"},
CommonName: "localhost",
},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
serverCertDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create server cert: %v", err)
}
writeCert("certs/server-cert.pem", serverCertDER)
writeKey("certs/server-key.pem", serverKey)
// Generate Client cert
clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatalf("Failed to generate client key: %v", err)
}
clientTemplate := &x509.Certificate{
SerialNumber: big.NewInt(3),
Subject: pkix.Name{
Organization: []string{"gRPC Registry"},
CommonName: "agent",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey)
if err != nil {
log.Fatalf("Failed to create client cert: %v", err)
}
writeCert("certs/client-cert.pem", clientCertDER)
writeKey("certs/client-key.pem", clientKey)
log.Println("All certificates generated in certs/ directory")
}
func writeCert(path string, derBytes []byte) {
f, err := os.Create(path)
if err != nil {
log.Fatalf("Failed to create %s: %v", path, err)
}
defer f.Close()
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
log.Printf("Written: %s", path)
}
func writeKey(path string, key *ecdsa.PrivateKey) {
f, err := os.Create(path)
if err != nil {
log.Fatalf("Failed to create %s: %v", path, err)
}
defer f.Close()
keyBytes, err := x509.MarshalECPrivateKey(key)
if err != nil {
log.Fatalf("Failed to marshal key: %v", err)
}
pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
log.Printf("Written: %s", path)
}
Review the hub's mTLS configuration
Your cmd/hub/main.go already has the mTLS configuration built in from an earlier step. Now that you understand the certificate chain, the TLS setup in the hub makes more sense.
How does the hub enforce mTLS?
Look at the main() function in cmd/hub/main.go. The key TLS configuration is:
- tls.LoadX509KeyPair(certFile, keyFile) loads the server certificate and private key so the hub can prove its identity.
- os.ReadFile(caFile) loads the CA certificate into a certificate pool. This pool is the list of CAs the hub trusts.
- ClientAuth: tls.RequireAndVerifyClientCert is the line that makes this mutual TLS. It forces every connecting client to present a certificate signed by a trusted CA.
- ClientCAs: caPool tells the hub which CA to trust for verifying client certificates. Only agents with certs signed by your CA will be accepted.
Why mTLS instead of regular TLS?
Regular TLS only verifies the server to the client (like HTTPS in your browser). mTLS adds the reverse check: the server also verifies the client.
This is the exact pattern Open Cluster Management (OCM) and Kubernetes use for cluster registration. Every agent must cryptographically prove its identity before the hub accepts any RPCs from it.
Review the agent's mTLS configuration
The agent in cmd/agent/main.go mirrors the hub's TLS setup from the client side.
How does the agent connect with mTLS?
The agent's TLS configuration has three key parts:
- tls.LoadX509KeyPair(certFile, keyFile) loads the client certificate and key. This is what the agent presents to prove its identity.
- RootCAs: caPool tells the agent which CA to trust for verifying the server. Only a hub with a cert signed by this CA will be accepted.
- ServerName: "localhost" must match the DNSNames in the server certificate. This prevents man-in-the-middle attacks by ensuring you're talking to the right server.
Generate certificates and verify compilation
With the certificate generator written and the hub/agent TLS code already in place, it's time to generate the actual certificate files and confirm everything compiles together.
- Generate the certificates by running this command:
go run ./cmd/certgen/
You should see output like this:
Written: certs/ca-cert.pem
Written: certs/ca-key.pem
Written: certs/server-cert.pem
Written: certs/server-key.pem
Written: certs/client-cert.pem
Written: certs/client-key.pem
All certificates generated in certs/ directory
- Confirm all six certificate files were created by running:
ls certs/
You should see ca-cert.pem, ca-key.pem, server-cert.pem, server-key.pem, client-cert.pem, and client-key.pem listed.
Seeing a compilation error?
- Check that all import paths match exactly. Missing imports like math/big or net will cause errors.
- Make sure you closed all braces properly. The main() function has one closing brace, and the two helper functions each have their own.
Help me debug my certgen compilation error.
- Verify the hub compiles with the certificates in place by running:
go build ./cmd/hub/
- Verify the agent compiles as well:
go build ./cmd/agent/
Both commands should complete without errors. If you see no output, that means the build succeeded.
Build errors on hub or agent?
- If you see an error about missing modules, run go mod tidy to resolve dependencies.
- Check that your go.mod file still has google.golang.org/grpc and google.golang.org/protobuf in the require block.
Help me fix my hub or agent build error.
Your mTLS infrastructure is fully in place. Both binaries compile, and the certificates create a complete chain of trust between the hub and agent. Next up, you'll start the system and see mTLS in action.
Test the Complete System
You have all the pieces of your distributed system built and ready. The hub server, agent client, and mTLS certificates are all in place.
Now it is time to bring everything together and prove the system works end-to-end. You will start the hub, connect an agent, watch the registration and heartbeat flow in real time, and confirm that unauthenticated connections are rejected.
In this step, get ready to:
- Start the hub server with mTLS enabled and verify it is listening.
- Confirm the server rejects connections that do not present valid client certificates.
- Start the agent client and observe the registration and heartbeat flow.
Start the hub server
The hub server loads its server certificate and the CA certificate, then listens on port :50051 with mTLS enforced. Any client that connects must present a certificate signed by your CA.
- In your WSL terminal, make sure you are inside the grpc-registry project folder.
- Start the hub server by running:
go run ./cmd/hub/
You should see this log line confirming the server is running:
[HUB] Server listening on :50051 with mTLS
What does this confirm?
If the server starts without errors, it means your server certificate and CA certificate loaded correctly. The server is now actively requiring mTLS for every incoming connection.
Server fails to start?
If you see a message about failing to load the server cert, double check that your certs/ directory contains server-cert.pem and server-key.pem. Re-run go run ./cmd/certgen/ if any files are missing.
If you see a message about the port being in use, another process is already using :50051. Stop it with lsof -i :50051 to find the process, then kill it.
Help me debug my hub server startup error.
Verify mTLS rejects unauthenticated connections
The whole point of mTLS is that both sides must prove their identity. Let's confirm that a connection without a valid client certificate gets rejected.
- Open a second WSL terminal tab or window. Keep the hub server running in the first terminal.
- Attempt a connection without any client certificate by running:
curl -k https://localhost:50051
You should see a TLS handshake failure. The connection is rejected because curl did not present a client certificate.
Why does this prove mTLS is working?
The -k flag tells curl to skip verifying the server's certificate (since it is self-signed). Even so, the connection still fails because the server demands a client certificate and curl cannot provide one.
This is mutual authentication in action. Without a valid client cert signed by your CA, the server refuses to speak to the client at all. No data is exchanged beyond the TLS handshake.
curl command succeeds or hangs?
If curl seems to hang instead of failing, wait a few seconds. The TLS handshake timeout may take a moment before the connection is dropped.
If curl is not installed, install it with sudo apt install -y curl and try again.
Help me verify my mTLS server rejects unauthenticated connections.
Start the agent and observe the registration flow
Now let's connect a properly authenticated agent. The agent loads its client certificate (signed by the same CA), registers with the hub, and enters its heartbeat loop.
- In the same second terminal (the one you used for curl), start the agent by running:
go run ./cmd/agent/
In the agent terminal, you should see output like this:
[AGENT agent-001] Registration response: accepted=true message="Welcome, agent agent-001"
[AGENT agent-001] Starting heartbeat loop (every 5s)...
[AGENT agent-001] Heartbeat acknowledged: true
- Switch back to the first terminal where the hub server is running.
In the hub terminal, you should see log lines like this appearing every 5 seconds:
[HUB] Agent registered: id=agent-001 hostname=worker-node-1 version=v1.0.0
[HUB] Heartbeat from agent: id=agent-001
What is happening here?
The agent presented its client certificate during the TLS handshake. The hub validated it against the CA, accepted the connection, and processed the Register RPC. Now the agent sends a Heartbeat RPC every 5 seconds to prove it is still alive.
Each heartbeat uses context.WithTimeout with a 3-second deadline. If the hub does not respond within 3 seconds, the agent logs a failure and tries again on the next tick.
- Let the system run for 15-20 seconds to see several heartbeats come through.
- Stop the agent by pressing Ctrl+C in the agent terminal.
- Stop the hub by pressing Ctrl+C in the hub terminal.
Run multiple agents
You can start additional agents with different IDs by passing a name as an argument. Open another terminal and run go run ./cmd/agent/ agent-002. The hub tracks each agent independently in its in-memory map.
Agent fails to connect?
If you see a message about failing to load the client cert, verify that certs/client-cert.pem and certs/client-key.pem exist. Re-run go run ./cmd/certgen/ if needed.
If you see a connection refused error, make sure the hub server is still running in the other terminal. The agent cannot connect if the hub is not listening on :50051.
Help me debug my agent connection failure.
Your mTLS-secured gRPC service registry is fully operational. The hub accepts only authenticated agents, tracks their status, and monitors their health in real time. Next up, you can push this further with production-grade observability in the Secret Mission.
Secret mission
Add gRPC Interceptors
Your hub and agent communicate securely, but you have zero visibility into what's happening on the wire. Production systems need observability. Add gRPC interceptors that log every RPC call and enforce deadlines on every request.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The grpc-registry project folder on your Desktop (source code, generated protobuf code, certificates).
- Go 1.26 installed at /usr/local/go.
- The Protocol Buffer compiler (protoc) installed via apt.
- Go gRPC plugins (protoc-gen-go and protoc-gen-go-grpc) in your GOPATH bin.
Keep everything running
No action needed. Choose this if you plan to continue building on this project or experimenting with gRPC services.
- Your project folder stays on your Desktop at ~/Desktop/grpc-registry.
- Go, protoc, and the gRPC plugins remain installed and available for future Go projects.
- Your self-signed certificates in certs/ expire after 365 days. Regenerate them by running go run cmd/certgen/main.go again when needed.
Pause - I'll come back to this later
Stop any running hub or agent processes to free up system resources. Your files and tools stay intact for when you return.
- If the hub server or agent client is still running, press Ctrl+C in each terminal to stop them.
- Your project folder, Go installation, and tools remain ready for next time.
Delete - I don't want to use this again
Remove all project files and optionally uninstall the tools you set up.
- Stop any running hub or agent processes by pressing Ctrl+C in each terminal.
- Delete the project folder by running:
rm -rf ~/Desktop/grpc-registry
- Remove the Go gRPC plugins by running:
rm -f $(go env GOPATH)/bin/protoc-gen-go
rm -f $(go env GOPATH)/bin/protoc-gen-go-grpc
- Remove the Protocol Buffer compiler by running:
sudo apt remove protobuf-compiler
- Remove Go itself by running:
sudo rm -rf /usr/local/go
- Remove the Go PATH entry from your shell profile. Open ~/.profile or ~/.bashrc and delete the line that adds /usr/local/go/bin to your PATH.
Nice Work!
Nice Work!
You just built the networking foundation that enterprise distributed systems run on. Your hub-agent service registry uses the same patterns found in production platforms like Open Cluster Management and Kubernetes.
You've learned how to:
- Define a Protocol Buffers service and generate Go code from it using protoc.
- Implement a gRPC server and client with registration and heartbeat RPCs.
- Secure communications with mTLS using self-signed certificates generated with Go's crypto/x509 package.
- Added gRPC interceptors for request logging and deadline enforcement on both the server and client.
Ready to quiz yourself?