Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 93 additions & 3 deletions tavern/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package main
import (
"context"
"crypto/ecdh"
"crypto/ed25519"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"fmt"
"log"
Expand All @@ -23,6 +25,8 @@ import (
"golang.org/x/net/http2/h2c"
"google.golang.org/grpc"
"realm.pub/tavern/internal/auth"
"realm.pub/tavern/internal/builder"
"realm.pub/tavern/internal/builder/builderpb"
"realm.pub/tavern/internal/c2"
"realm.pub/tavern/internal/c2/c2pb"
"realm.pub/tavern/internal/cdn"
Expand Down Expand Up @@ -134,6 +138,34 @@ func newApp(ctx context.Context) (app *cli.App) {
},
},
},
{
Name: "builder",
Usage: "Run a builder that compiles agents for target platforms",
Flags: []cli.Flag{
cli.StringFlag{
Name: "config",
Usage: "Path to the builder YAML configuration file",
},
},
Action: func(c *cli.Context) error {
configPath := c.String("config")
if configPath == "" {
return fmt.Errorf("--config flag is required")
}

cfg, err := builder.ParseConfig(configPath)
if err != nil {
return fmt.Errorf("failed to parse builder config: %w", err)
}

slog.InfoContext(ctx, "starting builder",
"config", configPath,
"supported_targets", cfg.SupportedTargets,
)

return builder.Run(ctx, cfg)
},
},
}
return
}
Expand Down Expand Up @@ -227,6 +259,13 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) {
// Initialize Git Tome Importer
git := cfg.NewGitImporter(client)

// Initialize Builder CA
builderCACert, builderCAKey, err := getBuilderCA()
if err != nil {
client.Close()
return nil, fmt.Errorf("failed to initialize builder CA: %w", err)
}

// Initialize Test Data
if cfg.IsTestDataEnabled() {
createTestData(ctx, client)
Expand Down Expand Up @@ -286,7 +325,7 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) {
AllowUnactivated: true,
},
"/graphql": tavernhttp.Endpoint{
Handler: newGraphQLHandler(client, git),
Handler: newGraphQLHandler(client, git, graphql.WithBuilderCAKey(builderCAKey), graphql.WithBuilderCA(builderCACert)),
AllowUnactivated: true,
},
"/c2.C2/": tavernhttp.Endpoint{
Expand All @@ -297,6 +336,11 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) {
"/portal.Portal/": tavernhttp.Endpoint{
Handler: newPortalGRPCHandler(client, portalMux),
},
"/builder.Builder/": tavernhttp.Endpoint{
Handler: newBuilderGRPCHandler(client, builderCACert),
AllowUnauthenticated: true,
AllowUnactivated: true,
},
"/cdn/": tavernhttp.Endpoint{
Handler: cdn.NewLinkDownloadHandler(client, "/cdn/"),
AllowUnauthenticated: true,
Expand Down Expand Up @@ -380,8 +424,8 @@ func NewServer(ctx context.Context, options ...func(*Config)) (*Server, error) {
return tSrv, nil
}

func newGraphQLHandler(client *ent.Client, repoImporter graphql.RepoImporter) http.Handler {
srv := handler.NewDefaultServer(graphql.NewSchema(client, repoImporter))
func newGraphQLHandler(client *ent.Client, repoImporter graphql.RepoImporter, options ...func(*graphql.Resolver)) http.Handler {
srv := handler.NewDefaultServer(graphql.NewSchema(client, repoImporter, options...))
srv.Use(entgql.Transactioner{TxOpener: client})

// Configure Raw Query Logging
Expand Down Expand Up @@ -497,6 +541,27 @@ func getKeyPairEd25519() (pubKey []byte, privKey []byte, err error) {
return pubKey, privKey, nil
}

// getBuilderCA returns the Builder CA certificate and private key for signing builder certificates.
// It uses the existing ED25519 key from the secrets manager.
func getBuilderCA() (caCert *x509.Certificate, caKey ed25519.PrivateKey, err error) {
secretsManager, err := newSecretsManager()
if err != nil {
return nil, nil, err
}

caKey, err = crypto.GetPrivKeyED25519(secretsManager)
if err != nil {
return nil, nil, fmt.Errorf("failed to get ED25519 private key: %w", err)
}

caCert, err = builder.CreateCA(caKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to create builder CA: %w", err)
}

return caCert, caKey, nil
}

func newPortalGRPCHandler(graph *ent.Client, portalMux *mux.Mux) http.Handler {
portalSrv := portals.New(graph, portalMux)
grpcSrv := grpc.NewServer(
Expand All @@ -519,6 +584,31 @@ func newPortalGRPCHandler(graph *ent.Client, portalMux *mux.Mux) http.Handler {
})
}

func newBuilderGRPCHandler(client *ent.Client, caCert *x509.Certificate) http.Handler {
builderSrv := builder.New(client)
grpcSrv := grpc.NewServer(
grpc.ChainUnaryInterceptor(
builder.NewMTLSAuthInterceptor(caCert, client),
grpcWithUnaryMetrics,
),
grpc.StreamInterceptor(grpcWithStreamMetrics),
)
builderpb.RegisterBuilderServer(grpcSrv, builderSrv)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 2 {
http.Error(w, "grpc requires HTTP/2", http.StatusBadRequest)
return
}

if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/grpc") {
http.Error(w, "must specify Content-Type application/grpc", http.StatusBadRequest)
return
}

grpcSrv.ServeHTTP(w, r)
})
}

func newGRPCHandler(client *ent.Client, grpcShellMux *stream.Mux, portalMux *mux.Mux) http.Handler {
pub, priv, err := getKeyPairX25519()
if err != nil {
Expand Down
68 changes: 68 additions & 0 deletions tavern/internal/builder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Builder

The builder package orchestrates agent compilation for target platforms. It connects to the Tavern server via gRPC and compiles agents based on its configuration.

## Overview

- **Registration**: Builders register with Tavern via the `registerBuilder` GraphQL mutation, which returns an mTLS certificate signed by the Tavern Builder CA and a YAML configuration file.
- **mTLS Authentication**: All gRPC requests are authenticated using application-level mTLS. The builder presents its CA-signed certificate and a signed timestamp in gRPC metadata on each request. The server verifies the certificate chain, proof of private key possession, and looks up the builder by the identifier embedded in the certificate CN.
- **gRPC API**: Builders communicate with Tavern over gRPC at the `/builder.Builder/` route. Currently supports a `Ping` health check endpoint.
- **CLI**: Run a builder using the `builder` subcommand with a `--config` flag pointing to a YAML configuration file.

## Configuration

Builders are configured via a YAML file with the following schema:

```yaml
id: <unique builder identifier>
supported_targets:
- linux
- macos
- windows
mtls: <mTLS certificate and key PEM bundle>
upstream: <tavern server address>
```

| Field | Description |
|-------|-------------|
| `id` | Unique identifier for this builder, assigned during registration. Embedded in the mTLS certificate CN as `builder-{id}`. |
| `supported_targets` | List of platforms this builder can compile agents for. Valid values: `linux`, `macos`, `windows`. |
| `mtls` | PEM bundle containing the CA-signed mTLS certificate and private key for authenticating with Tavern. |
| `upstream` | The Tavern server address to connect to. |

## Authentication Flow

1. An admin registers a builder via the `registerBuilder` GraphQL mutation.
2. Tavern generates a unique identifier and an Ed25519 client certificate signed by the Tavern Builder CA, with CN=`builder-{identifier}`.
3. The builder config YAML is returned containing the certificate, private key, identifier, and upstream address.
4. On each gRPC call, the builder client sends three metadata fields:
- `builder-cert-bin`: DER-encoded certificate (binary metadata)
- `builder-signature-bin`: Ed25519 signature over the timestamp (binary metadata)
- `builder-timestamp`: RFC3339Nano timestamp
5. The server interceptor verifies:
- Certificate was signed by the Tavern Builder CA
- Signature proves private key possession
- Timestamp is within 5 minutes (replay prevention)
- Certificate has not expired
- Builder identifier from CN exists in the database

## Usage

```bash
# Register a builder via GraphQL (returns config YAML)
# Then run it:
go run ./tavern builder --config /path/to/builder-config.yaml
```

## Package Structure

| File | Purpose |
|------|---------|
| `auth.go` | gRPC unary interceptor for mTLS authentication |
| `ca.go` | Builder CA generation, persistence, and certificate signing |
| `client.go` | Builder client with `PerRPCCredentials` for mTLS auth |
| `config.go` | YAML configuration parsing and validation |
| `server.go` | gRPC server implementation (Ping) |
| `proto/builder.proto` | Protobuf service definition |
| `builderpb/` | Generated protobuf Go code |
| `integration_test.go` | End-to-end test covering registration, mTLS auth, and unauthenticated rejection |
128 changes: 128 additions & 0 deletions tavern/internal/builder/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package builder

import (
"context"
"crypto/ed25519"
"crypto/x509"
"log/slog"
"strings"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

"realm.pub/tavern/internal/ent"
entbuilder "realm.pub/tavern/internal/ent/builder"
)

const (
// Metadata keys for mTLS authentication.
// Keys ending in "-bin" use gRPC binary metadata encoding.
mdKeyBuilderCert = "builder-cert-bin"
mdKeyBuilderSignature = "builder-signature-bin"
mdKeyBuilderTimestamp = "builder-timestamp"

// Maximum age for a timestamp to be considered valid.
maxTimestampAge = 5 * time.Minute

// CN prefix used in builder certificates.
builderCNPrefix = "builder-"
)

type builderContextKey struct{}

// BuilderFromContext extracts the authenticated builder entity from the context.
func BuilderFromContext(ctx context.Context) (*ent.Builder, bool) {
b, ok := ctx.Value(builderContextKey{}).(*ent.Builder)
return b, ok
}

// NewMTLSAuthInterceptor creates a gRPC unary server interceptor that validates
// builder mTLS credentials. It verifies:
// 1. The certificate was signed by the provided CA
// 2. The signature proves possession of the corresponding private key
// 3. The timestamp is recent (prevents replay)
// 4. The builder identifier from the CN exists in the database
func NewMTLSAuthInterceptor(caCert *x509.Certificate, graph *ent.Client) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}

// Extract metadata values.
// Binary metadata (keys ending in "-bin") is automatically base64 decoded by gRPC.
certDER := getMetadataValue(md, mdKeyBuilderCert)
signature := getMetadataValue(md, mdKeyBuilderSignature)
timestamp := getMetadataValue(md, mdKeyBuilderTimestamp)

if certDER == "" || signature == "" || timestamp == "" {
return nil, status.Error(codes.Unauthenticated, "missing builder credentials")
}

// Parse the certificate
cert, err := x509.ParseCertificate([]byte(certDER))
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid certificate")
}

// Verify certificate was signed by our CA
if err := cert.CheckSignatureFrom(caCert); err != nil {
return nil, status.Error(codes.Unauthenticated, "certificate not signed by trusted CA")
}

// Verify certificate validity period
now := time.Now()
if now.Before(cert.NotBefore) || now.After(cert.NotAfter) {
return nil, status.Error(codes.Unauthenticated, "certificate expired or not yet valid")
}

// Verify timestamp freshness
ts, err := time.Parse(time.RFC3339Nano, timestamp)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid timestamp format")
}
if now.Sub(ts).Abs() > maxTimestampAge {
return nil, status.Error(codes.Unauthenticated, "timestamp too old or too far in the future")
}

// Verify signature (proof of private key possession)
pubKey, ok := cert.PublicKey.(ed25519.PublicKey)
if !ok {
return nil, status.Error(codes.Unauthenticated, "certificate does not contain ED25519 public key")
}
if !ed25519.Verify(pubKey, []byte(timestamp), []byte(signature)) {
return nil, status.Error(codes.Unauthenticated, "invalid signature")
}

// Extract builder identifier from CN
cn := cert.Subject.CommonName
if !strings.HasPrefix(cn, builderCNPrefix) {
return nil, status.Error(codes.Unauthenticated, "invalid certificate CN format")
}
identifier := strings.TrimPrefix(cn, builderCNPrefix)

// Look up builder in database
b, err := graph.Builder.Query().Where(entbuilder.IdentifierEQ(identifier)).Only(ctx)
if err != nil {
slog.WarnContext(ctx, "builder authentication failed: builder not found", "identifier", identifier, "error", err)
return nil, status.Error(codes.Unauthenticated, "builder not found")
}

slog.InfoContext(ctx, "builder authenticated", "builder_id", b.ID, "identifier", identifier)

// Store builder in context for downstream handlers
ctx = context.WithValue(ctx, builderContextKey{}, b)
return handler(ctx, req)
}
}

func getMetadataValue(md metadata.MD, key string) string {
values := md.Get(key)
if len(values) == 0 {
return ""
}
return values[0]
}
Loading
Loading