-
Notifications
You must be signed in to change notification settings - Fork 55
Agent builder #1786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Agent builder #1786
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
6385b3e
ping works
hulto b29e77d
encrypted ping
hulto 19a647a
Replace b64 ID with uuid
hulto 26ea646
Update to use ed25519 key
hulto e9561bf
Address feedback
hulto 92c33ac
Merge branch 'main' into agent-builder
hulto 50c5355
fix
hulto f4ce416
Address feedback
hulto c2e6419
fix tests
hulto b185fab
Merge branch 'main' into agent-builder
hulto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
hulto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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] | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.