Add TLS unsealing via gRPC to CLI and server

Implements the SystemService gRPC endpoint (Status, Init, Unseal, Seal)
alongside the existing REST API, secured with the same TLS certificate.

The `metacrypt unseal` CLI command now prefers gRPC when --grpc-addr is
provided, falling back to the REST API via --addr. Both transports require
TLS; a custom CA certificate can be supplied with --ca-cert.

Server changes:
- internal/server/grpc.go: SystemServiceServer implementation with
  StartGRPC/ShutdownGRPC methods; uses the TLS cert from config.
- internal/server/server.go: adds grpcSrv field and grpc import.
- cmd/metacrypt/server.go: starts gRPC goroutine when grpc_addr is set
  in config, shuts it down on signal.

Generated code (from proto/metacrypt/v1/system.proto):
- gen/metacrypt/v1/system.pb.go: protobuf message types
- gen/metacrypt/v1/system_grpc.pb.go: gRPC client/server stubs

Dependencies added to go.mod (run `go mod tidy` to populate go.sum):
- google.golang.org/grpc v1.71.1
- google.golang.org/protobuf v1.36.5
- google.golang.org/genproto/googleapis/rpc (indirect)
- golang.org/x/net (indirect)

https://claude.ai/code/session_013m1QXGoTB4jaPUN5gwir8F
This commit is contained in:
Claude
2026-03-15 16:38:17 +00:00
committed by Kyle Isom
parent 167db48eb4
commit b8e348db03
7 changed files with 862 additions and 0 deletions

View File

@@ -86,7 +86,17 @@ func runServer(cmd *cobra.Command, args []string) error {
}
}()
if cfg.Server.GRPCAddr != "" {
go func() {
if err := srv.StartGRPC(); err != nil {
logger.Error("grpc server error", "error", err)
os.Exit(1)
}
}()
}
<-ctx.Done()
logger.Info("shutting down")
srv.ShutdownGRPC()
return srv.Shutdown(context.Background())
}

132
cmd/metacrypt/unseal.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"golang.org/x/term"
metacryptv1 "git.wntrmute.dev/kyle/metacrypt/gen/metacrypt/v1"
)
var unsealCmd = &cobra.Command{
Use: "unseal",
Short: "Unseal the Metacrypt service",
Long: "Send an unseal request to a running Metacrypt server over TLS. Uses gRPC when --grpc-addr is provided, otherwise falls back to the REST API.",
RunE: runUnseal,
}
var (
unsealAddr string
unsealGRPCAddr string
unsealCACert string
)
func init() {
unsealCmd.Flags().StringVar(&unsealAddr, "addr", "", "REST server address (e.g., https://localhost:8443)")
unsealCmd.Flags().StringVar(&unsealGRPCAddr, "grpc-addr", "", "gRPC server address (e.g., localhost:9443); preferred over --addr when set")
unsealCmd.Flags().StringVar(&unsealCACert, "ca-cert", "", "path to CA certificate for TLS verification")
rootCmd.AddCommand(unsealCmd)
}
func runUnseal(cmd *cobra.Command, args []string) error {
if unsealGRPCAddr == "" && unsealAddr == "" {
return fmt.Errorf("one of --grpc-addr or --addr is required")
}
fmt.Print("Unseal password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return fmt.Errorf("read password: %w", err)
}
if unsealGRPCAddr != "" {
return unsealViaGRPC(unsealGRPCAddr, unsealCACert, string(passwordBytes))
}
return unsealViaREST(unsealAddr, unsealCACert, string(passwordBytes))
}
func buildTLSConfig(caCertPath string) (*tls.Config, error) {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if caCertPath != "" {
pem, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("no valid certs in CA file")
}
tlsCfg.RootCAs = pool
}
return tlsCfg, nil
}
func unsealViaGRPC(addr, caCertPath, password string) error {
tlsCfg, err := buildTLSConfig(caCertPath)
if err != nil {
return err
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
return fmt.Errorf("grpc dial: %w", err)
}
defer conn.Close()
client := metacryptv1.NewSystemServiceClient(conn)
resp, err := client.Unseal(context.Background(), &metacryptv1.UnsealRequest{Password: password})
if err != nil {
return fmt.Errorf("unseal failed: %w", err)
}
fmt.Printf("State: %s\n", resp.State)
return nil
}
func unsealViaREST(addr, caCertPath, password string) error {
tlsCfg, err := buildTLSConfig(caCertPath)
if err != nil {
return err
}
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
}
body, err := json.Marshal(map[string]string{"password": password})
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
resp, err := client.Post(addr+"/v1/unseal", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
var result struct {
State string `json:"state"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode response: %w", err)
}
if result.Error != "" {
return fmt.Errorf("unseal failed: %s", result.Error)
}
fmt.Printf("State: %s\n", result.State)
return nil
}