From 42f32162b0acf7ccca8238aba3634fe8c84e2274 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 04:23:18 +0000 Subject: [PATCH 1/7] atunnel: broker actor JWTs through atelet --- .../internal/actoridentity/actoridentity.go | 128 +++--- .../actoridentity/actoridentity_test.go | 74 ++- cmd/ateapi/internal/actoridjwt/actoridjwt.go | 24 +- .../internal/controlapi/functional_test.go | 2 +- cmd/ateapi/internal/controlapi/service.go | 3 +- cmd/ateapi/internal/controlapi/workflow.go | 43 +- .../internal/controlapi/workflow_resume.go | 30 +- .../controlapi/workflow_testutil_test.go | 2 +- cmd/ateapi/main.go | 19 +- cmd/atelet/credentialbroker.go | 113 +++++ cmd/atelet/credentialbroker_test.go | 133 ++++++ cmd/atelet/main.go | 57 +++ cmd/ateom-gvisor/main.go | 180 +++++--- cmd/ateom-microvm/main.go | 105 +++-- cmd/ateom-microvm/restore.go | 19 +- cmd/ateom-microvm/run.go | 25 +- internal/ateompath/ateompath.go | 3 +- internal/atunnel/client.go | 41 +- internal/atunnel/client_test.go | 46 +- internal/atunnel/credential.go | 130 ++++++ internal/atunnel/credential_test.go | 193 ++++++++ internal/atunnel/egress.go | 116 +++-- internal/atunnel/egress_test.go | 271 +++++++++-- internal/atunnel/{server.go => ingress.go} | 0 .../{server_test.go => ingress_test.go} | 0 internal/proto/ateletpb/atelet.pb.go | 424 ++++++++++++------ internal/proto/ateletpb/atelet.proto | 26 ++ internal/proto/ateletpb/atelet_grpc.pb.go | 106 +++++ internal/proto/ateompb/ateom.pb.go | 99 ++-- internal/proto/ateompb/ateom.proto | 12 +- manifests/ate-install/ate-api-server.yaml | 1 + manifests/ate-install/atelet.yaml | 17 +- pkg/proto/ateapipb/ateapi.pb.go | 136 +++--- pkg/proto/ateapipb/ateapi.proto | 29 +- pkg/proto/ateapipb/ateapi_grpc.pb.go | 44 +- 35 files changed, 1983 insertions(+), 668 deletions(-) create mode 100644 cmd/atelet/credentialbroker.go create mode 100644 cmd/atelet/credentialbroker_test.go create mode 100644 internal/atunnel/credential.go create mode 100644 internal/atunnel/credential_test.go rename internal/atunnel/{server.go => ingress.go} (100%) rename internal/atunnel/{server_test.go => ingress_test.go} (100%) diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 6fc47c5de..33892ad07 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -22,15 +22,12 @@ import ( "errors" "fmt" "log/slog" - "net/http" "net/url" "os" "path" - "strings" "time" "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/k8sjwt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/localjwtauthority" @@ -39,24 +36,20 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" ) // Server implements ateapipb.ActorIdentityServer type Server struct { ateapipb.UnimplementedActorIdentityServer - clientJWTIssuer string - clientJWTAudience string - // TODO: Cache the signing keys in memory, so we don't read from a file every time. - actorIDJWTPoolFile string - actorIDCAPoolFile string - - workerCACerts string - httpClient *http.Client + actorIDJWTPoolFile string + actorIDCAPoolFile string + egressGatewayAudience string + actorJWTLifetime time.Duration // store is the actor database. MintCert consults it to confirm the caller // is entitled to the actor it is asking for a credential for. @@ -65,15 +58,13 @@ type Server struct { var _ ateapipb.ActorIdentityServer = (*Server)(nil) -func New(clientJWTIssuer, clientJWTAudience, actorIDJWTPoolFile, actorIDCAPoolFile, workerCACerts string, httpClient *http.Client, store store.Interface) *Server { +func New(actorIDJWTPoolFile, actorIDCAPoolFile, egressGatewayAudience string, actorJWTLifetime time.Duration, store store.Interface) *Server { return &Server{ - clientJWTIssuer: clientJWTIssuer, - clientJWTAudience: clientJWTAudience, - actorIDJWTPoolFile: actorIDJWTPoolFile, - actorIDCAPoolFile: actorIDCAPoolFile, - workerCACerts: workerCACerts, - httpClient: httpClient, - store: store, + actorIDJWTPoolFile: actorIDJWTPoolFile, + actorIDCAPoolFile: actorIDCAPoolFile, + egressGatewayAudience: egressGatewayAudience, + actorJWTLifetime: actorJWTLifetime, + store: store, } } @@ -91,29 +82,24 @@ const ( ) func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*ateapipb.MintJWTResponse, error) { - reqMetadata, ok := metadata.FromIncomingContext(ctx) - if !ok { - return nil, fmt.Errorf("no metadata found") + caller, err := authenticateAtelet(ctx) + if err != nil { + return nil, err } - - authorization := reqMetadata["authorization"] - if len(authorization) != 1 { - return nil, status.Errorf(codes.Unauthenticated, "Need authorization header") + if req.GetAudience() == "" || req.GetAudience() != s.egressGatewayAudience { + return nil, status.Error(codes.PermissionDenied, "requested audience is not permitted") } - - clientJWT := strings.TrimPrefix(authorization[0], "Bearer ") - - clientClaims, err := k8sjwt.Verify(ctx, s.httpClient, clientJWT, s.clientJWTIssuer, s.clientJWTAudience, time.Now()) + if req.GetWorkerPodUid() == "" || req.GetAtespace() == "" || req.GetActorName() == "" || req.GetActorUid() == "" { + return nil, status.Error(codes.InvalidArgument, "worker_pod_uid and actor identity are required") + } + actorRef := resources.ActorRef{Atespace: req.GetAtespace(), Name: req.GetActorName()} + actor, err := s.authorizeActor(ctx, caller, actorRef, req.GetWorkerPodUid()) if err != nil { - slog.ErrorContext(ctx, "Error while verifying client JWT", slog.Any("err", err)) - return nil, status.Errorf(codes.Unauthenticated, "Unauthenticated") + return nil, err + } + if actor.GetMetadata().GetUid() != req.GetActorUid() { + return nil, status.Error(codes.PermissionDenied, "caller is not permitted to mint credentials for this actor") } - - slog.InfoContext(ctx, "Verified client JWT", slog.Any("claims", clientClaims)) - - // TODO: Extract K8s identity from incoming JWT - - // TODO: Cross-check requested actor and user claims against the actor database. // TODO: Cache signing keys in memory, so we don't read from disk every time. signingPoolBytes, err := os.ReadFile(s.actorIDJWTPoolFile) @@ -125,27 +111,31 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at if err != nil { return nil, fmt.Errorf("while unmarshaling signing pool: %w", err) } + if len(signingPool.Authorities) == 0 { + return nil, fmt.Errorf("actor JWT signing pool is empty") + } // We only issue tokens with audience bindings. - if len(req.GetAudience()) == 0 { - return nil, fmt.Errorf("at least one audience must be requested") - } + now := time.Now() + expires := now.Add(s.actorJWTLifetime) actorClaims := &actoridjwt.Claims{ // TODO: This is currently API but it has to be a globally unique, oidc-compliant and accsible DNS name Issuer: "https://api.ate-system.svc", // TODO: this format is very likely going to change. Subject: fmt.Sprintf("atespaces:%s:actors:%s", req.GetAtespace(), req.GetActorName()), - Audiences: req.GetAudience(), - Expiration: time.Now().Add(15 * time.Minute), - NotBefore: time.Now().Add(-5 * time.Minute), - IssuedAt: time.Now(), + Audiences: []string{req.GetAudience()}, + Expiration: expires, + NotBefore: now.Add(-5 * time.Minute), + IssuedAt: now, JTI: rand.Text(), Substrate: actoridjwt.SubstrateClaims{ - Atespace: req.GetAtespace(), - ActorName: req.GetActorName(), - ActorUid: req.GetActorUid(), + Atespace: req.GetAtespace(), + ActorName: req.GetActorName(), + ActorUid: req.GetActorUid(), + ActorResourceVersion: actor.GetMetadata().GetVersion(), + WorkerPodUid: req.GetWorkerPodUid(), }, } @@ -161,7 +151,8 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at } return &ateapipb.MintJWTResponse{ - ActorJwt: actorJWT, + ActorJwt: actorJWT, + ExpirationTime: timestamppb.New(expires), }, nil } @@ -179,7 +170,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* } actorRef := resources.ActorRef{Atespace: atespace, Name: actorName} - actor, err := s.authorizeActor(ctx, caller, actorRef) + actor, err := s.authorizeActor(ctx, caller, actorRef, "") if err != nil { return nil, err } @@ -269,7 +260,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* }, nil } -// ateletCaller is the verified identity of an atelet that called MintCert. +// ateletCaller is the verified identity of an atelet requesting an actor credential. type ateletCaller struct { podName string nodeName string @@ -298,7 +289,7 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { } leaf := tlsInfo.State.PeerCertificates[0] - // Only atelet may mint actor certificates. Everything else with a valid + // Only atelet may mint actor credentials. Everything else with a valid // pod-identity certificate — including the actor workloads themselves — is // rejected here. expected := (&url.URL{ @@ -307,19 +298,19 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { Path: path.Join("ns", ateletNamespace, "sa", ateletSA), }).String() if len(leaf.URIs) == 0 || leaf.URIs[0].String() != expected { - slog.WarnContext(ctx, "MintCert denied: caller is not atelet", + slog.WarnContext(ctx, "ActorIdentity denied: caller is not atelet", slog.Any("uris", leaf.URIs), slog.String("expected", expected)) - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } identity, err := substratex509.PodIdentityFromCertificate(leaf) if err != nil { - slog.WarnContext(ctx, "MintCert denied: malformed PodIdentity extension", slog.Any("err", err)) - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + slog.WarnContext(ctx, "ActorIdentity denied: malformed PodIdentity extension", slog.Any("err", err)) + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } if identity == nil { - slog.WarnContext(ctx, "MintCert denied: certificate has no PodIdentity extension") - return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor certificates") + slog.WarnContext(ctx, "ActorIdentity denied: certificate has no PodIdentity extension") + return nil, status.Errorf(codes.PermissionDenied, "caller is not permitted to mint actor credentials") } return &ateletCaller{podName: identity.PodName, nodeName: identity.NodeName}, nil @@ -333,14 +324,16 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { // An atelet is therefore confined to the actors it is actually hosting, and an // actor that has been suspended, paused or migrated elsewhere can no longer // have credentials minted for it. -func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actorRef resources.ActorRef) (*ateapipb.Actor, error) { +// expectedWorkerPodUID is required for JWTs. The legacy certificate path +// leaves it empty because its request does not carry a worker Pod UID. +func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actorRef resources.ActorRef, expectedWorkerPodUID string) (*ateapipb.Actor, error) { // Denials are deliberately indistinguishable from each other: a caller that // is not entitled to an actor should not be able to use this RPC to learn // whether that actor exists, or where it is running. deny := func(reason string, args ...any) error { - slog.WarnContext(ctx, "MintCert denied: "+reason, + slog.WarnContext(ctx, "ActorIdentity denied: "+reason, append([]any{slog.Any("actor", actorRef), slog.String("callerPod", caller.podName), slog.String("callerNode", caller.nodeName)}, args...)...) - return status.Errorf(codes.PermissionDenied, "caller is not permitted to mint certificates for this actor") + return status.Errorf(codes.PermissionDenied, "caller is not permitted to mint credentials for this actor") } actor, err := s.store.GetActor(ctx, actorRef) @@ -348,7 +341,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor if errors.Is(err, store.ErrNotFound) { return nil, deny("actor not found") } - slog.ErrorContext(ctx, "MintCert: failed to read actor", slog.Any("actor", actorRef), slog.Any("err", err)) + slog.ErrorContext(ctx, "ActorIdentity: failed to read actor", slog.Any("actor", actorRef), slog.Any("err", err)) return nil, status.Errorf(codes.Internal, "failed to look up actor") } @@ -356,7 +349,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor // have already released the worker, so the assignment check below would // reject this too. It is kept because minting for better visbility and logging. if actor.GetStatus() == ateapipb.Actor_STATUS_DELETING { - slog.WarnContext(ctx, "MintCert refused: actor is being deleted", slog.Any("actor", actorRef)) + slog.WarnContext(ctx, "ActorIdentity refused: actor is being deleted", slog.Any("actor", actorRef)) return nil, status.Errorf(codes.FailedPrecondition, "actor is being deleted") } @@ -365,7 +358,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor // folded into deny(). podNamespace, podName, pool := actor.GetAteomPodNamespace(), actor.GetAteomPodName(), actor.GetWorkerPoolName() if podNamespace == "" || podName == "" || pool == "" { - slog.ErrorContext(ctx, "MintCert: running actor has incomplete placement", + slog.ErrorContext(ctx, "ActorIdentity: running actor has incomplete placement", slog.Any("actor", actorRef), slog.String("podNamespace", podNamespace), slog.String("podName", podName), slog.String("workerPool", pool)) return nil, status.Errorf(codes.FailedPrecondition, "actor has no worker assigned") @@ -376,13 +369,16 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor if errors.Is(err, store.ErrNotFound) { return nil, deny("worker hosting the actor not found", slog.String("workerPod", podNamespace+"/"+podName)) } - slog.ErrorContext(ctx, "MintCert: failed to read worker", slog.Any("actor", actorRef), slog.Any("err", err)) + slog.ErrorContext(ctx, "ActorIdentity: failed to read worker", slog.Any("actor", actorRef), slog.Any("err", err)) return nil, status.Errorf(codes.Internal, "failed to look up worker") } if worker.GetNodeName() != caller.nodeName { return nil, deny("actor is hosted on a different node", slog.String("actorNode", worker.GetNodeName())) } + if expectedWorkerPodUID != "" && worker.GetWorkerPodUid() != expectedWorkerPodUID { + return nil, deny("worker Pod UID does not match", slog.String("workerPodUID", expectedWorkerPodUID)) + } // The worker must still agree that it is hosting this actor. if assigned := worker.GetAssignment().GetActor(); resources.ActorRefFromObjectRef(assigned) != actorRef { diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index 0385a1a58..f9538a568 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -21,17 +21,22 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" + "encoding/json" "math/big" "net/url" "os" "path" "path/filepath" + "strings" "testing" "time" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest" "github.com/agent-substrate/substrate/internal/localca" + "github.com/agent-substrate/substrate/internal/localjwtauthority" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/substratex509" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -147,7 +152,72 @@ func newTestServer(t *testing.T, st store.Interface) *Server { t.Fatalf("write CA pool: %v", err) } - return New("issuer", "audience", "", poolFile, "", nil, st) + authority, err := localjwtauthority.GenerateECDSAP256Authority("test") + if err != nil { + t.Fatalf("generate JWT authority: %v", err) + } + jwtPool, err := localjwtauthority.Marshal(&localjwtauthority.Pool{Authorities: []*localjwtauthority.Authority{authority}}) + if err != nil { + t.Fatalf("marshal JWT authority: %v", err) + } + jwtPoolFile := filepath.Join(t.TempDir(), "actor-jwt-pool.json") + if err := os.WriteFile(jwtPoolFile, jwtPool, 0o600); err != nil { + t.Fatalf("write JWT pool: %v", err) + } + + return New(jwtPoolFile, poolFile, "egress.test", time.Hour, st) +} + +func TestMintJWTAuthorizesAndBindsWorker(t *testing.T) { + ctx := context.Background() + st, cleanup := storetest.SetupTestStore(t) + defer cleanup() + seedActor(t, ctx, st, runningOnNode(testNode)) + actor, err := st.GetActor(ctx, resources.ActorRef{Atespace: testAtespace, Name: testActorName}) + if err != nil { + t.Fatal(err) + } + srv := newTestServer(t, st) + request := func() *ateapipb.MintJWTRequest { + return &ateapipb.MintJWTRequest{ + Audience: "egress.test", Atespace: testAtespace, ActorName: testActorName, + ActorUid: actor.GetMetadata().GetUid(), WorkerPodUid: "worker-uid", + } + } + + resp, err := srv.MintJWT(ctxWithCert(ateletCertOn(t, testNode)), request()) + if err != nil { + t.Fatal(err) + } + parts := strings.Split(resp.GetActorJwt(), ".") + if len(parts) != 3 { + t.Fatalf("JWT has %d parts", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatal(err) + } + var claims actoridjwt.WireClaims + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatal(err) + } + if claims.Substrate.WorkerPodUid != "worker-uid" || claims.Substrate.ActorResourceVersion != actor.GetMetadata().GetVersion() { + t.Errorf("JWT binding = %+v", claims.Substrate) + } + + for name, mutate := range map[string]func(*ateapipb.MintJWTRequest){ + "wrong audience": func(r *ateapipb.MintJWTRequest) { r.Audience = "other" }, + "sibling worker": func(r *ateapipb.MintJWTRequest) { r.WorkerPodUid = "other-worker" }, + "stale actor UID": func(r *ateapipb.MintJWTRequest) { r.ActorUid = "old-actor" }, + } { + t.Run(name, func(t *testing.T) { + req := request() + mutate(req) + if _, err := srv.MintJWT(ctxWithCert(ateletCertOn(t, testNode)), req); status.Code(err) != codes.PermissionDenied { + t.Fatalf("MintJWT() error = %v, want PermissionDenied", err) + } + }) + } } // newCSR returns a DER-encoded, correctly self-signed CSR. @@ -605,7 +675,7 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { // A server whose CA pool file does not exist: reaching the signing path at // all would surface as Internal rather than PermissionDenied. - srv := New("issuer", "audience", "", filepath.Join(t.TempDir(), "missing.json"), "", nil, st) + srv := New("", filepath.Join(t.TempDir(), "missing.json"), "egress.test", time.Hour, st) _, err := srv.MintCert(ctxWithCert(ateletCertOn(t, testNode)), &ateapipb.MintCertRequest{ Atespace: testAtespace, diff --git a/cmd/ateapi/internal/actoridjwt/actoridjwt.go b/cmd/ateapi/internal/actoridjwt/actoridjwt.go index a6a00a48d..ca65556be 100644 --- a/cmd/ateapi/internal/actoridjwt/actoridjwt.go +++ b/cmd/ateapi/internal/actoridjwt/actoridjwt.go @@ -42,9 +42,11 @@ type Claims struct { } type SubstrateClaims struct { - Atespace string - ActorName string - ActorUid string + Atespace string + ActorName string + ActorUid string + ActorResourceVersion int64 + WorkerPodUid string } type wireHeader struct { @@ -68,9 +70,11 @@ type WireClaims struct { } type WireSubstrateClaims struct { - Atespace string `json:"atespace,omitempty"` - ActorName string `json:"actorName,omitempty"` - ActorUid string `json:"actorUid,omitempty"` + Atespace string `json:"atespace,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorUid string `json:"actorUid,omitempty"` + ActorResourceVersion int64 `json:"actorResourceVersion,omitempty"` + WorkerPodUid string `json:"workerPodUid,omitempty"` } func ClaimsToWire(claims *Claims) (*WireClaims, error) { @@ -88,9 +92,11 @@ func ClaimsToWire(claims *Claims) (*WireClaims, error) { IssuedAt: float64(claims.IssuedAt.Unix()), JTI: claims.JTI, Substrate: WireSubstrateClaims{ - Atespace: claims.Substrate.Atespace, - ActorName: claims.Substrate.ActorName, - ActorUid: claims.Substrate.ActorUid, + Atespace: claims.Substrate.Atespace, + ActorName: claims.Substrate.ActorName, + ActorUid: claims.Substrate.ActorUid, + ActorResourceVersion: claims.Substrate.ActorResourceVersion, + WorkerPodUid: claims.Substrate.WorkerPodUid, }, } diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 646da16a0..18667fbf5 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -324,7 +324,7 @@ func setupTest(t *testing.T, ns string) *testContext { return insecure.NewCredentials(), nil } - service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient) + service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, "", "") // 5. Start REAL gRPC Server for ATE API grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor)) diff --git a/cmd/ateapi/internal/controlapi/service.go b/cmd/ateapi/internal/controlapi/service.go index 944ae441f..64b5047f2 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -43,13 +43,14 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, dialer *AteletDialer, kubeClient kubernetes.Interface, + egressGatewayAddress, egressGatewayAudience string, ) *Service { s := &Service{ persistence: persistence, actorTemplateLister: actorTemplateLister, workerPoolLister: workerPoolLister, dialer: dialer, - actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient), + actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, egressGatewayAddress, egressGatewayAudience), } return s } diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index 8f591fc5a..28c9e6a07 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -130,15 +130,17 @@ func runStep[Params any, Context any](ctx context.Context, params Params, wCtx C // ActorWorkflow handles the workflows for actor's resume / suspend operations. type ActorWorkflow struct { - store store.Interface - workerCache *workercache.Cache - scheduler scheduling.Scheduler - dialer *AteletDialer - actorTemplateLister listersv1alpha1.ActorTemplateLister - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - kubeClient kubernetes.Interface - secretCache *envSecretCache + store store.Interface + workerCache *workercache.Cache + scheduler scheduling.Scheduler + dialer *AteletDialer + actorTemplateLister listersv1alpha1.ActorTemplateLister + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + kubeClient kubernetes.Interface + secretCache *envSecretCache + egressGatewayAddress string + egressGatewayAudience string } // NewActorWorkflow creates a new ActorWorkflow. @@ -150,17 +152,20 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, kubeClient kubernetes.Interface, + egressGatewayAddress, egressGatewayAudience string, ) *ActorWorkflow { return &ActorWorkflow{ - store: store, - workerCache: workerCache, - scheduler: scheduling.New(workerCache), - dialer: dialer, - actorTemplateLister: actorTemplateLister, - workerPoolLister: workerPoolLister, - sandboxConfigLister: sandboxConfigLister, - kubeClient: kubeClient, - secretCache: newEnvSecretCache(envSecretCacheTTL), + store: store, + workerCache: workerCache, + scheduler: scheduling.New(workerCache), + dialer: dialer, + actorTemplateLister: actorTemplateLister, + workerPoolLister: workerPoolLister, + sandboxConfigLister: sandboxConfigLister, + kubeClient: kubeClient, + secretCache: newEnvSecretCache(envSecretCacheTTL), + egressGatewayAddress: egressGatewayAddress, + egressGatewayAudience: egressGatewayAudience, } } @@ -183,7 +188,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, actorRef resources.Acto &CreateVolumesStep{store: w.store}, &AssignWorkerStep{store: w.store, workerCache: w.workerCache, scheduler: w.scheduler}, &AttachVolumesStep{store: w.store}, - &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler}, + &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler, egressGatewayAddress: w.egressGatewayAddress, egressGatewayAudience: w.egressGatewayAudience}, &FinalizeRunningStep{store: w.store}, } diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index f24e91a49..3c0d32204 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -452,13 +452,15 @@ func (s *AttachVolumesStep) Execute(ctx context.Context, input *ResumeInput, sta func (s *AttachVolumesStep) RetryBackoff() *wait.Backoff { return nil } type CallAteletRestoreStep struct { - store store.Interface - dialer *AteletDialer - kubeClient kubernetes.Interface - secretCache *envSecretCache - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - scheduler scheduling.Scheduler + store store.Interface + dialer *AteletDialer + kubeClient kubernetes.Interface + secretCache *envSecretCache + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + scheduler scheduling.Scheduler + egressGatewayAddress string + egressGatewayAudience string } func (s *CallAteletRestoreStep) Name() string { return "CallAteletRestore" } @@ -516,6 +518,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, if err != nil { return err } + egressGatewayAddress, egressGatewayAudience := s.egressGateway() if local := state.Actor.GetLocalSnapshotInfo(); local != nil { slog.InfoContext(ctx, "Actor has snapshot; Restoring from snapshot") @@ -528,6 +531,8 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorTemplateName: state.Actor.GetActorTemplateName(), Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, + EgressGatewayAddress: egressGatewayAddress, + EgressGatewayAudience: egressGatewayAudience, } req.Type = ateletpb.CheckpointType_CHECKPOINT_TYPE_LOCAL req.Config = &ateletpb.RestoreRequest_LocalConfig{ @@ -574,6 +579,8 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, // Empty unless this is a Golden data resume. GoldenSnapshotUriPrefix: state.GoldenSnapshotLocation, ActorUid: state.Actor.GetMetadata().Uid, + EgressGatewayAddress: egressGatewayAddress, + EgressGatewayAudience: egressGatewayAudience, } _, err = client.Restore(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while restoring durable snapshot") @@ -597,6 +604,8 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, SandboxAssets: sandboxAssets, Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, + EgressGatewayAddress: egressGatewayAddress, + EgressGatewayAudience: egressGatewayAudience, } _, err = client.Run(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while creating workload from spec") @@ -606,6 +615,13 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, func (s *CallAteletRestoreStep) RetryBackoff() *wait.Backoff { return nil } +func (s *CallAteletRestoreStep) egressGateway() (*string, *string) { + if s.egressGatewayAddress == "" { + return nil, nil + } + return &s.egressGatewayAddress, &s.egressGatewayAudience +} + type FinalizeRunningStep struct { store store.Interface } diff --git a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go index eebcc09cf..6f01ae061 100644 --- a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go +++ b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go @@ -42,7 +42,7 @@ func newTestActorWorkflow(t *testing.T, st store.Interface, tmplNamespace, tmplN }); err != nil { t.Fatalf("add template to indexer: %v", err) } - return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil) + return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, "", "") } // seedWorkflowActor stores an actor with the given status, bound to the given diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index 683ece541..1883c854e 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -71,9 +71,12 @@ var ( redisTLSServerName = pflag.String("redis-tls-server-name", "", "The ServerName to use for Redis TLS hostname verification.") redisClientCert = pflag.String("redis-client-cert", "", "The file containing client TLS certificate/key credential bundle for Redis/Valkey.") - clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") - clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") - actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") + clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") + clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") + actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") + egressGatewayAddress = pflag.String("egress-gateway-address", "", "Address of the egress PEP. Empty disables tunneled egress.") + egressGatewayAudience = pflag.String("egress-gateway-audience", "", "Audience allowed for atunnel actor JWTs.") + actorJWTLifetime = pflag.Duration("actor-jwt-lifetime", time.Hour, "Lifetime of actor JWTs minted for atunnel.") actorIDCAPoolFile = pflag.String("actor-id-ca-pool", "", "The file that contains the CA pool for signing actor JWTs") podIdentityCACerts = pflag.String("pod-identity-ca-certs", "", "The file that contains the pod-identity CA bundle, used both for verifying client certificates presented to the gRPC server and for verifying atelet serving certificates when dialing atelet. If empty, client-cert verification is disabled and atelet dials will fail.") @@ -95,6 +98,12 @@ func main() { } ctx := context.Background() serverboot.InitLogger() + if *egressGatewayAddress != "" && *egressGatewayAudience == "" { + serverboot.Fatal(ctx, "Invalid egress gateway configuration", fmt.Errorf("--egress-gateway-audience is required with --egress-gateway-address")) + } + if *actorJWTLifetime <= 0 { + serverboot.Fatal(ctx, "Invalid actor JWT lifetime", fmt.Errorf("--actor-jwt-lifetime must be positive")) + } if err := serverboot.SetLogLevel(*logLevelFlag); err != nil { serverboot.Fatal(ctx, "Invalid --log-level", err) } @@ -171,11 +180,11 @@ func main() { } ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) - sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset) + sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset, *egressGatewayAddress, *egressGatewayAudience) jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) - actorIdentitySrv := actoridentity.New(*clientJWTIssuer, *clientJWTAudience, *actorIDJWTPoolFile, *actorIDCAPoolFile, *podIdentityCACerts, jwtIssuerDiscoveryClient, redisPersistence) + actorIdentitySrv := actoridentity.New(*actorIDJWTPoolFile, *actorIDCAPoolFile, *egressGatewayAudience, *actorJWTLifetime, redisPersistence) debugSrv := debugapi.NewService(redisPersistence) lisCfg := &net.ListenConfig{} diff --git a/cmd/atelet/credentialbroker.go b/cmd/atelet/credentialbroker.go new file mode 100644 index 000000000..e3bba2134 --- /dev/null +++ b/cmd/atelet/credentialbroker.go @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +type credentialBroker struct { + ateletpb.UnimplementedCredentialBrokerServer + control ateapipb.ControlClient + identity ateapipb.ActorIdentityClient +} + +func (b *credentialBroker) MintActorJWT(ctx context.Context, req *ateletpb.MintActorJWTRequest) (*ateletpb.MintActorJWTResponse, error) { + workerUID, err := authenticatedWorkerUID(ctx) + if err != nil { + return nil, err + } + var assigned *ateapipb.Worker + for pageToken := ""; ; { + resp, err := b.control.ListWorkers(ctx, &ateapipb.ListWorkersRequest{PageSize: 1000, PageToken: pageToken}) + if err != nil { + return nil, fmt.Errorf("list workers: %w", err) + } + for _, worker := range resp.GetWorkers() { + if worker.GetWorkerPodUid() == workerUID { + assigned = worker + break + } + } + if assigned != nil || resp.GetNextPageToken() == "" { + break + } + pageToken = resp.GetNextPageToken() + } + if assigned == nil { + return nil, status.Error(codes.PermissionDenied, "worker is not registered") + } + actorRef := assigned.GetAssignment().GetActor() + if actorRef.GetAtespace() == "" || actorRef.GetName() == "" { + return nil, status.Error(codes.PermissionDenied, "worker has no actor assignment") + } + actor, err := b.control.GetActor(ctx, &ateapipb.GetActorRequest{Actor: actorRef}) + if err != nil { + return nil, fmt.Errorf("get assigned actor: %w", err) + } + resp, err := b.identity.MintJWT(ctx, &ateapipb.MintJWTRequest{ + Audience: req.GetAudience(), + Atespace: actor.GetMetadata().GetAtespace(), + ActorName: actor.GetMetadata().GetName(), + ActorUid: actor.GetMetadata().GetUid(), + WorkerPodUid: workerUID, + }) + if err != nil { + return nil, fmt.Errorf("mint actor JWT: %w", err) + } + return &ateletpb.MintActorJWTResponse{ActorJwt: resp.GetActorJwt(), ExpirationTime: resp.GetExpirationTime()}, nil +} + +func authenticatedWorkerUID(ctx context.Context) (string, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return "", status.Error(codes.Unauthenticated, "missing peer credentials") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.PeerCertificates) == 0 { + return "", status.Error(codes.Unauthenticated, "missing peer certificate") + } + identity, err := substratex509.PodIdentityFromCertificate(tlsInfo.State.PeerCertificates[0]) + if err != nil || identity == nil { + return "", status.Error(codes.PermissionDenied, "invalid worker identity") + } + return identity.PodUID, nil +} + +func restrictClientToNode(node *substratex509.PodIdentity) func(tls.ConnectionState) error { + return func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("worker certificate is required") + } + identity, err := substratex509.PodIdentityFromCertificate(state.PeerCertificates[0]) + if err != nil { + return fmt.Errorf("parse worker Pod identity: %w", err) + } + if identity == nil || identity.NodeName != node.NodeName || identity.NodeUID != node.NodeUID { + return fmt.Errorf("worker is not on node %q (%s)", node.NodeName, node.NodeUID) + } + return nil + } +} diff --git a/cmd/atelet/credentialbroker_test.go b/cmd/atelet/credentialbroker_test.go new file mode 100644 index 000000000..a07384281 --- /dev/null +++ b/cmd/atelet/credentialbroker_test.go @@ -0,0 +1,133 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type brokerControlClient struct { + ateapipb.ControlClient + worker *ateapipb.Worker + actor *ateapipb.Actor +} + +func (c *brokerControlClient) ListWorkers(context.Context, *ateapipb.ListWorkersRequest, ...grpc.CallOption) (*ateapipb.ListWorkersResponse, error) { + return &ateapipb.ListWorkersResponse{Workers: []*ateapipb.Worker{c.worker}}, nil +} + +func (c *brokerControlClient) GetActor(context.Context, *ateapipb.GetActorRequest, ...grpc.CallOption) (*ateapipb.Actor, error) { + return c.actor, nil +} + +type brokerIdentityClient struct { + ateapipb.ActorIdentityClient + request *ateapipb.MintJWTRequest +} + +func (c *brokerIdentityClient) MintJWT(_ context.Context, req *ateapipb.MintJWTRequest, _ ...grpc.CallOption) (*ateapipb.MintJWTResponse, error) { + c.request = req + return &ateapipb.MintJWTResponse{ActorJwt: "jwt", ExpirationTime: timestamppb.New(time.Now().Add(time.Hour))}, nil +} + +func TestCredentialBrokerDerivesActorFromWorkerCertificate(t *testing.T) { + control := &brokerControlClient{ + worker: &ateapipb.Worker{ + WorkerPodUid: "worker-uid", + Assignment: &ateapipb.Assignment{Actor: &ateapipb.ObjectRef{Atespace: "team", Name: "actor"}}, + }, + actor: &ateapipb.Actor{Metadata: &ateapipb.ResourceMetadata{Atespace: "team", Name: "actor", Uid: "actor-uid"}}, + } + identity := &brokerIdentityClient{} + broker := &credentialBroker{control: control, identity: identity} + resp, err := broker.MintActorJWT(workerContext(t, "worker-uid"), &ateletpb.MintActorJWTRequest{Audience: "pep"}) + if err != nil { + t.Fatal(err) + } + if resp.GetActorJwt() != "jwt" { + t.Fatalf("JWT = %q", resp.GetActorJwt()) + } + want := &ateapipb.MintJWTRequest{Audience: "pep", Atespace: "team", ActorName: "actor", ActorUid: "actor-uid", WorkerPodUid: "worker-uid"} + if !proto.Equal(identity.request, want) { + t.Fatalf("MintJWT request = %+v, want %+v", identity.request, want) + } +} + +func TestCredentialBrokerRejectsUnknownWorker(t *testing.T) { + broker := &credentialBroker{control: &brokerControlClient{}} + if _, err := broker.MintActorJWT(workerContext(t, "unknown-worker"), &ateletpb.MintActorJWTRequest{Audience: "pep"}); err == nil { + t.Fatal("unknown worker was accepted") + } +} + +func workerContext(t *testing.T, podUID string) context.Context { + t.Helper() + cert := workerCertificate(t, podUID, "node") + return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}}}}) +} + +func TestRestrictClientToNode(t *testing.T) { + state := tls.ConnectionState{PeerCertificates: []*x509.Certificate{workerCertificate(t, "worker-uid", "node-a")}} + nodeA := &substratex509.PodIdentity{NodeName: "node-a", NodeUID: "node-uid"} + if err := restrictClientToNode(nodeA)(state); err != nil { + t.Fatalf("same-node worker rejected: %v", err) + } + if err := restrictClientToNode(&substratex509.PodIdentity{NodeName: "node-b", NodeUID: "node-uid"})(state); err == nil { + t.Fatal("cross-node worker accepted") + } + if err := restrictClientToNode(&substratex509.PodIdentity{NodeName: "node-a", NodeUID: "replacement-node"})(state); err == nil { + t.Fatal("replacement node accepted") + } +} + +func workerCertificate(t *testing.T, podUID, nodeName string) *x509.Certificate { + t.Helper() + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour)} + if err := substratex509.AddPodIdentityToCertificate(&substratex509.PodIdentity{ + Namespace: "workers", ServiceAccountName: "default", ServiceAccountUID: "sa-uid", + PodName: "worker", PodUID: podUID, NodeName: nodeName, NodeUID: "node-uid", + }, template); err != nil { + t.Fatal(err) + } + der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index b3f0241c9..75c04982c 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -32,6 +32,7 @@ import ( "cloud.google.com/go/storage" "github.com/agent-substrate/substrate/cmd/atelet/internal/ategcs" + "github.com/agent-substrate/substrate/internal/ateapiauth" "github.com/agent-substrate/substrate/internal/ateerrors" "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/ateompath" @@ -41,7 +42,9 @@ import ( "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/serverboot" + "github.com/agent-substrate/substrate/internal/substratex509" "github.com/agent-substrate/substrate/internal/version" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/google/go-containerregistry/pkg/authn" @@ -71,6 +74,9 @@ var ( grpcServerCredBundle = pflag.String("grpc-server-cred-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Credential bundle atelet presents as its gRPC serving certificate.") clientCACerts = pflag.String("client-ca-certs", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify gRPC client certificates.") + ateapiAddress = pflag.String("ateapi-address", "dns:///api.ate-system.svc:443", "ateapi gRPC target used by the credential broker.") + ateapiCAFile = pflag.String("ateapi-ca-file", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "CA bundle used to verify ateapi.") + ateapiServerName = pflag.String("ateapi-server-name", "api.ate-system.svc", "DNS name expected on the ateapi certificate.") gcpAuthForImagePulls = pflag.Bool("gcp-auth-for-image-pulls", true, "Use GCP application default credentials mechanism.") localhostRegistryReplacement = pflag.String("localhost-registry-replacement", "", "The replacement registry endpoint for localhost and/or loopback IP addresses, useful for local development. for example kind-registry:5000") @@ -182,6 +188,19 @@ func main() { wrappedGCS, imageCache, ) + dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{ + CAFile: *ateapiCAFile, + ServerName: *ateapiServerName, + ClientCredBundle: *grpcServerCredBundle, + }) + if err != nil { + serverboot.Fatal(ctx, "Failed to build ateapi client credentials", err) + } + ateapiConn, err := grpc.NewClient(*ateapiAddress, dialOpts...) + if err != nil { + serverboot.Fatal(ctx, "Failed to create ateapi client", err) + } + defer ateapiConn.Close() lis, err := net.Listen("tcp", ":"+strconv.Itoa(*port)) if err != nil { @@ -192,6 +211,40 @@ func main() { if err != nil { serverboot.Fatal(ctx, "Failed to build server TLS config", err) } + ateletCert, err := credbundle.Parse(*grpcServerCredBundle) + if err != nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", err) + } + ateletIdentity, err := substratex509.PodIdentityFromCertificate(ateletCert.Leaf) + if err != nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", err) + } + if ateletIdentity == nil { + serverboot.Fatal(ctx, "Failed to load atelet Pod identity", fmt.Errorf("credential bundle has no Pod identity")) + } + brokerTLS := tlsCfg.Clone() + brokerTLS.VerifyConnection = restrictClientToNode(ateletIdentity) + if err := os.Remove(ateompath.CredentialBrokerSocket); err != nil && !errors.Is(err, os.ErrNotExist) { + serverboot.Fatal(ctx, "Failed to remove stale credential broker socket", err) + } + brokerLis, err := net.Listen("unix", ateompath.CredentialBrokerSocket) + if err != nil { + serverboot.Fatal(ctx, "Failed to listen for credential broker", err) + } + defer brokerLis.Close() + if err := os.Chmod(ateompath.CredentialBrokerSocket, 0o600); err != nil { + serverboot.Fatal(ctx, "Failed to restrict credential broker socket", err) + } + brokerServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(brokerTLS))) + ateletpb.RegisterCredentialBrokerServer(brokerServer, &credentialBroker{ + control: ateapipb.NewControlClient(ateapiConn), + identity: ateapipb.NewActorIdentityClient(ateapiConn), + }) + go func() { + if err := brokerServer.Serve(brokerLis); err != nil { + serverboot.Fatal(ctx, "Failed to serve credential broker", err) + } + }() svr := grpc.NewServer( grpc.Creds(credentials.NewTLS(tlsCfg)), @@ -292,6 +345,8 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (resp * RuntimeAssetPaths: assetPaths, Spec: buildAteomWorkloadSpec(req.GetSpec()), ActorUid: actorUID, + EgressGatewayAddress: req.EgressGatewayAddress, + EgressGatewayAudience: req.EgressGatewayAudience, }); err != nil { return nil, fmt.Errorf("while calling ateom.RunWorkload: %w", err) } @@ -667,6 +722,8 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) Spec: buildAteomWorkloadSpec(req.GetSpec()), Scope: toAteomSnapshotScope(req.GetScope()), ActorUid: req.GetActorUid(), + EgressGatewayAddress: req.EgressGatewayAddress, + EgressGatewayAudience: req.EgressGatewayAudience, // Informational: for DATA_ON_GOLDEN the golden snapshot's files are // already staged into the restore dir by the combined download above; // ateom restores from the shared dir and never fetches this URI. diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 5e1a6440a..0cf80e806 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -24,9 +24,11 @@ import ( "net" "net/url" "os" + "slices" "sort" "strings" "sync" + "time" "cloud.google.com/go/compute/metadata" "github.com/agent-substrate/substrate/internal/actorlog" @@ -160,12 +162,12 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while parsing atunnel upstream: %w", err) } - atunnelServer, atunnelEgress, atunnelEgressPort, err := runAtunnel(ctx, upstream) + atunnelIngress, atunnelEgress, atunnelEgressPort, err := runAtunnel(ctx, upstream) if err != nil { return err } - ateomService := NewService(interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle) + ateomService := NewService(interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelTrustBundle, *atunnelEgressTrustBundle) svr := grpc.NewServer( grpc.StatsHandler(otelgrpc.NewServerHandler()), @@ -183,7 +185,7 @@ func do(ctx context.Context) error { } func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunnel.Egress, uint16, error) { - atunnelServer, err := atunnel.NewServer(atunnel.Config{ + atunnelIngress, err := atunnel.NewServer(atunnel.Config{ CredentialBundlePath: *atunnelCredentialBundle, TrustBundlePath: *atunnelTrustBundle, AllowedClientID: *atunnelClientIdentity, @@ -197,7 +199,7 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn return nil, nil, 0, fmt.Errorf("while opening atunnel listener: %w", err) } go func() { - if err := atunnelServer.Serve(ctx, atunnelListener); err != nil { + if err := atunnelIngress.Serve(ctx, atunnelListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor ingress", err) } }() @@ -223,7 +225,7 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn } }() slog.InfoContext(ctx, "atunnel egress serving", slog.String("address", *atunnelEgressListenAddress)) - return atunnelServer, atunnelEgress, atunnelEgressPort, nil + return atunnelIngress, atunnelEgress, atunnelEgressPort, nil } // AteomService is a service for shepherding single microvm. @@ -234,28 +236,29 @@ type AteomService struct { // subcommands are probably not safe to call concurrently. lock sync.Mutex - interiorNetNS netns.NsHandle - actorLogger *actorlog.ActorLogger - atunnel *atunnel.Server - atunnelEgress *atunnel.Egress - // atunnelEgressPort is zero when tunneled egress is disabled. Otherwise, - // actor TCP connections are transparently redirected to this local port. + interiorNetNS netns.NsHandle + actorLogger *actorlog.ActorLogger + atunnelIngress *atunnel.Server + atunnelEgress *atunnel.Egress + // Actor TCP connections are transparently redirected to atunnelEgressPort. atunnelEgressPort uint16 atunnelCredentialBundle string + atunnelTrustBundle string atunnelEgressTrustBundle string } var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { +func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, trustBundle, egressTrustBundle string) *AteomService { return &AteomService{ interiorNetNS: interiorNetNS, actorLogger: actorLogger, - atunnel: atunnelServer, + atunnelIngress: atunnelIngress, atunnelEgress: atunnelEgress, atunnelEgressPort: atunnelEgressPort, atunnelCredentialBundle: credentialBundle, + atunnelTrustBundle: trustBundle, atunnelEgressTrustBundle: egressTrustBundle, } } @@ -275,6 +278,10 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // * Correct runsc version is downloaded and placed on disk. // * All OCI bundles are set up, including for "pause" container. + egress, err := s.prepareActorEgress(ctx, req.GetEgressGatewayAddress(), req.GetEgressGatewayAudience()) + if err != nil { + return nil, err + } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, @@ -282,8 +289,19 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } + rcmd := &runsc{ + path: req.GetRunscPath(), + actorUID: req.GetActorUid(), + } + var containersToDelete []string defer func() { if retErr != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := s.deactivateActorNetworking(cleanupCtx); err != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Run failure", slog.Any("err", err)) + } + deleteContainers(cleanupCtx, rcmd, containersToDelete, "Run") // Detach any bundle rootfs overlays a partially-completed setup // mounted, mirroring the post-checkpoint cleanup — otherwise they // linger in this namespace until atelet wipes the bundle dirs. @@ -292,17 +310,12 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload slog.WarnContext(ctx, "Failed to unmount bundle rootfs overlays after Run failure", "actorUID", req.GetActorUid(), "err", err) } - if err := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); err != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Run failure", slog.Any("err", err)) + if err := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); err != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Run failure", slog.Any("err", err)) } } }() - rcmd := &runsc{ - path: req.GetRunscPath(), - actorUID: req.GetActorUid(), - } - // Create and start pause container. The bundle rootfs is composed here — // an overlay of the node's cached image layers plus the bundle's private // upper — because mounting is ateom's job (atelet runs with no @@ -311,6 +324,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := imagecache.SetupBundleRootfs(ateompath.OCIBundlePath(req.GetActorUid(), "pause")); err != nil { return nil, fmt.Errorf("while composing pause rootfs: %w", err) } + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -329,6 +343,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := imagecache.SetupBundleRootfs(ateompath.OCIBundlePath(req.GetActorUid(), ac.GetName())); err != nil { return nil, fmt.Errorf("while composing %q rootfs: %w", ac.GetName(), err) } + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -341,7 +356,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), ateomnet.ActorVethIP); err != nil { return nil, fmt.Errorf("while waiting for container readyz: %w", err) } - if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), req.GetActorVersion(), req.GetEgressGatewayAddress()); err != nil { + if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), egress); err != nil { return nil, err } @@ -496,6 +511,10 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // * All OCI bundles are set up, including for "pause" container. // * Checkpoint downloaded and placed on disk + egress, err := s.prepareActorEgress(ctx, req.GetEgressGatewayAddress(), req.GetEgressGatewayAudience()) + if err != nil { + return nil, err + } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, @@ -503,24 +522,30 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } + rcmd := &runsc{ + path: req.GetRunscPath(), + actorUID: req.GetActorUid(), + } + var containersToDelete []string defer func() { if retErr != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if err := s.deactivateActorNetworking(cleanupCtx); err != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Restore failure", slog.Any("err", err)) + } + deleteContainers(cleanupCtx, rcmd, containersToDelete, "Restore") // Same overlay detach as the Run-failure path above. if err := imagecache.UnmountAllUnder(ateompath.OCIBundleDir(req.GetActorUid())); err != nil { slog.WarnContext(ctx, "Failed to unmount bundle rootfs overlays after Restore failure", "actorUID", req.GetActorUid(), "err", err) } - if err := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); err != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Restore failure", slog.Any("err", err)) + if err := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); err != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Restore failure", slog.Any("err", err)) } } }() - rcmd := &runsc{ - path: req.GetRunscPath(), - actorUID: req.GetActorUid(), - } - checkpointDir := ateompath.RestoreStateDir(req.GetActorUid()) // Compose the pause rootfs before create (see RunWorkload). runsc restore @@ -533,6 +558,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore switch req.GetScope() { case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: // Create and restore pause container + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", []string{"--fs-restore-image-path", checkpointDir}); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -541,6 +567,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: // Create and restore pause container + containersToDelete = append(containersToDelete, "pause") if err := rcmd.cmdCreate(ctx, os.Stdout, "pause", nil); err != nil { return nil, fmt.Errorf("while creating pause container: %w", err) } @@ -564,6 +591,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } switch req.GetScope() { case ateompb.SnapshotScope_SNAPSHOT_SCOPE_DATA: + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -571,6 +599,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return nil, fmt.Errorf("while starting %q application container: %w", ac.GetName(), err) } case ateompb.SnapshotScope_SNAPSHOT_SCOPE_FULL: + containersToDelete = append(containersToDelete, ac.GetName()) if err := rcmd.cmdCreate(ctx, pw, ac.GetName(), nil); err != nil { return nil, fmt.Errorf("while creating %q application container: %w", ac.GetName(), err) } @@ -586,7 +615,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore if err := readyz.WaitAll(ctx, req.GetSpec().GetContainers(), ateomnet.ActorVethIP); err != nil { return nil, fmt.Errorf("while waiting for container readyz: %w", err) } - if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), req.GetActorVersion(), req.GetEgressGatewayAddress()); err != nil { + if err := s.activateActorNetworking(req.GetAtespace(), req.GetActorName(), egress); err != nil { return nil, err } @@ -595,49 +624,74 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore return &ateompb.RestoreWorkloadResponse{}, nil } -func (s *AteomService) activateActorNetworking(atespace, actorName string, actorVersion int64, egressGatewayAddress string) error { - var egressClient atunnel.EgressDialer - if s.atunnelEgress != nil && egressGatewayAddress != "" { - serverName, _, err := net.SplitHostPort(egressGatewayAddress) - if err != nil { - return fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) - } - egressClient, err = atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, - ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, - }) - if err != nil { - return fmt.Errorf("while configuring actor egress client: %w", err) - } +type actorEgress struct { + egressClient atunnel.EgressDialer + jwtSource *atunnel.BrokerJWTSource + jwt atunnel.ActorJWT +} + +func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { + if egressGatewayAddress == "" { + return nil, nil } - if s.atunnel != nil { - if err := s.atunnel.Activate(atespace, actorName); err != nil { - return fmt.Errorf("while activating actor ingress: %w", err) - } + if egressGatewayAudience == "" { + return nil, fmt.Errorf("egress gateway audience is required with an egress gateway") } - if egressClient != nil { - if err := s.atunnelEgress.Activate(egressClient, atespace, actorName, actorVersion, ""); err != nil { - if s.atunnel != nil { - _ = s.atunnel.Deactivate(context.Background()) - } - return fmt.Errorf("while activating actor egress: %w", err) - } + serverName, _, err := net.SplitHostPort(egressGatewayAddress) + if err != nil { + return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) + } + egressClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: egressGatewayAddress, + ServerName: serverName, + CredentialBundlePath: s.atunnelCredentialBundle, + TrustBundlePath: s.atunnelEgressTrustBundle, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) + } + jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ + SocketPath: ateompath.CredentialBrokerSocket, + CredentialBundlePath: s.atunnelCredentialBundle, + TrustBundlePath: s.atunnelTrustBundle, + Audience: egressGatewayAudience, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) + } + jwt, err := jwtSource.Mint(ctx) + if err != nil { + return nil, fmt.Errorf("while obtaining actor JWT: %w", err) + } + return &actorEgress{egressClient: egressClient, jwtSource: jwtSource, jwt: jwt}, nil +} + +func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { + if err := s.atunnelIngress.Activate(atespace, actorName); err != nil { + return fmt.Errorf("while activating actor ingress: %w", err) + } + if egress == nil { + return nil + } + if err := s.atunnelEgress.Activate(egress.egressClient, egress.jwtSource, egress.jwt); err != nil { + return fmt.Errorf("while activating actor egress: %w", err) } return nil } +func deleteContainers(ctx context.Context, rcmd *runsc, containers []string, operation string) { + for _, container := range slices.Backward(containers) { + if err := rcmd.cmdDelete(ctx, container); err != nil { + slog.WarnContext(ctx, "Failed to delete runsc container after failure", + "operation", operation, "container", container, "err", err) + } + } +} + func (s *AteomService) deactivateActorNetworking(ctx context.Context) error { // Stop admitting traffic and drain active streams before the Actor network // is torn down. Attempt both directions even if one fails to deactivate. - var err error - if s.atunnel != nil { - err = errors.Join(err, s.atunnel.Deactivate(ctx)) - } - if s.atunnelEgress != nil { - err = errors.Join(err, s.atunnelEgress.Deactivate(ctx)) - } + err := errors.Join(s.atunnelIngress.Deactivate(ctx), s.atunnelEgress.Deactivate(ctx)) if err != nil { return fmt.Errorf("while deactivating actor networking: %w", err) } diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 246726997..7a3f07bf6 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -168,7 +168,7 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while parsing atunnel upstream: %w", err) } - atunnelServer, err := atunnel.NewServer(atunnel.Config{ + atunnelIngress, err := atunnel.NewServer(atunnel.Config{ CredentialBundlePath: *atunnelCredentialBundle, TrustBundlePath: *atunnelTrustBundle, AllowedClientID: *atunnelClientIdentity, @@ -182,7 +182,7 @@ func do(ctx context.Context) error { return fmt.Errorf("while opening atunnel listener: %w", err) } go func() { - if err := atunnelServer.Serve(ctx, atunnelListener); err != nil { + if err := atunnelIngress.Serve(ctx, atunnelListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor ingress", err) } }() @@ -212,7 +212,7 @@ func do(ctx context.Context) error { grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.UnaryInterceptor(ateinterceptors.InternalServerUnaryInterceptor), ) - ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle)) + ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelTrustBundle, *atunnelEgressTrustBundle)) reflection.Register(svr) slog.InfoContext(ctx, "ateom-microvm serving", slog.String("socket", sockPath)) @@ -270,13 +270,13 @@ type AteomService struct { // actorLogger forwards the actor container's stdout/stderr to the worker pod's // stdout as ate.dev/*-labeled JSON and emits actor lifecycle events (parity // with ateom-gvisor). - actorLogger *actorlog.ActorLogger - atunnel *atunnel.Server - atunnelEgress *atunnel.Egress - // atunnelEgressPort is zero when tunneled egress is disabled. Otherwise, - // actor TCP connections are transparently redirected to this local port. + actorLogger *actorlog.ActorLogger + atunnelIngress *atunnel.Server + atunnelEgress *atunnel.Egress + // Actor TCP connections are transparently redirected to atunnelEgressPort. atunnelEgressPort uint16 atunnelCredentialBundle string + atunnelTrustBundle string atunnelEgressTrustBundle string // running maps actor UID -> the live micro-VM, kept so CheckpointWorkload can @@ -288,7 +288,7 @@ type AteomService struct { var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { +func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, trustBundle, egressTrustBundle string) *AteomService { return &AteomService{ podUID: podUID, chBinary: chBinary, @@ -296,44 +296,67 @@ func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNet kataDebug: kataDebug, interiorNetNS: interiorNetNS, actorLogger: actorLogger, - atunnel: atunnelServer, + atunnelIngress: atunnelIngress, atunnelEgress: atunnelEgress, atunnelEgressPort: atunnelEgressPort, atunnelCredentialBundle: credentialBundle, + atunnelTrustBundle: trustBundle, atunnelEgressTrustBundle: egressTrustBundle, running: map[string]*runningActor{}, } } -func (s *AteomService) activateActorNetworking(atespace, actorName string, actorVersion int64, egressGatewayAddress string) error { - var egressClient atunnel.EgressDialer - if s.atunnelEgress != nil && egressGatewayAddress != "" { - serverName, _, err := net.SplitHostPort(egressGatewayAddress) - if err != nil { - return fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) - } - egressClient, err = atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, - ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, - }) - if err != nil { - return fmt.Errorf("while configuring actor egress client: %w", err) - } +type actorEgress struct { + egressClient atunnel.EgressDialer + jwtSource *atunnel.BrokerJWTSource + jwt atunnel.ActorJWT +} + +func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { + if egressGatewayAddress == "" { + return nil, nil } - if s.atunnel != nil { - if err := s.atunnel.Activate(atespace, actorName); err != nil { - return fmt.Errorf("while activating actor ingress: %w", err) - } + if egressGatewayAudience == "" { + return nil, fmt.Errorf("egress gateway audience is required with an egress gateway") } - if egressClient != nil { - if err := s.atunnelEgress.Activate(egressClient, atespace, actorName, actorVersion, ""); err != nil { - if s.atunnel != nil { - _ = s.atunnel.Deactivate(context.Background()) - } - return fmt.Errorf("while activating actor egress: %w", err) - } + serverName, _, err := net.SplitHostPort(egressGatewayAddress) + if err != nil { + return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) + } + egressClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: egressGatewayAddress, + ServerName: serverName, + CredentialBundlePath: s.atunnelCredentialBundle, + TrustBundlePath: s.atunnelEgressTrustBundle, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) + } + jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ + SocketPath: ateompath.CredentialBrokerSocket, + CredentialBundlePath: s.atunnelCredentialBundle, + TrustBundlePath: s.atunnelTrustBundle, + Audience: egressGatewayAudience, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) + } + jwt, err := jwtSource.Mint(ctx) + if err != nil { + return nil, fmt.Errorf("while obtaining actor JWT: %w", err) + } + return &actorEgress{egressClient: egressClient, jwtSource: jwtSource, jwt: jwt}, nil +} + +func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { + if err := s.atunnelIngress.Activate(atespace, actorName); err != nil { + return fmt.Errorf("while activating actor ingress: %w", err) + } + if egress == nil { + return nil + } + if err := s.atunnelEgress.Activate(egress.egressClient, egress.jwtSource, egress.jwt); err != nil { + return fmt.Errorf("while activating actor egress: %w", err) } return nil } @@ -341,13 +364,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, actor func (s *AteomService) deactivateActorNetworking(ctx context.Context) error { // Stop admitting traffic and drain active streams before the Actor network // is torn down. Attempt both directions even if one fails to deactivate. - var err error - if s.atunnel != nil { - err = errors.Join(err, s.atunnel.Deactivate(ctx)) - } - if s.atunnelEgress != nil { - err = errors.Join(err, s.atunnelEgress.Deactivate(ctx)) - } + err := errors.Join(s.atunnelIngress.Deactivate(ctx), s.atunnelEgress.Deactivate(ctx)) if err != nil { return fmt.Errorf("while deactivating actor networking: %w", err) } diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index a0c76affc..e9f3fa94f 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -70,8 +70,8 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - actorVersion: req.GetActorVersion(), - egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGatewayAudience: req.GetEgressGatewayAudience(), } restoreDir := ateompath.RestoreStateDir(p.actorUID) durableDir := ateompath.DurableDirVolumeMountsDir(p.actorUID) @@ -133,6 +133,10 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, templateNS, templateName := p.templateNS, p.templateName rr := s.resolveRuntime(p.assetPaths) + egress, err := s.prepareActorEgress(ctx, p.egressGatewayAddress, p.egressGatewayAudience) + if err != nil { + return err + } kata.CleanupSandboxState(ctx, actorUID) // Repoint the snapshot's vsock socket to this actor's VMDir (the disk + kernel @@ -209,8 +213,13 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, } defer func() { if retErr != nil { - if cleanupErr := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); cleanupErr != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Restore failure", slog.Any("err", cleanupErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if cleanupErr := s.deactivateActorNetworking(cleanupCtx); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Restore failure", slog.Any("err", cleanupErr)) + } + if cleanupErr := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Restore failure", slog.Any("err", cleanupErr)) } // Detach any bundle rootfs overlays mounted by buildActorContainers // before the failure, mirroring teardownActor's cleanup. @@ -298,7 +307,7 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, } } - if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, p.actorVersion, p.egressGatewayAddress); err != nil { + if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, egress); err != nil { return err } s.running[actorUID] = ra diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index ae691a2ca..ae6d36703 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -212,8 +212,8 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - actorVersion: req.GetActorVersion(), - egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGatewayAddress: req.GetEgressGatewayAddress(), + egressGatewayAudience: req.GetEgressGatewayAudience(), } s.actorLogger.EmitLifecycleLog("Actor starting", p.actorRef, p.actorUID, p.templateNS, p.templateName) @@ -235,12 +235,10 @@ type actorBootParams struct { templateName string containers []*ateompb.Container assetPaths map[string]string - // actorVersion is the Actor resource version ate-api observed when it - // assigned this worker; atunnel asserts it to the egress gateway. - actorVersion int64 // egressGatewayAddress is empty unless an egress gateway is configured, in // which case actor TCP egress is redirected to atunnel's local listener. - egressGatewayAddress string + egressGatewayAddress string + egressGatewayAudience string } // coldBootAttempts is how many times a cold boot is tried when the micro-VM @@ -298,6 +296,10 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re return fmt.Errorf("ateom-microvm requires %q and %q asset paths", assetKernel, assetImage) } rr := s.resolveRuntime(paths) + egress, err := s.prepareActorEgress(ctx, p.egressGatewayAddress, p.egressGatewayAudience) + if err != nil { + return err + } // Networking (host side): per-activation veth into the interior netns. The // tap + TC mirror is built below (after the VM exists) so its FDs are fresh. @@ -311,8 +313,13 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re } defer func() { if retErr != nil { - if cleanupErr := ateomnet.CleanupActorNetwork(ctx, s.interiorNetNS); cleanupErr != nil { - slog.WarnContext(ctx, "Failed to clean up actor network after Run failure", slog.Any("err", cleanupErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + if cleanupErr := s.deactivateActorNetworking(cleanupCtx); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to deactivate actor networking after Run failure", slog.Any("err", cleanupErr)) + } + if cleanupErr := ateomnet.CleanupActorNetwork(cleanupCtx, s.interiorNetNS); cleanupErr != nil { + slog.WarnContext(cleanupCtx, "Failed to clean up actor network after Run failure", slog.Any("err", cleanupErr)) } // Detach any bundle rootfs overlays mounted by buildActorContainers // before the failure, mirroring teardownActor's cleanup. @@ -459,7 +466,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re } ra := &runningActor{chCmd: chCmd, vfsdCmd: vfsdCmd, durableVfsdCmd: durableVfsdCmd, apiSocket: apiSocket, baseID: actorUID, logAgent: ac} - if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, p.actorVersion, p.egressGatewayAddress); err != nil { + if err := s.activateActorNetworking(p.actorRef.Atespace, p.actorRef.Name, egress); err != nil { return err } s.running[actorUID] = ra diff --git a/internal/ateompath/ateompath.go b/internal/ateompath/ateompath.go index a6f9deb8f..5f9f5523d 100644 --- a/internal/ateompath/ateompath.go +++ b/internal/ateompath/ateompath.go @@ -33,7 +33,8 @@ var ( // internal/imagecache). It lives under BasePath so the cached layer // directories are visible at the same path in atelet (which writes them) // and in every ateom pod (which mounts them as overlay lowerdirs). - ImageCacheDir = filepath.Join(BasePath, "image-cache") + ImageCacheDir = filepath.Join(BasePath, "image-cache") + CredentialBrokerSocket = filepath.Join(BasePath, "credential-broker.sock") ) func RunSCBinaryPath(sha256 string) string { diff --git a/internal/atunnel/client.go b/internal/atunnel/client.go index 1a7817a5e..ecfe06366 100644 --- a/internal/atunnel/client.go +++ b/internal/atunnel/client.go @@ -27,20 +27,6 @@ import ( "os" "strconv" "strings" - - "github.com/agent-substrate/substrate/internal/resources" -) - -const ( - // ActorAtespaceHeader identifies the atespace whose actor opened an egress - // tunnel. The egress gateway must authenticate this metadata before using it - // for policy decisions. - ActorAtespaceHeader = "X-Ate-Atespace" - // ActorNameHeader identifies the actor that opened an egress tunnel. - ActorNameHeader = "X-Ate-Actor-Name" - // ActorVersionHeader is the Actor resource version observed when the worker - // was assigned. Gateways use it as a lower bound on cached Actor metadata. - ActorVersionHeader = "X-Ate-Actor-Version" ) // TODO(liorlieberman): support/use CONNECT on Ingress as well. @@ -69,15 +55,6 @@ func WithDialer(dial DialFunc) ClientOption { } } -// EgressMetadata is attached to an egress CONNECT request. BearerToken is -// optional until actor JWT issuance is wired into ateom. -type EgressMetadata struct { - Atespace string - ActorName string - ActorVersion int64 - BearerToken string -} - // Client opens actor egress streams through an mTLS-authenticated gateway. type Client struct { gatewayAddress string @@ -135,15 +112,12 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { // DialContext opens a CONNECT tunnel to destination. destination becomes the // request authority, so it must include an explicit port. -func (c *Client) DialContext(ctx context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { +func (c *Client) DialContext(ctx context.Context, destination, bearerToken string) (net.Conn, error) { if err := validateDestination(destination); err != nil { return nil, err } - if !resources.IsValidResourceName(metadata.Atespace) || !resources.IsValidResourceName(metadata.ActorName) { - return nil, fmt.Errorf("atunnel: invalid actor identity %q/%q", metadata.Atespace, metadata.ActorName) - } - if metadata.ActorVersion < 1 { - return nil, fmt.Errorf("atunnel: actor version must be positive") + if bearerToken == "" { + return nil, fmt.Errorf("atunnel: actor bearer token is required") } rawConn, err := c.dialContext(ctx, "tcp", c.gatewayAddress) @@ -160,14 +134,7 @@ func (c *Client) DialContext(ctx context.Context, destination string, metadata E Method: http.MethodConnect, URL: &url.URL{Host: destination}, Host: destination, - Header: http.Header{ - ActorAtespaceHeader: []string{metadata.Atespace}, - ActorNameHeader: []string{metadata.ActorName}, - ActorVersionHeader: []string{strconv.FormatInt(metadata.ActorVersion, 10)}, - }, - } - if metadata.BearerToken != "" { - req.Header.Set("Authorization", "Bearer "+metadata.BearerToken) + Header: http.Header{"Authorization": []string{"Bearer " + bearerToken}}, } if err := req.Write(tlsConn); err != nil { _ = tlsConn.Close() diff --git a/internal/atunnel/client_test.go b/internal/atunnel/client_test.go index 9d74884d4..f54370fd3 100644 --- a/internal/atunnel/client_test.go +++ b/internal/atunnel/client_test.go @@ -55,12 +55,7 @@ func TestClientDialContext(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - conn, err := client.DialContext(context.Background(), "192.0.2.10:443", EgressMetadata{ - Atespace: "team-a", - ActorName: "actor-1", - ActorVersion: 7, - BearerToken: "actor-token", - }) + conn, err := client.DialContext(context.Background(), "192.0.2.10:443", "actor-token") if err != nil { t.Fatal(err) } @@ -73,14 +68,10 @@ func TestClientDialContext(t *testing.T) { if gotRequest.Host != "192.0.2.10:443" { t.Errorf("authority = %q, want 192.0.2.10:443", gotRequest.Host) } - if got := gotRequest.Header.Get(ActorAtespaceHeader); got != "team-a" { - t.Errorf("%s = %q, want team-a", ActorAtespaceHeader, got) - } - if got := gotRequest.Header.Get(ActorNameHeader); got != "actor-1" { - t.Errorf("%s = %q, want actor-1", ActorNameHeader, got) - } - if got := gotRequest.Header.Get(ActorVersionHeader); got != "7" { - t.Errorf("%s = %q, want 7", ActorVersionHeader, got) + for name := range gotRequest.Header { + if strings.HasPrefix(strings.ToLower(name), "x-ate-") { + t.Errorf("legacy identity header %q was sent", name) + } } if got := gotRequest.Header.Get("Authorization"); got != "Bearer actor-token" { t.Errorf("Authorization = %q, want Bearer actor-token", got) @@ -106,11 +97,7 @@ func TestClientDialContextRejected(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - _, err := client.DialContext(context.Background(), "192.0.2.10:443", EgressMetadata{ - Atespace: "team-a", - ActorName: "actor-1", - ActorVersion: 7, - }) + _, err := client.DialContext(context.Background(), "192.0.2.10:443", "actor-token") if err == nil || !strings.Contains(err.Error(), "denied by policy") { t.Fatalf("DialContext error = %v, want policy rejection", err) } @@ -122,37 +109,26 @@ func TestClientDialContextValidatesInput(t *testing.T) { tests := []struct { name string destination string - metadata EgressMetadata + bearerToken string }{ { name: "destination has no port", destination: "192.0.2.10", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7}, + bearerToken: "actor-token", }, { name: "destination is a hostname", destination: "example.com:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7}, - }, - { - name: "invalid atespace", - destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "TEAM A", ActorName: "actor-1", ActorVersion: 7}, - }, - { - name: "invalid actor", - destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor/1", ActorVersion: 7}, + bearerToken: "actor-token", }, { - name: "invalid actor version", + name: "missing bearer token", destination: "192.0.2.10:443", - metadata: EgressMetadata{Atespace: "team-a", ActorName: "actor-1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if _, err := client.DialContext(context.Background(), tt.destination, tt.metadata); err == nil { + if _, err := client.DialContext(context.Background(), tt.destination, tt.bearerToken); err == nil { t.Fatal("DialContext unexpectedly succeeded") } }) diff --git a/internal/atunnel/credential.go b/internal/atunnel/credential.go new file mode 100644 index 000000000..c7aca0973 --- /dev/null +++ b/internal/atunnel/credential.go @@ -0,0 +1,130 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package atunnel + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/url" + "os" + "path" + "time" + + "github.com/agent-substrate/substrate/internal/credbundle" + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// ActorJWT is a short-lived credential for one actor assignment. +type ActorJWT struct { + Token string + ExpiresAt time.Time +} + +// BrokerJWTSource obtains actor credentials from the node-local atelet broker. +type BrokerJWTSource struct { + socketPath string + audience string + tlsConfig *tls.Config +} + +// BrokerConfig configures the node-local atelet credential broker client. +type BrokerConfig struct { + SocketPath string + CredentialBundlePath string + TrustBundlePath string + Audience string +} + +// NewBrokerJWTSource returns a source that mutually authenticates with atelet +// over its Unix socket and requests JWTs for Audience. +func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { + if cfg.SocketPath == "" || cfg.CredentialBundlePath == "" || cfg.TrustBundlePath == "" || cfg.Audience == "" { + return nil, fmt.Errorf("atunnel: credential broker socket, credentials, trust bundle, and audience are required") + } + localCert, err := credbundle.Parse(cfg.CredentialBundlePath) + if err != nil { + return nil, fmt.Errorf("atunnel: load worker identity: %w", err) + } + localIdentity, err := substratex509.PodIdentityFromCertificate(localCert.Leaf) + if err != nil || localIdentity == nil { + return nil, fmt.Errorf("atunnel: worker certificate has no valid Pod identity") + } + trustPEM, err := os.ReadFile(cfg.TrustBundlePath) + if err != nil { + return nil, fmt.Errorf("atunnel: read credential broker trust bundle: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(trustPEM) { + return nil, fmt.Errorf("atunnel: credential broker trust bundle contains no certificates") + } + expectedURI := (&url.URL{Scheme: "spiffe", Host: "cluster.local", Path: path.Join("ns", "ate-system", "sa", "atelet")}).String() + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + InsecureSkipVerify: true, // Verification below supports SPIFFE Pod certificates without a DNS name. + GetClientCertificate: credbundle.ClientLoader(cfg.CredentialBundlePath), + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return fmt.Errorf("credential broker certificate is required") + } + intermediates := x509.NewCertPool() + for _, cert := range state.PeerCertificates[1:] { + intermediates.AddCert(cert) + } + if _, err := state.PeerCertificates[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: intermediates, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil { + return fmt.Errorf("verify credential broker certificate: %w", err) + } + leaf := state.PeerCertificates[0] + if len(leaf.URIs) != 1 || leaf.URIs[0].String() != expectedURI { + return fmt.Errorf("credential broker is not atelet") + } + identity, err := substratex509.PodIdentityFromCertificate(leaf) + if err != nil || identity == nil || identity.NodeName != localIdentity.NodeName || identity.NodeUID != localIdentity.NodeUID { + return fmt.Errorf("credential broker is not on worker node %q (%s)", localIdentity.NodeName, localIdentity.NodeUID) + } + return nil + }, + } + + return &BrokerJWTSource{socketPath: cfg.SocketPath, audience: cfg.Audience, tlsConfig: tlsConfig}, nil +} + +// Mint requests a fresh actor JWT from atelet. +func (s *BrokerJWTSource) Mint(ctx context.Context) (ActorJWT, error) { + conn, err := grpc.NewClient("passthrough:///credential-broker", + grpc.WithTransportCredentials(credentials.NewTLS(s.tlsConfig)), + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", s.socketPath) + }), + ) + if err != nil { + return ActorJWT{}, err + } + defer conn.Close() + resp, err := ateletpb.NewCredentialBrokerClient(conn).MintActorJWT(ctx, &ateletpb.MintActorJWTRequest{Audience: s.audience}) + if err != nil { + return ActorJWT{}, fmt.Errorf("atunnel: mint actor JWT: %w", err) + } + expiresAt := resp.GetExpirationTime().AsTime() + if resp.GetActorJwt() == "" || !expiresAt.After(time.Now()) { + return ActorJWT{}, fmt.Errorf("atunnel: credential broker returned an invalid actor JWT") + } + return ActorJWT{Token: resp.GetActorJwt(), ExpiresAt: expiresAt}, nil +} diff --git a/internal/atunnel/credential_test.go b/internal/atunnel/credential_test.go new file mode 100644 index 000000000..244bd3dfa --- /dev/null +++ b/internal/atunnel/credential_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package atunnel + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/agent-substrate/substrate/internal/proto/ateletpb" + "github.com/agent-substrate/substrate/internal/substratex509" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestBrokerJWTSourceMint(t *testing.T) { + expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) + source, broker := newTestBrokerJWTSource(t, testAteletIdentity("node-a"), &ateletpb.MintActorJWTResponse{ + ActorJwt: "actor-token", + ExpirationTime: timestamppb.New(expiresAt), + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + got, err := source.Mint(ctx) + if err != nil { + t.Fatal(err) + } + if got.Token != "actor-token" || !got.ExpiresAt.Equal(expiresAt) { + t.Errorf("Mint() = %+v, want actor-token expiring at %v", got, expiresAt) + } + select { + case req := <-broker.requests: + if req.GetAudience() != "egress.test" { + t.Errorf("audience = %q, want egress.test", req.GetAudience()) + } + case <-ctx.Done(): + t.Fatal("credential broker received no request") + } +} + +func TestBrokerJWTSourceRejectsAteletOnDifferentNode(t *testing.T) { + source, _ := newTestBrokerJWTSource(t, testAteletIdentity("node-b"), &ateletpb.MintActorJWTResponse{ + ActorJwt: "actor-token", + ExpirationTime: timestamppb.New(time.Now().Add(time.Hour)), + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := source.Mint(ctx) + if err == nil || !strings.Contains(err.Error(), "not on worker node") { + t.Fatalf("Mint() error = %v, want node identity rejection", err) + } +} + +func TestBrokerJWTSourceRejectsInvalidJWT(t *testing.T) { + source, _ := newTestBrokerJWTSource(t, testAteletIdentity("node-a"), &ateletpb.MintActorJWTResponse{ + ExpirationTime: timestamppb.New(time.Now().Add(time.Hour)), + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, err := source.Mint(ctx) + if err == nil || !strings.Contains(err.Error(), "invalid actor JWT") { + t.Fatalf("Mint() error = %v, want invalid JWT rejection", err) + } +} + +type credentialBrokerStub struct { + ateletpb.UnimplementedCredentialBrokerServer + response *ateletpb.MintActorJWTResponse + requests chan *ateletpb.MintActorJWTRequest +} + +func (s *credentialBrokerStub) MintActorJWT(_ context.Context, req *ateletpb.MintActorJWTRequest) (*ateletpb.MintActorJWTResponse, error) { + s.requests <- req + return s.response, nil +} + +func newTestBrokerJWTSource(t *testing.T, ateletIdentity *substratex509.PodIdentity, response *ateletpb.MintActorJWTResponse) (*BrokerJWTSource, *credentialBrokerStub) { + t.Helper() + ca := newTestCA(t) + workerCert := issueTestPodCertificate(t, ca, &substratex509.PodIdentity{ + Namespace: "ate-demo", + ServiceAccountName: "ateom", + ServiceAccountUID: "ateom-sa-uid", + PodName: "worker", + PodUID: "worker-uid", + NodeName: "node-a", + NodeUID: "node-uid", + }, "spiffe://cluster.local/ns/ate-demo/sa/ateom", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}) + ateletCert := issueTestPodCertificate(t, ca, ateletIdentity, + "spiffe://cluster.local/ns/ate-system/sa/atelet", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}) + + dir := t.TempDir() + credentialPath := filepath.Join(dir, "worker.pem") + trustPath := filepath.Join(dir, "trust.pem") + writeCredentialBundle(t, credentialPath, workerCert) + if err := os.WriteFile(trustPath, ca.certPEM, 0o600); err != nil { + t.Fatal(err) + } + + clientCAs := x509.NewCertPool() + clientCAs.AppendCertsFromPEM(ca.certPEM) + socketPath := filepath.Join(dir, "credential-broker.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{ateletCert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + }))) + broker := &credentialBrokerStub{response: response, requests: make(chan *ateletpb.MintActorJWTRequest, 1)} + ateletpb.RegisterCredentialBrokerServer(server, broker) + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { + server.Stop() + _ = listener.Close() + }) + + source, err := NewBrokerJWTSource(BrokerConfig{ + SocketPath: socketPath, + CredentialBundlePath: credentialPath, + TrustBundlePath: trustPath, + Audience: "egress.test", + }) + if err != nil { + t.Fatal(err) + } + return source, broker +} + +func testAteletIdentity(nodeName string) *substratex509.PodIdentity { + return &substratex509.PodIdentity{ + Namespace: "ate-system", + ServiceAccountName: "atelet", + ServiceAccountUID: "atelet-sa-uid", + PodName: "atelet", + PodUID: "atelet-uid", + NodeName: nodeName, + NodeUID: "node-uid", + } +} + +func issueTestPodCertificate(t *testing.T, ca *testCA, identity *substratex509.PodIdentity, spiffeID string, usages []x509.ExtKeyUsage) tls.Certificate { + t.Helper() + cert := ca.issue(t, spiffeID, usages) + template, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if err := substratex509.AddPodIdentityToCertificate(identity, template); err != nil { + t.Fatal(err) + } + key, ok := cert.PrivateKey.(*ecdsa.PrivateKey) + if !ok { + t.Fatalf("private key has type %T", cert.PrivateKey) + } + der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + if err != nil { + t.Fatal(err) + } + cert.Certificate[0] = der + cert.Leaf, err = x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index 5b1d63b08..8d3acc996 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -22,13 +22,16 @@ import ( "log/slog" "net" "sync" - - "github.com/agent-substrate/substrate/internal/resources" + "time" ) // EgressDialer opens an authenticated tunnel to an original destination. type EgressDialer interface { - DialContext(context.Context, string, EgressMetadata) (net.Conn, error) + DialContext(context.Context, string, string) (net.Conn, error) +} + +type actorJWTSource interface { + Mint(context.Context) (ActorJWT, error) } // OriginalDestination returns the address that a transparently intercepted @@ -46,11 +49,12 @@ type Egress struct { } type egressActivation struct { - metadata EgressMetadata - dialer EgressDialer - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup + dialer EgressDialer + jwtSource actorJWTSource + jwt ActorJWT + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } // NewEgress creates an activation-aware egress proxy. @@ -88,38 +92,85 @@ func (e *Egress) Serve(ctx context.Context, listener net.Listener) error { } } -// Activate allows egress for one actor. There can be only one active actor per -// worker. bearerToken may be empty until actor JWT issuance is available. -func (e *Egress) Activate(dialer EgressDialer, atespace, actorName string, actorVersion int64, bearerToken string) error { +// Activate allows egress with a previously obtained actor JWT and renews it until deactivation. +func (e *Egress) Activate(dialer EgressDialer, jwtSource actorJWTSource, jwt ActorJWT) error { if dialer == nil { return fmt.Errorf("atunnel: egress dialer is required") } - if !resources.IsValidResourceName(atespace) || !resources.IsValidResourceName(actorName) { - return fmt.Errorf("atunnel: invalid actor identity %q/%q", atespace, actorName) + if jwtSource == nil { + return fmt.Errorf("atunnel: actor JWT source is required") } - if actorVersion < 1 { - return fmt.Errorf("atunnel: actor version must be positive") + if jwt.Token == "" || !jwt.ExpiresAt.After(time.Now()) { + return fmt.Errorf("atunnel: valid actor JWT is required") } e.mu.Lock() defer e.mu.Unlock() if e.active != nil { - return fmt.Errorf("atunnel: actor %s/%s already has active egress", e.active.metadata.Atespace, e.active.metadata.ActorName) - } - ctx, cancel := context.WithCancel(context.Background()) - e.active = &egressActivation{ - metadata: EgressMetadata{ - Atespace: atespace, - ActorName: actorName, - ActorVersion: actorVersion, - BearerToken: bearerToken, - }, - dialer: dialer, - ctx: ctx, - cancel: cancel, + return fmt.Errorf("atunnel: actor already has active egress") + } + activationCtx, cancel := context.WithCancel(context.Background()) + active := &egressActivation{ + dialer: dialer, + jwtSource: jwtSource, + jwt: jwt, + ctx: activationCtx, + cancel: cancel, } + e.active = active + active.wg.Add(1) + go e.renew(active, jwt.ExpiresAt) return nil } +func (e *Egress) renew(active *egressActivation, expiresAt time.Time) { + defer active.wg.Done() + delay := renewAfter(expiresAt) + for waitForRenewal(active.ctx, delay) { + next, err := active.jwtSource.Mint(active.ctx) + if err != nil { + delay = retryAfter(expiresAt) + continue + } + if next.Token == "" || !next.ExpiresAt.After(time.Now()) { + delay = retryAfter(expiresAt) + continue + } + e.mu.Lock() + if active.ctx.Err() != nil { + e.mu.Unlock() + return + } + active.jwt = next + e.mu.Unlock() + expiresAt = next.ExpiresAt + delay = renewAfter(expiresAt) + } +} + +func renewAfter(expiresAt time.Time) time.Duration { + remaining := time.Until(expiresAt) + return remaining - remaining/10 +} + +func retryAfter(expiresAt time.Time) time.Duration { + remaining := time.Until(expiresAt) + return min(30*time.Second, max(time.Second, remaining/10), remaining) +} + +func waitForRenewal(ctx context.Context, delay time.Duration) bool { + if delay <= 0 { + return false + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + // Deactivate rejects new egress, closes active streams, and waits for their // forwarding goroutines to exit. func (e *Egress) Deactivate(ctx context.Context) error { @@ -127,6 +178,7 @@ func (e *Egress) Deactivate(ctx context.Context) error { active := e.active e.active = nil if active != nil { + active.jwt = ActorJWT{} active.cancel() } e.mu.Unlock() @@ -155,6 +207,12 @@ func (e *Egress) handle(downstream net.Conn) { _ = downstream.Close() return } + if !time.Now().Before(active.jwt.ExpiresAt) { + e.mu.Unlock() + _ = downstream.Close() + return + } + bearerToken := active.jwt.Token active.wg.Add(1) e.mu.Unlock() @@ -167,7 +225,7 @@ func (e *Egress) handle(downstream net.Conn) { slog.WarnContext(active.ctx, "atunnel failed to resolve original egress destination", slog.Any("err", err)) return } - upstream, err := active.dialer.DialContext(active.ctx, destination, active.metadata) + upstream, err := active.dialer.DialContext(active.ctx, destination, bearerToken) if err != nil { slog.WarnContext(active.ctx, "atunnel failed to open egress tunnel", slog.String("destination", destination), slog.Any("err", err)) return diff --git a/internal/atunnel/egress_test.go b/internal/atunnel/egress_test.go index 17828fbd5..e3d6e2e4f 100644 --- a/internal/atunnel/egress_test.go +++ b/internal/atunnel/egress_test.go @@ -16,30 +16,218 @@ package atunnel import ( "context" + "crypto/tls" + "errors" "io" "net" + "net/http" + "strings" + "sync/atomic" "testing" "time" ) -func TestEgressForwardsActiveActor(t *testing.T) { - dialed := make(chan egressDial, 1) - upstreamProxy, upstreamGateway := net.Pipe() - t.Cleanup(func() { - _ = upstreamProxy.Close() - _ = upstreamGateway.Close() +func TestEgressActivationFailsClosed(t *testing.T) { + egress, err := NewEgress(func(net.Conn) (string, error) { return "", nil }) + if err != nil { + t.Fatal(err) + } + dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { + t.Fatal("dialed after failed activation") + return nil, nil }) - dialer := egressDialerFunc(func(_ context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { - dialed <- egressDial{destination: destination, metadata: metadata} + if err := egress.Activate(dialer, fakeActorJWTSource{err: errors.New("renewal failed")}, ActorJWT{}); err == nil { + t.Fatal("Activate() succeeded") + } + actor, proxy := net.Pipe() + defer actor.Close() + if err := actor.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + egress.handle(proxy) + if _, err := actor.Read(make([]byte, 1)); err == nil { + t.Fatal("failed activation admitted egress") + } +} + +func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { + upstreamProxy, upstreamGateway := net.Pipe() + defer upstreamGateway.Close() + var dials atomic.Int32 + var mints atomic.Int32 + dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { + dials.Add(1) return upstreamProxy, nil }) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) + if err != nil { + t.Fatal(err) + } + if err := egress.Activate(dialer, fakeActorJWTSource{err: errors.New("renewal failed"), calls: &mints}, ActorJWT{Token: "short-lived", ExpiresAt: time.Now().Add(50 * time.Millisecond)}); err != nil { + t.Fatal(err) + } + actor, proxy := net.Pipe() + defer actor.Close() + egress.handle(proxy) + deadline := time.Now().Add(time.Second) + for dials.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if dials.Load() != 1 { + t.Fatalf("dials = %d, want 1", dials.Load()) + } + time.Sleep(100 * time.Millisecond) + go func() { _, _ = actor.Write([]byte("still-open")) }() + buf := make([]byte, len("still-open")) + if _, err := io.ReadFull(upstreamGateway, buf); err != nil { + t.Fatalf("established tunnel closed after JWT expiry: %v", err) + } + + newActor, newProxy := net.Pipe() + defer newActor.Close() + if err := newActor.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + egress.handle(newProxy) + if _, err := newActor.Read(make([]byte, 1)); err == nil { + t.Fatal("new tunnel admitted after JWT expiry") + } + if dials.Load() != 1 { + t.Fatalf("dials = %d after expiry, want 1", dials.Load()) + } + if got := mints.Load(); got > 3 { + t.Fatalf("mint attempts = %d, retry loop spun near expiry", got) + } + _ = egress.Deactivate(context.Background()) +} + +func TestEgressRenewsBeforeExpiry(t *testing.T) { + var mints atomic.Int32 + renewed := make(chan struct{}, 1) + source := fakeActorJWTSource{ + jwt: ActorJWT{Token: "renewed", ExpiresAt: time.Now().Add(time.Hour)}, + calls: &mints, + called: renewed, + } + got := make(chan string, 1) + upstream, gateway := net.Pipe() + defer gateway.Close() + dialer := egressDialerFunc(func(_ context.Context, _ string, bearerToken string) (net.Conn, error) { + got <- bearerToken + return upstream, nil + }) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) + if err != nil { + t.Fatal(err) + } + if err := egress.Activate(dialer, source, ActorJWT{Token: "initial", ExpiresAt: time.Now().Add(80 * time.Millisecond)}); err != nil { + t.Fatal(err) + } + select { + case <-renewed: + case <-time.After(time.Second): + t.Fatal("JWT was not renewed") + } + deadline := time.Now().Add(time.Second) + for { + egress.mu.Lock() + token := egress.active.jwt.Token + egress.mu.Unlock() + if token == "renewed" { + break + } + if time.Now().After(deadline) { + t.Fatal("renewed JWT was not installed") + } + time.Sleep(time.Millisecond) + } + actor, proxy := net.Pipe() + defer actor.Close() + egress.handle(proxy) + if bearerToken := <-got; bearerToken != "renewed" { + t.Fatalf("token = %q, want renewed", bearerToken) + } + _ = egress.Deactivate(context.Background()) +} + +func TestEgressDeactivationDropsConcurrentRenewal(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + egress, err := NewEgress(func(net.Conn) (string, error) { return "", nil }) + if err != nil { + t.Fatal(err) + } + dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { return nil, nil }) + if err := egress.Activate(dialer, fakeActorJWTSource{ + jwt: ActorJWT{Token: "renewed", ExpiresAt: time.Now().Add(time.Hour)}, + called: started, + release: release, + }, ActorJWT{Token: "initial", ExpiresAt: time.Now().Add(50 * time.Millisecond)}); err != nil { + t.Fatal(err) + } + egress.mu.Lock() + active := egress.active + egress.mu.Unlock() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("JWT renewal did not start") + } + done := make(chan error, 1) + go func() { done <- egress.Deactivate(context.Background()) }() + <-active.ctx.Done() + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if active.jwt != (ActorJWT{}) { + t.Fatalf("deactivated JWT = %+v, want empty", active.jwt) + } +} + +func TestEgressEndToEnd(t *testing.T) { + ca := newTestCA(t) + requests := make(chan *http.Request, 1) + gatewayDone := make(chan struct{}) + gatewayAddress := serveTestConnectGateway(t, ca, func(conn net.Conn, req *http.Request) { + defer close(gatewayDone) + requests <- req + + tlsConn, ok := conn.(*tls.Conn) + if !ok { + t.Errorf("gateway connection has type %T, want *tls.Conn", conn) + } else { + peer := tlsConn.ConnectionState().PeerCertificates[0] + if len(peer.URIs) != 1 || peer.URIs[0].String() != "spiffe://cluster.local/ns/ate-demo/sa/ateom" { + t.Errorf("client identity = %v, want ateom SPIFFE ID", peer.URIs) + } + } + + if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + t.Errorf("writing CONNECT response: %v", err) + return + } + payload := make([]byte, len("from actor")) + if _, err := io.ReadFull(conn, payload); err != nil { + t.Errorf("reading actor payload: %v", err) + return + } + if string(payload) != "from actor" { + t.Errorf("gateway payload = %q, want %q", payload, "from actor") + } + if _, err := io.WriteString(conn, "from gateway"); err != nil { + t.Errorf("writing gateway payload: %v", err) + } + }) + client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) + egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) if err != nil { t.Fatal(err) } - if err := egress.Activate(dialer, "team-a", "actor-1", 7, "actor-token"); err != nil { + if err := egress.Activate(client, fakeActorJWTSource{jwt: ActorJWT{Token: "actor-token", ExpiresAt: time.Now().Add(time.Hour)}}, ActorJWT{Token: "actor-token", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { t.Fatal(err) } @@ -50,33 +238,33 @@ func TestEgressForwardsActiveActor(t *testing.T) { }) egress.handle(downstreamProxy) - gotDial := <-dialed - if gotDial.destination != "192.0.2.10:443" { - t.Errorf("destination = %q, want 192.0.2.10:443", gotDial.destination) + req := <-requests + if req.Method != http.MethodConnect || req.Host != "192.0.2.10:443" { + t.Errorf("request = %s %s, want CONNECT 192.0.2.10:443", req.Method, req.Host) + } + if got := req.Header.Get("Authorization"); got != "Bearer actor-token" { + t.Errorf("Authorization = %q, want Bearer actor-token", got) } - if gotDial.metadata != (EgressMetadata{Atespace: "team-a", ActorName: "actor-1", ActorVersion: 7, BearerToken: "actor-token"}) { - t.Errorf("metadata = %+v", gotDial.metadata) + for name := range req.Header { + if strings.HasPrefix(strings.ToLower(name), "x-ate-") { + t.Errorf("legacy identity header %q was sent", name) + } } - actorPayload := []byte("from actor") - go func() { _, _ = downstreamActor.Write(actorPayload) }() - gotAtGateway := make([]byte, len(actorPayload)) - if _, err := io.ReadFull(upstreamGateway, gotAtGateway); err != nil { + if err := downstreamActor.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } - if string(gotAtGateway) != string(actorPayload) { - t.Errorf("gateway payload = %q, want %q", gotAtGateway, actorPayload) + if _, err := io.WriteString(downstreamActor, "from actor"); err != nil { + t.Fatal(err) } - - gatewayPayload := []byte("from gateway") - go func() { _, _ = upstreamGateway.Write(gatewayPayload) }() - gotAtActor := make([]byte, len(gatewayPayload)) + gotAtActor := make([]byte, len("from gateway")) if _, err := io.ReadFull(downstreamActor, gotAtActor); err != nil { t.Fatal(err) } - if string(gotAtActor) != string(gatewayPayload) { - t.Errorf("actor payload = %q, want %q", gotAtActor, gatewayPayload) + if string(gotAtActor) != "from gateway" { + t.Errorf("actor payload = %q, want %q", gotAtActor, "from gateway") } + <-gatewayDone if err := egress.Deactivate(context.Background()); err != nil { t.Fatal(err) @@ -102,13 +290,32 @@ func TestEgressRejectsInactiveConnection(t *testing.T) { } } -type egressDial struct { - destination string - metadata EgressMetadata +type egressDialerFunc func(context.Context, string, string) (net.Conn, error) + +func (f egressDialerFunc) DialContext(ctx context.Context, destination, bearerToken string) (net.Conn, error) { + return f(ctx, destination, bearerToken) } -type egressDialerFunc func(context.Context, string, EgressMetadata) (net.Conn, error) +type fakeActorJWTSource struct { + jwt ActorJWT + err error + calls *atomic.Int32 + called chan<- struct{} + release <-chan struct{} +} -func (f egressDialerFunc) DialContext(ctx context.Context, destination string, metadata EgressMetadata) (net.Conn, error) { - return f(ctx, destination, metadata) +func (s fakeActorJWTSource) Mint(context.Context) (ActorJWT, error) { + if s.calls != nil { + s.calls.Add(1) + } + if s.called != nil { + select { + case s.called <- struct{}{}: + default: + } + } + if s.release != nil { + <-s.release + } + return s.jwt, s.err } diff --git a/internal/atunnel/server.go b/internal/atunnel/ingress.go similarity index 100% rename from internal/atunnel/server.go rename to internal/atunnel/ingress.go diff --git a/internal/atunnel/server_test.go b/internal/atunnel/ingress_test.go similarity index 100% rename from internal/atunnel/server_test.go rename to internal/atunnel/ingress_test.go diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 021fc8dad..7385e1c44 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -23,6 +23,7 @@ package ateletpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -200,6 +201,102 @@ func (SnapshotScope) EnumDescriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{2} } +type MintActorJWTRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Audience string `protobuf:"bytes,1,opt,name=audience,proto3" json:"audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MintActorJWTRequest) Reset() { + *x = MintActorJWTRequest{} + mi := &file_atelet_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MintActorJWTRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MintActorJWTRequest) ProtoMessage() {} + +func (x *MintActorJWTRequest) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MintActorJWTRequest.ProtoReflect.Descriptor instead. +func (*MintActorJWTRequest) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{0} +} + +func (x *MintActorJWTRequest) GetAudience() string { + if x != nil { + return x.Audience + } + return "" +} + +type MintActorJWTResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MintActorJWTResponse) Reset() { + *x = MintActorJWTResponse{} + mi := &file_atelet_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MintActorJWTResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MintActorJWTResponse) ProtoMessage() {} + +func (x *MintActorJWTResponse) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MintActorJWTResponse.ProtoReflect.Descriptor instead. +func (*MintActorJWTResponse) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{1} +} + +func (x *MintActorJWTResponse) GetActorJwt() string { + if x != nil { + return x.ActorJwt + } + return "" +} + +func (x *MintActorJWTResponse) GetExpirationTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpirationTime + } + return nil +} + type RunRequest struct { state protoimpl.MessageState `protogen:"open.v1"` TargetAteomUid string `protobuf:"bytes,1,opt,name=target_ateom_uid,json=targetAteomUid,proto3" json:"target_ateom_uid,omitempty"` @@ -213,13 +310,17 @@ type RunRequest struct { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets *SandboxAssets `protobuf:"bytes,8,opt,name=sandbox_assets,json=sandboxAssets,proto3" json:"sandbox_assets,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Configured together to enable tunneled egress for this activation. + EgressGatewayAddress *string `protobuf:"bytes,9,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` + // Logical PEP audience placed in the actor JWT, not a network address. + EgressGatewayAudience *string `protobuf:"bytes,10,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { *x = RunRequest{} - mi := &file_atelet_proto_msgTypes[0] + mi := &file_atelet_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -231,7 +332,7 @@ func (x *RunRequest) String() string { func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[0] + mi := &file_atelet_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -244,7 +345,7 @@ func (x *RunRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. func (*RunRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{0} + return file_atelet_proto_rawDescGZIP(), []int{2} } func (x *RunRequest) GetTargetAteomUid() string { @@ -303,6 +404,20 @@ func (x *RunRequest) GetSandboxAssets() *SandboxAssets { return nil } +func (x *RunRequest) GetEgressGatewayAddress() string { + if x != nil && x.EgressGatewayAddress != nil { + return *x.EgressGatewayAddress + } + return "" +} + +func (x *RunRequest) GetEgressGatewayAudience() string { + if x != nil && x.EgressGatewayAudience != nil { + return *x.EgressGatewayAudience + } + return "" +} + // AssetFile is one content-addressed file atelet fetches for a sandbox runtime // (e.g. the gVisor runsc binary). type AssetFile struct { @@ -317,7 +432,7 @@ type AssetFile struct { func (x *AssetFile) Reset() { *x = AssetFile{} - mi := &file_atelet_proto_msgTypes[1] + mi := &file_atelet_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -329,7 +444,7 @@ func (x *AssetFile) String() string { func (*AssetFile) ProtoMessage() {} func (x *AssetFile) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[1] + mi := &file_atelet_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -342,7 +457,7 @@ func (x *AssetFile) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetFile.ProtoReflect.Descriptor instead. func (*AssetFile) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{1} + return file_atelet_proto_rawDescGZIP(), []int{3} } func (x *AssetFile) GetUrl() string { @@ -370,7 +485,7 @@ type ArchAssets struct { func (x *ArchAssets) Reset() { *x = ArchAssets{} - mi := &file_atelet_proto_msgTypes[2] + mi := &file_atelet_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -382,7 +497,7 @@ func (x *ArchAssets) String() string { func (*ArchAssets) ProtoMessage() {} func (x *ArchAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[2] + mi := &file_atelet_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -395,7 +510,7 @@ func (x *ArchAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchAssets.ProtoReflect.Descriptor instead. func (*ArchAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{2} + return file_atelet_proto_rawDescGZIP(), []int{4} } func (x *ArchAssets) GetFiles() map[string]*AssetFile { @@ -419,7 +534,7 @@ type SandboxAssets struct { func (x *SandboxAssets) Reset() { *x = SandboxAssets{} - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -431,7 +546,7 @@ func (x *SandboxAssets) String() string { func (*SandboxAssets) ProtoMessage() {} func (x *SandboxAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -444,7 +559,7 @@ func (x *SandboxAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxAssets.ProtoReflect.Descriptor instead. func (*SandboxAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{3} + return file_atelet_proto_rawDescGZIP(), []int{5} } func (x *SandboxAssets) GetSandboxClass() string { @@ -473,7 +588,7 @@ type WorkloadSpec struct { func (x *WorkloadSpec) Reset() { *x = WorkloadSpec{} - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -485,7 +600,7 @@ func (x *WorkloadSpec) String() string { func (*WorkloadSpec) ProtoMessage() {} func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -498,7 +613,7 @@ func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadSpec.ProtoReflect.Descriptor instead. func (*WorkloadSpec) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{4} + return file_atelet_proto_rawDescGZIP(), []int{6} } func (x *WorkloadSpec) GetContainers() []*Container { @@ -530,7 +645,7 @@ type DurableDirVolume struct { func (x *DurableDirVolume) Reset() { *x = DurableDirVolume{} - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -542,7 +657,7 @@ func (x *DurableDirVolume) String() string { func (*DurableDirVolume) ProtoMessage() {} func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -555,7 +670,7 @@ func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use DurableDirVolume.ProtoReflect.Descriptor instead. func (*DurableDirVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{5} + return file_atelet_proto_rawDescGZIP(), []int{7} } type ExternalVolumeSource struct { @@ -568,7 +683,7 @@ type ExternalVolumeSource struct { func (x *ExternalVolumeSource) Reset() { *x = ExternalVolumeSource{} - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -580,7 +695,7 @@ func (x *ExternalVolumeSource) String() string { func (*ExternalVolumeSource) ProtoMessage() {} func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -593,7 +708,7 @@ func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalVolumeSource.ProtoReflect.Descriptor instead. func (*ExternalVolumeSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{6} + return file_atelet_proto_rawDescGZIP(), []int{8} } func (x *ExternalVolumeSource) GetStorageVolumeId() string { @@ -625,7 +740,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -637,7 +752,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -650,7 +765,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{7} + return file_atelet_proto_rawDescGZIP(), []int{9} } func (x *Volume) GetName() string { @@ -718,7 +833,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -730,7 +845,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -743,7 +858,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{8} + return file_atelet_proto_rawDescGZIP(), []int{10} } func (x *VolumeMount) GetName() string { @@ -775,7 +890,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -787,7 +902,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -800,7 +915,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{9} + return file_atelet_proto_rawDescGZIP(), []int{11} } func (x *Container) GetName() string { @@ -862,7 +977,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -874,7 +989,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -887,7 +1002,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{10} + return file_atelet_proto_rawDescGZIP(), []int{12} } func (x *EnvEntry) GetName() string { @@ -915,7 +1030,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -927,7 +1042,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -940,7 +1055,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{11} + return file_atelet_proto_rawDescGZIP(), []int{13} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -963,7 +1078,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -975,7 +1090,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -988,7 +1103,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{12} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *HTTPGetAction) GetPath() string { @@ -1013,7 +1128,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1140,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1153,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{15} } type LocalCheckpointConfiguration struct { @@ -1052,7 +1167,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1064,7 +1179,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1077,7 +1192,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{16} } func (x *LocalCheckpointConfiguration) GetSnapshotPrefix() string { @@ -1106,7 +1221,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1118,7 +1233,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1131,7 +1246,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *ExternalCheckpointConfiguration) GetSnapshotUriPrefix() string { @@ -1169,7 +1284,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1181,7 +1296,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1194,7 +1309,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1309,7 +1424,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1321,7 +1436,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1334,7 +1449,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{19} } type RestoreRequest struct { @@ -1366,13 +1481,17 @@ type RestoreRequest struct { // of the `config` oneof: the actor's snapshot may be local (a pause // checkpoint) while the golden snapshot is always external. GoldenSnapshotUriPrefix string `protobuf:"bytes,12,opt,name=golden_snapshot_uri_prefix,json=goldenSnapshotUriPrefix,proto3" json:"golden_snapshot_uri_prefix,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Configured together to enable tunneled egress for this activation. + EgressGatewayAddress *string `protobuf:"bytes,13,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` + // Logical PEP audience placed in the actor JWT, not a network address. + EgressGatewayAudience *string `protobuf:"bytes,14,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1384,7 +1503,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1397,7 +1516,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{20} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1495,6 +1614,20 @@ func (x *RestoreRequest) GetGoldenSnapshotUriPrefix() string { return "" } +func (x *RestoreRequest) GetEgressGatewayAddress() string { + if x != nil && x.EgressGatewayAddress != nil { + return *x.EgressGatewayAddress + } + return "" +} + +func (x *RestoreRequest) GetEgressGatewayAudience() string { + if x != nil && x.EgressGatewayAudience != nil { + return *x.EgressGatewayAudience + } + return "" +} + type isRestoreRequest_Config interface { isRestoreRequest_Config() } @@ -1519,7 +1652,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1531,7 +1664,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1544,14 +1677,19 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{21} } var File_atelet_proto protoreflect.FileDescriptor const file_atelet_proto_rawDesc = "" + "\n" + - "\fatelet.proto\x12\x06atelet\"\xe0\x02\n" + + "\fatelet.proto\x12\x06atelet\x1a\x1fgoogle/protobuf/timestamp.proto\"1\n" + + "\x13MintActorJWTRequest\x12\x1a\n" + + "\baudience\x18\x01 \x01(\tR\baudience\"x\n" + + "\x14MintActorJWTResponse\x12\x1b\n" + + "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\x12C\n" + + "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\x8f\x04\n" + "\n" + "RunRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + @@ -1562,7 +1700,12 @@ const file_atelet_proto_rawDesc = "" + "\x18actor_template_namespace\x18\x05 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x06 \x01(\tR\x11actorTemplateName\x12(\n" + "\x04spec\x18\a \x01(\v2\x14.atelet.WorkloadSpecR\x04spec\x12<\n" + - "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\"5\n" + + "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x129\n" + + "\x16egress_gateway_address\x18\t \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + + "\x17egress_gateway_audience\x18\n" + + " \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01B\x19\n" + + "\x17_egress_gateway_addressB\x1a\n" + + "\x18_egress_gateway_audience\"5\n" + "\tAssetFile\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x8e\x01\n" + @@ -1638,7 +1781,7 @@ const file_atelet_proto_rawDesc = "" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scopeB\b\n" + "\x06config\"\x14\n" + - "\x12CheckpointResponse\"\xe5\x04\n" + + "\x12CheckpointResponse\"\x94\x06\n" + "\x0eRestoreRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + @@ -1653,8 +1796,12 @@ const file_atelet_proto_rawDesc = "" + "\x0fexternal_config\x18\n" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scope\x12;\n" + - "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefixB\b\n" + - "\x06config\"\x11\n" + + "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefix\x129\n" + + "\x16egress_gateway_address\x18\r \x01(\tH\x01R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + + "\x17egress_gateway_audience\x18\x0e \x01(\tH\x02R\x15egressGatewayAudience\x88\x01\x01B\b\n" + + "\x06configB\x19\n" + + "\x17_egress_gateway_addressB\x1a\n" + + "\x18_egress_gateway_audience\"\x11\n" + "\x0fRestoreResponse*`\n" + "\n" + "VolumeType\x12\x1b\n" + @@ -1669,7 +1816,9 @@ const file_atelet_proto_rawDesc = "" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13SNAPSHOT_SCOPE_FULL\x10\x01\x12\x17\n" + "\x13SNAPSHOT_SCOPE_DATA\x10\x02\x12!\n" + - "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032\xc4\x01\n" + + "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032_\n" + + "\x10CredentialBroker\x12K\n" + + "\fMintActorJWT\x12\x1b.atelet.MintActorJWTRequest\x1a\x1c.atelet.MintActorJWTResponse\"\x002\xc4\x01\n" + "\vAteomHerder\x120\n" + "\x03Run\x12\x12.atelet.RunRequest\x1a\x13.atelet.RunResponse\"\x00\x12E\n" + "\n" + @@ -1689,71 +1838,77 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType (SnapshotScope)(0), // 2: atelet.SnapshotScope - (*RunRequest)(nil), // 3: atelet.RunRequest - (*AssetFile)(nil), // 4: atelet.AssetFile - (*ArchAssets)(nil), // 5: atelet.ArchAssets - (*SandboxAssets)(nil), // 6: atelet.SandboxAssets - (*WorkloadSpec)(nil), // 7: atelet.WorkloadSpec - (*DurableDirVolume)(nil), // 8: atelet.DurableDirVolume - (*ExternalVolumeSource)(nil), // 9: atelet.ExternalVolumeSource - (*Volume)(nil), // 10: atelet.Volume - (*VolumeMount)(nil), // 11: atelet.VolumeMount - (*Container)(nil), // 12: atelet.Container - (*EnvEntry)(nil), // 13: atelet.EnvEntry - (*Readyz)(nil), // 14: atelet.Readyz - (*HTTPGetAction)(nil), // 15: atelet.HTTPGetAction - (*RunResponse)(nil), // 16: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 17: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 18: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 19: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 20: atelet.CheckpointResponse - (*RestoreRequest)(nil), // 21: atelet.RestoreRequest - (*RestoreResponse)(nil), // 22: atelet.RestoreResponse - nil, // 23: atelet.ArchAssets.FilesEntry - nil, // 24: atelet.SandboxAssets.AssetsEntry + (*MintActorJWTRequest)(nil), // 3: atelet.MintActorJWTRequest + (*MintActorJWTResponse)(nil), // 4: atelet.MintActorJWTResponse + (*RunRequest)(nil), // 5: atelet.RunRequest + (*AssetFile)(nil), // 6: atelet.AssetFile + (*ArchAssets)(nil), // 7: atelet.ArchAssets + (*SandboxAssets)(nil), // 8: atelet.SandboxAssets + (*WorkloadSpec)(nil), // 9: atelet.WorkloadSpec + (*DurableDirVolume)(nil), // 10: atelet.DurableDirVolume + (*ExternalVolumeSource)(nil), // 11: atelet.ExternalVolumeSource + (*Volume)(nil), // 12: atelet.Volume + (*VolumeMount)(nil), // 13: atelet.VolumeMount + (*Container)(nil), // 14: atelet.Container + (*EnvEntry)(nil), // 15: atelet.EnvEntry + (*Readyz)(nil), // 16: atelet.Readyz + (*HTTPGetAction)(nil), // 17: atelet.HTTPGetAction + (*RunResponse)(nil), // 18: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 19: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 20: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 21: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 22: atelet.CheckpointResponse + (*RestoreRequest)(nil), // 23: atelet.RestoreRequest + (*RestoreResponse)(nil), // 24: atelet.RestoreResponse + nil, // 25: atelet.ArchAssets.FilesEntry + nil, // 26: atelet.SandboxAssets.AssetsEntry + (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp } var file_atelet_proto_depIdxs = []int32{ - 7, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec - 6, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets - 23, // 2: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 24, // 3: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 12, // 4: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 10, // 5: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 0, // 6: atelet.Volume.type:type_name -> atelet.VolumeType - 8, // 7: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 9, // 8: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 13, // 9: atelet.Container.env:type_name -> atelet.EnvEntry - 14, // 10: atelet.Container.readyz:type_name -> atelet.Readyz - 11, // 11: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 15, // 12: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 7, // 13: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 14: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 17, // 15: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 18, // 16: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 17: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 7, // 18: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 19: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 17, // 20: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 18, // 21: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 22: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 4, // 23: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 5, // 24: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 25: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 19, // 26: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 21, // 27: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 16, // 28: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 20, // 29: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 22, // 30: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 28, // [28:31] is the sub-list for method output_type - 25, // [25:28] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 27, // 0: atelet.MintActorJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp + 9, // 1: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec + 8, // 2: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets + 25, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 26, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 14, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 12, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 0, // 7: atelet.Volume.type:type_name -> atelet.VolumeType + 10, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 11, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 15, // 10: atelet.Container.env:type_name -> atelet.EnvEntry + 16, // 11: atelet.Container.readyz:type_name -> atelet.Readyz + 13, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 17, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 9, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 19, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 20, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 9, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 19, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 20, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 24: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 7, // 25: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 26: atelet.CredentialBroker.MintActorJWT:input_type -> atelet.MintActorJWTRequest + 5, // 27: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 21, // 28: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 23, // 29: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 4, // 30: atelet.CredentialBroker.MintActorJWT:output_type -> atelet.MintActorJWTResponse + 18, // 31: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 22, // 32: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 24, // 33: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 30, // [30:34] is the sub-list for method output_type + 26, // [26:30] is the sub-list for method input_type + 26, // [26:26] is the sub-list for extension type_name + 26, // [26:26] is the sub-list for extension extendee + 0, // [0:26] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -1761,15 +1916,16 @@ func file_atelet_proto_init() { if File_atelet_proto != nil { return } - file_atelet_proto_msgTypes[7].OneofWrappers = []any{ + file_atelet_proto_msgTypes[2].OneofWrappers = []any{} + file_atelet_proto_msgTypes[9].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), } - file_atelet_proto_msgTypes[16].OneofWrappers = []any{ + file_atelet_proto_msgTypes[18].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[18].OneofWrappers = []any{ + file_atelet_proto_msgTypes[20].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -1779,9 +1935,9 @@ func file_atelet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), NumEnums: 3, - NumMessages: 22, + NumMessages: 24, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_atelet_proto_goTypes, DependencyIndexes: file_atelet_proto_depIdxs, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index cee9e6a89..50ffcb20d 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -18,6 +18,22 @@ package atelet; option go_package = "github.com/agent-substrate/substrate/internal/proto/ateletpb"; +import "google/protobuf/timestamp.proto"; + +// CredentialBroker gives an authenticated worker its current actor credential. +service CredentialBroker { + rpc MintActorJWT(MintActorJWTRequest) returns (MintActorJWTResponse) {} +} + +message MintActorJWTRequest { + string audience = 1; +} + +message MintActorJWTResponse { + string actor_jwt = 1; + google.protobuf.Timestamp expiration_time = 2; +} + service AteomHerder { // Run tells atelet to create a new containerized workload from scratch on an // ateom. @@ -48,6 +64,11 @@ message RunRequest { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets sandbox_assets = 8; + + // Configured together to enable tunneled egress for this activation. + optional string egress_gateway_address = 9; + // Logical PEP audience placed in the actor JWT, not a network address. + optional string egress_gateway_audience = 10; } // AssetFile is one content-addressed file atelet fetches for a sandbox runtime @@ -254,6 +275,11 @@ message RestoreRequest { // of the `config` oneof: the actor's snapshot may be local (a pause // checkpoint) while the golden snapshot is always external. string golden_snapshot_uri_prefix = 12; + + // Configured together to enable tunneled egress for this activation. + optional string egress_gateway_address = 13; + // Logical PEP audience placed in the actor JWT, not a network address. + optional string egress_gateway_audience = 14; } message RestoreResponse { diff --git a/internal/proto/ateletpb/atelet_grpc.pb.go b/internal/proto/ateletpb/atelet_grpc.pb.go index 4f05a878d..961d3584f 100644 --- a/internal/proto/ateletpb/atelet_grpc.pb.go +++ b/internal/proto/ateletpb/atelet_grpc.pb.go @@ -32,6 +32,112 @@ import ( // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 +const ( + CredentialBroker_MintActorJWT_FullMethodName = "/atelet.CredentialBroker/MintActorJWT" +) + +// CredentialBrokerClient is the client API for CredentialBroker service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// CredentialBroker gives an authenticated worker its current actor credential. +type CredentialBrokerClient interface { + MintActorJWT(ctx context.Context, in *MintActorJWTRequest, opts ...grpc.CallOption) (*MintActorJWTResponse, error) +} + +type credentialBrokerClient struct { + cc grpc.ClientConnInterface +} + +func NewCredentialBrokerClient(cc grpc.ClientConnInterface) CredentialBrokerClient { + return &credentialBrokerClient{cc} +} + +func (c *credentialBrokerClient) MintActorJWT(ctx context.Context, in *MintActorJWTRequest, opts ...grpc.CallOption) (*MintActorJWTResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MintActorJWTResponse) + err := c.cc.Invoke(ctx, CredentialBroker_MintActorJWT_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CredentialBrokerServer is the server API for CredentialBroker service. +// All implementations must embed UnimplementedCredentialBrokerServer +// for forward compatibility. +// +// CredentialBroker gives an authenticated worker its current actor credential. +type CredentialBrokerServer interface { + MintActorJWT(context.Context, *MintActorJWTRequest) (*MintActorJWTResponse, error) + mustEmbedUnimplementedCredentialBrokerServer() +} + +// UnimplementedCredentialBrokerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCredentialBrokerServer struct{} + +func (UnimplementedCredentialBrokerServer) MintActorJWT(context.Context, *MintActorJWTRequest) (*MintActorJWTResponse, error) { + return nil, status.Error(codes.Unimplemented, "method MintActorJWT not implemented") +} +func (UnimplementedCredentialBrokerServer) mustEmbedUnimplementedCredentialBrokerServer() {} +func (UnimplementedCredentialBrokerServer) testEmbeddedByValue() {} + +// UnsafeCredentialBrokerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CredentialBrokerServer will +// result in compilation errors. +type UnsafeCredentialBrokerServer interface { + mustEmbedUnimplementedCredentialBrokerServer() +} + +func RegisterCredentialBrokerServer(s grpc.ServiceRegistrar, srv CredentialBrokerServer) { + // If the following call panics, it indicates UnimplementedCredentialBrokerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CredentialBroker_ServiceDesc, srv) +} + +func _CredentialBroker_MintActorJWT_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MintActorJWTRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CredentialBrokerServer).MintActorJWT(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CredentialBroker_MintActorJWT_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CredentialBrokerServer).MintActorJWT(ctx, req.(*MintActorJWTRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CredentialBroker_ServiceDesc is the grpc.ServiceDesc for CredentialBroker service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CredentialBroker_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "atelet.CredentialBroker", + HandlerType: (*CredentialBrokerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "MintActorJWT", + Handler: _CredentialBroker_MintActorJWT_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "atelet.proto", +} + const ( AteomHerder_Run_FullMethodName = "/atelet.AteomHerder/Run" AteomHerder_Checkpoint_FullMethodName = "/atelet.AteomHerder/Checkpoint" diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index f738c0578..83bc6f12d 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -99,16 +99,14 @@ func (SnapshotScope) EnumDescriptor() ([]byte, []int) { } type RunWorkloadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` - ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` - ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` - // Actor resource version observed by ate-api when assigning this worker. - ActorVersion int64 `protobuf:"varint,9,opt,name=actor_version,json=actorVersion,proto3" json:"actor_version,omitempty"` - ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` - ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` - RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` - Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` + ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` + ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` + ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` + ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` + RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` + Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` // runtime_asset_paths maps a runtime asset name (e.g. "cloud-hypervisor", // "virtiofsd", "kata-kernel", "kata-image", "kata-config") // to the local on-disk path atelet fetched it to (content-addressed, like @@ -117,8 +115,10 @@ type RunWorkloadRequest struct { // Remote egress gateway selected for this activation. When absent, actor // traffic uses direct egress instead of being redirected through atunnel. EgressGatewayAddress *string `protobuf:"bytes,10,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Logical PEP audience placed in the actor JWT. Required with the address. + EgressGatewayAudience *string `protobuf:"bytes,11,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunWorkloadRequest) Reset() { @@ -172,13 +172,6 @@ func (x *RunWorkloadRequest) GetActorUid() string { return "" } -func (x *RunWorkloadRequest) GetActorVersion() int64 { - if x != nil { - return x.ActorVersion - } - return 0 -} - func (x *RunWorkloadRequest) GetActorTemplateNamespace() string { if x != nil { return x.ActorTemplateNamespace @@ -221,6 +214,13 @@ func (x *RunWorkloadRequest) GetEgressGatewayAddress() string { return "" } +func (x *RunWorkloadRequest) GetEgressGatewayAudience() string { + if x != nil && x.EgressGatewayAudience != nil { + return *x.EgressGatewayAudience + } + return "" +} + // WorkloadSpec parallels Pod, but with far fewer configurable fields. type WorkloadSpec struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -696,16 +696,14 @@ func (x *CheckpointWorkloadResponse) GetSnapshotFiles() []string { } type RestoreWorkloadRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` - ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` - ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` - // Actor resource version observed by ate-api when assigning this worker. - ActorVersion int64 `protobuf:"varint,11,opt,name=actor_version,json=actorVersion,proto3" json:"actor_version,omitempty"` - ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` - ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` - RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` - Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` + ActorName string `protobuf:"bytes,2,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` + ActorUid string `protobuf:"bytes,3,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` + ActorTemplateNamespace string `protobuf:"bytes,4,opt,name=actor_template_namespace,json=actorTemplateNamespace,proto3" json:"actor_template_namespace,omitempty"` + ActorTemplateName string `protobuf:"bytes,5,opt,name=actor_template_name,json=actorTemplateName,proto3" json:"actor_template_name,omitempty"` + RunscPath string `protobuf:"bytes,6,opt,name=runsc_path,json=runscPath,proto3" json:"runsc_path,omitempty"` + Spec *WorkloadSpec `protobuf:"bytes,7,opt,name=spec,proto3" json:"spec,omitempty"` // The object storage URI prefix of the snapshot to restore. SnapshotUriPrefix string `protobuf:"bytes,8,opt,name=snapshot_uri_prefix,json=snapshotUriPrefix,proto3" json:"snapshot_uri_prefix,omitempty"` // runtime_asset_paths maps a runtime asset name to the local on-disk path @@ -720,8 +718,10 @@ type RestoreWorkloadRequest struct { // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the // snapshot_uri_prefix contract (field 8). GoldenSnapshotUriPrefix string `protobuf:"bytes,13,opt,name=golden_snapshot_uri_prefix,json=goldenSnapshotUriPrefix,proto3" json:"golden_snapshot_uri_prefix,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Logical PEP audience placed in the actor JWT. Required with the address. + EgressGatewayAudience *string `protobuf:"bytes,14,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreWorkloadRequest) Reset() { @@ -775,13 +775,6 @@ func (x *RestoreWorkloadRequest) GetActorUid() string { return "" } -func (x *RestoreWorkloadRequest) GetActorVersion() int64 { - if x != nil { - return x.ActorVersion - } - return 0 -} - func (x *RestoreWorkloadRequest) GetActorTemplateNamespace() string { if x != nil { return x.ActorTemplateNamespace @@ -845,6 +838,13 @@ func (x *RestoreWorkloadRequest) GetGoldenSnapshotUriPrefix() string { return "" } +func (x *RestoreWorkloadRequest) GetEgressGatewayAudience() string { + if x != nil && x.EgressGatewayAudience != nil { + return *x.EgressGatewayAudience + } + return "" +} + type RestoreWorkloadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -885,13 +885,12 @@ var File_ateom_proto protoreflect.FileDescriptor const file_ateom_proto_rawDesc = "" + "\n" + - "\vateom.proto\x12\x05ateom\"\xc1\x04\n" + + "\vateom.proto\x12\x05ateom\"\x8a\x05\n" + "\x12RunWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12#\n" + - "\ractor_version\x18\t \x01(\x03R\factorVersion\x128\n" + + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x128\n" + "\x18actor_template_namespace\x18\x04 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x05 \x01(\tR\x11actorTemplateName\x12\x1d\n" + "\n" + @@ -899,11 +898,14 @@ const file_ateom_proto_rawDesc = "" + "\x04spec\x18\a \x01(\v2\x13.ateom.WorkloadSpecR\x04spec\x12`\n" + "\x13runtime_asset_paths\x18\b \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x129\n" + "\x16egress_gateway_address\x18\n" + - " \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x1aD\n" + + " \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + + "\x17egress_gateway_audience\x18\v \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_address\"@\n" + + "\x17_egress_gateway_addressB\x1a\n" + + "\x18_egress_gateway_audienceJ\x04\b\t\x10\n" + + "R\ractor_version\"@\n" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + @@ -941,13 +943,12 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"C\n" + "\x1aCheckpointWorkloadResponse\x12%\n" + - "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xe2\x05\n" + + "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xab\x06\n" + "\x16RestoreWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12#\n" + - "\ractor_version\x18\v \x01(\x03R\factorVersion\x128\n" + + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x128\n" + "\x18actor_template_namespace\x18\x04 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x05 \x01(\tR\x11actorTemplateName\x12\x1d\n" + "\n" + @@ -958,11 +959,13 @@ const file_ateom_proto_rawDesc = "" + "\x05scope\x18\n" + " \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x129\n" + "\x16egress_gateway_address\x18\f \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + - "\x1agolden_snapshot_uri_prefix\x18\r \x01(\tR\x17goldenSnapshotUriPrefix\x1aD\n" + + "\x1agolden_snapshot_uri_prefix\x18\r \x01(\tR\x17goldenSnapshotUriPrefix\x12;\n" + + "\x17egress_gateway_audience\x18\x0e \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_address\"\x19\n" + + "\x17_egress_gateway_addressB\x1a\n" + + "\x18_egress_gateway_audienceJ\x04\b\v\x10\fR\ractor_version\"\x19\n" + "\x17RestoreWorkloadResponse*\x84\x01\n" + "\rSnapshotScope\x12\x1e\n" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 9a2176e38..c1f6fccd8 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -51,8 +51,8 @@ message RunWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - // Actor resource version observed by ate-api when assigning this worker. - int64 actor_version = 9; + reserved 9; + reserved "actor_version"; string actor_template_namespace = 4; string actor_template_name = 5; @@ -70,6 +70,8 @@ message RunWorkloadRequest { // Remote egress gateway selected for this activation. When absent, actor // traffic uses direct egress instead of being redirected through atunnel. optional string egress_gateway_address = 10; + // Logical PEP audience placed in the actor JWT. Required with the address. + optional string egress_gateway_audience = 11; } // WorkloadSpec parallels Pod, but with far fewer configurable fields. @@ -176,8 +178,8 @@ message RestoreWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - // Actor resource version observed by ate-api when assigning this worker. - int64 actor_version = 11; + reserved 11; + reserved "actor_version"; string actor_template_namespace = 4; string actor_template_name = 5; @@ -204,6 +206,8 @@ message RestoreWorkloadRequest { // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the // snapshot_uri_prefix contract (field 8). string golden_snapshot_uri_prefix = 13; + // Logical PEP audience placed in the actor JWT. Required with the address. + optional string egress_gateway_audience = 14; } message RestoreWorkloadResponse { diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index 0bb913c86..b0daba404 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -99,6 +99,7 @@ spec: - --client-jwt-issuer=@env - --client-jwt-audience=api.ate-system.svc - --actor-id-jwt-pool=/run/actor-id-jwt-pool/pool.json + - --egress-gateway-audience=egress.ate-system.svc - --actor-id-ca-pool=/run/actor-id-ca-pool/pool.json - --atelet-client-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --pod-identity-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem diff --git a/manifests/ate-install/atelet.yaml b/manifests/ate-install/atelet.yaml index b8e057a86..4fab73edc 100644 --- a/manifests/ate-install/atelet.yaml +++ b/manifests/ate-install/atelet.yaml @@ -71,6 +71,7 @@ spec: - --gcp-auth-for-image-pulls=true - --grpc-server-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --client-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem + - --ateapi-ca-file=/run/servicedns.podcert.ate.dev/trust-bundle.pem # atelet does no mounts, netlink, device, or namespace operations (those # live in the ateom worker pod) — it only reads/writes the # /var/lib/ateom-gvisor hostPath as root, so it needs no Linux @@ -87,10 +88,6 @@ spec: drop: - ALL env: - - name: MY_NODE_NAME - valueFrom: - fieldRef: - fieldPath: spec.nodeName - name: POD_NAME valueFrom: fieldRef: @@ -122,6 +119,9 @@ spec: mountPath: /var/lib/ateom-gvisor - name: podidentity mountPath: /run/podidentity.podcert.ate.dev + - name: servicedns-ca + mountPath: /run/servicedns.podcert.ate.dev + readOnly: true volumes: - name: run-ateom hostPath: @@ -144,3 +144,12 @@ spec: matchLabels: podcert.ate.dev/canarying: live path: trust-bundle.pem + - name: servicedns-ca + projected: + sources: + - clusterTrustBundle: + signerName: servicedns.podcert.ate.dev/identity + labelSelector: + matchLabels: + podcert.ate.dev/canarying: live + path: trust-bundle.pem diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index 557dad7da..c6e1ba458 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -2707,10 +2707,11 @@ func (*DebugClearResponse) Descriptor() ([]byte, []int) { type MintJWTRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - Audience []string `protobuf:"bytes,1,rep,name=audience,proto3" json:"audience,omitempty"` + Audience string `protobuf:"bytes,1,opt,name=audience,proto3" json:"audience,omitempty"` Atespace string `protobuf:"bytes,2,opt,name=atespace,proto3" json:"atespace,omitempty"` ActorName string `protobuf:"bytes,3,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` ActorUid string `protobuf:"bytes,4,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` + WorkerPodUid string `protobuf:"bytes,5,opt,name=worker_pod_uid,json=workerPodUid,proto3" json:"worker_pod_uid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2745,11 +2746,11 @@ func (*MintJWTRequest) Descriptor() ([]byte, []int) { return file_ateapi_proto_rawDescGZIP(), []int{41} } -func (x *MintJWTRequest) GetAudience() []string { +func (x *MintJWTRequest) GetAudience() string { if x != nil { return x.Audience } - return nil + return "" } func (x *MintJWTRequest) GetAtespace() string { @@ -2773,6 +2774,13 @@ func (x *MintJWTRequest) GetActorUid() string { return "" } +func (x *MintJWTRequest) GetWorkerPodUid() string { + if x != nil { + return x.WorkerPodUid + } + return "" +} + // TODO: check why k8s do ":" and not "/" as a seprator for the Subject format // TODO: whats the right format for the subject? kubernetes follow "system:serviceaccount::". type MintJWTResponse struct { @@ -2796,9 +2804,13 @@ type MintJWTResponse struct { // - `ate.dev`: Ate/Substrate Extension - JSON object // - atespace: (string) The atespace the actor belongs to // - actorName: (string) The actor's name, unique within its atespace - ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // - actorUid: (string) The actor incarnation + // - actorResourceVersion: (number) The actor version at issuance + // - workerPodUid: (string) The worker Pod bound to this token + ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` + ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MintJWTResponse) Reset() { @@ -2838,6 +2850,13 @@ func (x *MintJWTResponse) GetActorJwt() string { return "" } +func (x *MintJWTResponse) GetExpirationTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpirationTime + } + return nil +} + type MintCertRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` @@ -3144,15 +3163,17 @@ const file_ateapi_proto_rawDesc = "" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"\x13\n" + "\x11DebugClearRequest\"\x14\n" + - "\x12DebugClearResponse\"\x84\x01\n" + + "\x12DebugClearResponse\"\xaa\x01\n" + "\x0eMintJWTRequest\x12\x1a\n" + - "\baudience\x18\x01 \x03(\tR\baudience\x12\x1a\n" + + "\baudience\x18\x01 \x01(\tR\baudience\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x03 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x04 \x01(\tR\bactorUid\".\n" + + "\tactor_uid\x18\x04 \x01(\tR\bactorUid\x12$\n" + + "\x0eworker_pod_uid\x18\x05 \x01(\tR\fworkerPodUid\"s\n" + "\x0fMintJWTResponse\x12\x1b\n" + - "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\"\xa9\x01\n" + + "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\x12C\n" + + "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\xa9\x01\n" + "\x0fMintCertRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -3317,53 +3338,54 @@ var file_ateapi_proto_depIdxs = []int32{ 4, // 47: ateapi.Worker.state:type_name -> ateapi.Worker.State 43, // 48: ateapi.Assignment.actor_template:type_name -> ateapi.KubeNamespacedObjectRef 13, // 49: ateapi.Assignment.actor:type_name -> ateapi.ObjectRef - 20, // 50: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest - 21, // 51: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest - 22, // 52: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest - 24, // 53: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest - 26, // 54: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest - 28, // 55: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest - 30, // 56: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest - 31, // 57: ateapi.Control.GetActorSnapshot:input_type -> ateapi.GetActorSnapshotRequest - 32, // 58: ateapi.Control.ListActorSnapshots:input_type -> ateapi.ListActorSnapshotsRequest - 34, // 59: ateapi.Control.TagActorSnapshot:input_type -> ateapi.TagActorSnapshotRequest - 35, // 60: ateapi.Control.UpdateActorSnapshotTag:input_type -> ateapi.UpdateActorSnapshotTagRequest - 36, // 61: ateapi.Control.DeleteActorSnapshotTag:input_type -> ateapi.DeleteActorSnapshotTagRequest - 37, // 62: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest - 39, // 63: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest - 15, // 64: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest - 16, // 65: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest - 17, // 66: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest - 19, // 67: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest - 44, // 68: ateapi.Debug.DebugClear:input_type -> ateapi.DebugClearRequest - 46, // 69: ateapi.ActorIdentity.MintJWT:input_type -> ateapi.MintJWTRequest - 48, // 70: ateapi.ActorIdentity.MintCert:input_type -> ateapi.MintCertRequest - 9, // 71: ateapi.Control.GetActor:output_type -> ateapi.Actor - 9, // 72: ateapi.Control.CreateActor:output_type -> ateapi.Actor - 23, // 73: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse - 25, // 74: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse - 27, // 75: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse - 29, // 76: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse - 9, // 77: ateapi.Control.DeleteActor:output_type -> ateapi.Actor - 10, // 78: ateapi.Control.GetActorSnapshot:output_type -> ateapi.ActorSnapshot - 33, // 79: ateapi.Control.ListActorSnapshots:output_type -> ateapi.ListActorSnapshotsResponse - 11, // 80: ateapi.Control.TagActorSnapshot:output_type -> ateapi.ActorSnapshotTag - 11, // 81: ateapi.Control.UpdateActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag - 11, // 82: ateapi.Control.DeleteActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag - 38, // 83: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse - 40, // 84: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse - 12, // 85: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace - 12, // 86: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace - 18, // 87: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse - 12, // 88: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace - 45, // 89: ateapi.Debug.DebugClear:output_type -> ateapi.DebugClearResponse - 47, // 90: ateapi.ActorIdentity.MintJWT:output_type -> ateapi.MintJWTResponse - 49, // 91: ateapi.ActorIdentity.MintCert:output_type -> ateapi.MintCertResponse - 71, // [71:92] is the sub-list for method output_type - 50, // [50:71] is the sub-list for method input_type - 50, // [50:50] is the sub-list for extension type_name - 50, // [50:50] is the sub-list for extension extendee - 0, // [0:50] is the sub-list for field type_name + 52, // 50: ateapi.MintJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp + 20, // 51: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest + 21, // 52: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest + 22, // 53: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest + 24, // 54: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest + 26, // 55: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest + 28, // 56: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest + 30, // 57: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest + 31, // 58: ateapi.Control.GetActorSnapshot:input_type -> ateapi.GetActorSnapshotRequest + 32, // 59: ateapi.Control.ListActorSnapshots:input_type -> ateapi.ListActorSnapshotsRequest + 34, // 60: ateapi.Control.TagActorSnapshot:input_type -> ateapi.TagActorSnapshotRequest + 35, // 61: ateapi.Control.UpdateActorSnapshotTag:input_type -> ateapi.UpdateActorSnapshotTagRequest + 36, // 62: ateapi.Control.DeleteActorSnapshotTag:input_type -> ateapi.DeleteActorSnapshotTagRequest + 37, // 63: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest + 39, // 64: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest + 15, // 65: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest + 16, // 66: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest + 17, // 67: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest + 19, // 68: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest + 44, // 69: ateapi.Debug.DebugClear:input_type -> ateapi.DebugClearRequest + 46, // 70: ateapi.ActorIdentity.MintJWT:input_type -> ateapi.MintJWTRequest + 48, // 71: ateapi.ActorIdentity.MintCert:input_type -> ateapi.MintCertRequest + 9, // 72: ateapi.Control.GetActor:output_type -> ateapi.Actor + 9, // 73: ateapi.Control.CreateActor:output_type -> ateapi.Actor + 23, // 74: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse + 25, // 75: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse + 27, // 76: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse + 29, // 77: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse + 9, // 78: ateapi.Control.DeleteActor:output_type -> ateapi.Actor + 10, // 79: ateapi.Control.GetActorSnapshot:output_type -> ateapi.ActorSnapshot + 33, // 80: ateapi.Control.ListActorSnapshots:output_type -> ateapi.ListActorSnapshotsResponse + 11, // 81: ateapi.Control.TagActorSnapshot:output_type -> ateapi.ActorSnapshotTag + 11, // 82: ateapi.Control.UpdateActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag + 11, // 83: ateapi.Control.DeleteActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag + 38, // 84: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse + 40, // 85: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse + 12, // 86: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace + 12, // 87: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace + 18, // 88: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse + 12, // 89: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace + 45, // 90: ateapi.Debug.DebugClear:output_type -> ateapi.DebugClearResponse + 47, // 91: ateapi.ActorIdentity.MintJWT:output_type -> ateapi.MintJWTResponse + 49, // 92: ateapi.ActorIdentity.MintCert:output_type -> ateapi.MintCertResponse + 72, // [72:93] is the sub-list for method output_type + 51, // [51:72] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_ateapi_proto_init() } diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index 1324804d8..bf38d54ea 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -471,27 +471,13 @@ message DebugClearRequest {} message DebugClearResponse {} -// ActorIdentity allows substrate workloads to exchange their -// infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate -// between many different physical workers over the course of its lifecycle, -// whereas the actor credential's identity will be stable for the life of the -// actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. +// ActorIdentity lets atelet mint credentials for actors currently assigned to +// workers on its node. Calls require atelet's Pod certificate over mTLS. service ActorIdentity { // Request an Actor Identity JWT. // - // To call this RPC, you must be authenticated as the Kubernetes Pod that is - // currently running the requested actor. + // Atelet supplies the exact worker Pod UID and actor assignment it observed; + // the server independently revalidates both before signing. rpc MintJWT(MintJWTRequest) returns (MintJWTResponse); // Request an Actor Identity Certificate for an actor. @@ -510,11 +496,12 @@ service ActorIdentity { } message MintJWTRequest { - repeated string audience = 1; + string audience = 1; string atespace = 2; string actor_name = 3; string actor_uid = 4; + string worker_pod_uid = 5; } // TODO: check why k8s do ":" and not "/" as a seprator for the Subject format @@ -537,7 +524,11 @@ message MintJWTResponse { // * `ate.dev`: Ate/Substrate Extension - JSON object // * atespace: (string) The atespace the actor belongs to // * actorName: (string) The actor's name, unique within its atespace + // * actorUid: (string) The actor incarnation + // * actorResourceVersion: (number) The actor version at issuance + // * workerPodUid: (string) The worker Pod bound to this token string actor_jwt = 1; + google.protobuf.Timestamp expiration_time = 2; } message MintCertRequest { diff --git a/pkg/proto/ateapipb/ateapi_grpc.pb.go b/pkg/proto/ateapipb/ateapi_grpc.pb.go index 3db1d0bfa..c1dae3bd6 100644 --- a/pkg/proto/ateapipb/ateapi_grpc.pb.go +++ b/pkg/proto/ateapipb/ateapi_grpc.pb.go @@ -943,27 +943,13 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// ActorIdentity allows substrate workloads to exchange their -// infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate -// between many different physical workers over the course of its lifecycle, -// whereas the actor credential's identity will be stable for the life of the -// actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. +// ActorIdentity lets atelet mint credentials for actors currently assigned to +// workers on its node. Calls require atelet's Pod certificate over mTLS. type ActorIdentityClient interface { // Request an Actor Identity JWT. // - // To call this RPC, you must be authenticated as the Kubernetes Pod that is - // currently running the requested actor. + // Atelet supplies the exact worker Pod UID and actor assignment it observed; + // the server independently revalidates both before signing. MintJWT(ctx context.Context, in *MintJWTRequest, opts ...grpc.CallOption) (*MintJWTResponse, error) // Request an Actor Identity Certificate for an actor. // @@ -1012,27 +998,13 @@ func (c *actorIdentityClient) MintCert(ctx context.Context, in *MintCertRequest, // All implementations must embed UnimplementedActorIdentityServer // for forward compatibility. // -// ActorIdentity allows substrate workloads to exchange their -// infrastructure-level credentials (k8s service account token, etc.) for a -// substrate actor-level credential. A given substrate actor might migrate -// between many different physical workers over the course of its lifecycle, -// whereas the actor credential's identity will be stable for the life of the -// actor. -// -// This service requires authentication. You can authenticate with a Kubernetes -// service account token in an `Authorization: Bearer` header, or you can -// authenticate with a Kubernetes service account certificate as an mTLS -// certificate. (Kubernetes service account certificates do not currently exist -// upstream, but we will provide a polyfill based on Pod Certificates). -// -// The broker will check that the service credentials you authenticated with -// belong to a Pod that is currently mapped to the requested actor in the -// actor database. +// ActorIdentity lets atelet mint credentials for actors currently assigned to +// workers on its node. Calls require atelet's Pod certificate over mTLS. type ActorIdentityServer interface { // Request an Actor Identity JWT. // - // To call this RPC, you must be authenticated as the Kubernetes Pod that is - // currently running the requested actor. + // Atelet supplies the exact worker Pod UID and actor assignment it observed; + // the server independently revalidates both before signing. MintJWT(context.Context, *MintJWTRequest) (*MintJWTResponse, error) // Request an Actor Identity Certificate for an actor. // From b7a22b669173efe5948a3f624026e8bc1ac31616 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 04:35:30 +0000 Subject: [PATCH 2/7] atunnel: clarify credential lifecycle --- cmd/ateapi/internal/actoridentity/actoridentity.go | 2 ++ cmd/atelet/credentialbroker.go | 4 ++++ cmd/ateom-gvisor/main.go | 2 ++ cmd/ateom-microvm/main.go | 2 ++ internal/atunnel/credential.go | 3 +++ internal/atunnel/egress.go | 7 +++++++ internal/proto/ateompb/ateom.pb.go | 9 ++++----- internal/proto/ateompb/ateom.proto | 6 ------ 8 files changed, 24 insertions(+), 11 deletions(-) diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 33892ad07..0703d2e9b 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -82,6 +82,8 @@ const ( ) func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*ateapipb.MintJWTResponse, error) { + // Authentication identifies atelet and its node; no actor identity supplied + // by the worker-side caller is trusted until authorizeActor checks storage. caller, err := authenticateAtelet(ctx) if err != nil { return nil, err diff --git a/cmd/atelet/credentialbroker.go b/cmd/atelet/credentialbroker.go index e3bba2134..d75124a19 100644 --- a/cmd/atelet/credentialbroker.go +++ b/cmd/atelet/credentialbroker.go @@ -35,6 +35,8 @@ type credentialBroker struct { } func (b *credentialBroker) MintActorJWT(ctx context.Context, req *ateletpb.MintActorJWTRequest) (*ateletpb.MintActorJWTResponse, error) { + // The request deliberately carries no actor identity. The authenticated Pod + // UID is the only input used to select a worker and its current assignment. workerUID, err := authenticatedWorkerUID(ctx) if err != nil { return nil, err @@ -67,6 +69,8 @@ func (b *credentialBroker) MintActorJWT(ctx context.Context, req *ateletpb.MintA if err != nil { return nil, fmt.Errorf("get assigned actor: %w", err) } + // MintJWT revalidates this assignment in ateapi. That second check closes + // the race where the worker is reassigned after ListWorkers returns. resp, err := b.identity.MintJWT(ctx, &ateapipb.MintJWTRequest{ Audience: req.GetAudience(), Atespace: actor.GetMetadata().GetAtespace(), diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 0cf80e806..5ccbab7aa 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -659,6 +659,8 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) } + // Mint before starting the workload so configured tunneled egress fails the + // whole activation closed. The source is retained for background renewal. jwt, err := jwtSource.Mint(ctx) if err != nil { return nil, fmt.Errorf("while obtaining actor JWT: %w", err) diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 7a3f07bf6..9a4335d86 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -341,6 +341,8 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) } + // Mint before starting the workload so configured tunneled egress fails the + // whole activation closed. The source is retained for background renewal. jwt, err := jwtSource.Mint(ctx) if err != nil { return nil, fmt.Errorf("while obtaining actor JWT: %w", err) diff --git a/internal/atunnel/credential.go b/internal/atunnel/credential.go index c7aca0973..6bcb77feb 100644 --- a/internal/atunnel/credential.go +++ b/internal/atunnel/credential.go @@ -81,6 +81,9 @@ func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { InsecureSkipVerify: true, // Verification below supports SPIFFE Pod certificates without a DNS name. GetClientCertificate: credbundle.ClientLoader(cfg.CredentialBundlePath), VerifyConnection: func(state tls.ConnectionState) error { + // Verify both the normal server-auth chain and the identities that DNS + // verification cannot express: atelet's SPIFFE ID and exact node + // incarnation. This is why InsecureSkipVerify is set above. if len(state.PeerCertificates) == 0 { return fmt.Errorf("credential broker certificate is required") } diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index 8d3acc996..ad152cc4c 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -124,6 +124,8 @@ func (e *Egress) Activate(dialer EgressDialer, jwtSource actorJWTSource, jwt Act func (e *Egress) renew(active *egressActivation, expiresAt time.Time) { defer active.wg.Done() + // Schedule from the credential's remaining lifetime: renew at 90%, then + // retry failures only while the currently installed JWT is still valid. delay := renewAfter(expiresAt) for waitForRenewal(active.ctx, delay) { next, err := active.jwtSource.Mint(active.ctx) @@ -136,6 +138,9 @@ func (e *Egress) renew(active *egressActivation, expiresAt time.Time) { continue } e.mu.Lock() + // Check cancellation under the same lock as Deactivate. Whichever wins + // the lock last either installs a live JWT or leaves the activation empty; + // renewal can never restore a credential after deactivation cleared it. if active.ctx.Err() != nil { e.mu.Unlock() return @@ -208,6 +213,8 @@ func (e *Egress) handle(downstream net.Conn) { return } if !time.Now().Before(active.jwt.ExpiresAt) { + // Expiry blocks only new tunnels. Connections admitted with a valid JWT + // retain their copied token and are allowed to drain normally. e.mu.Unlock() _ = downstream.Close() return diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index 83bc6f12d..9a71a52c9 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -885,7 +885,7 @@ var File_ateom_proto protoreflect.FileDescriptor const file_ateom_proto_rawDesc = "" + "\n" + - "\vateom.proto\x12\x05ateom\"\x8a\x05\n" + + "\vateom.proto\x12\x05ateom\"\xf5\x04\n" + "\x12RunWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -904,8 +904,7 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audienceJ\x04\b\t\x10\n" + - "R\ractor_version\"@\n" + + "\x18_egress_gateway_audience\"@\n" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + @@ -943,7 +942,7 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"C\n" + "\x1aCheckpointWorkloadResponse\x12%\n" + - "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xab\x06\n" + + "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\x96\x06\n" + "\x16RestoreWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -965,7 +964,7 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audienceJ\x04\b\v\x10\fR\ractor_version\"\x19\n" + + "\x18_egress_gateway_audience\"\x19\n" + "\x17RestoreWorkloadResponse*\x84\x01\n" + "\rSnapshotScope\x12\x1e\n" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index c1f6fccd8..29fcecb14 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -51,9 +51,6 @@ message RunWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - reserved 9; - reserved "actor_version"; - string actor_template_namespace = 4; string actor_template_name = 5; @@ -178,9 +175,6 @@ message RestoreWorkloadRequest { string atespace = 1; string actor_name = 2; string actor_uid = 3; - reserved 11; - reserved "actor_version"; - string actor_template_namespace = 4; string actor_template_name = 5; From 36e58c5abe564ba4412886422b319264bc284a9f Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 13:12:23 +0000 Subject: [PATCH 3/7] atunnel: clarify credential trust boundaries --- cmd/atelet/credentialbroker.go | 4 +- cmd/ateom-gvisor/main.go | 79 +++++++++++++++++-------------- cmd/ateom-microvm/main.go | 85 +++++++++++++++++++--------------- internal/atunnel/credential.go | 12 +++-- internal/atunnel/egress.go | 9 ++-- 5 files changed, 111 insertions(+), 78 deletions(-) diff --git a/cmd/atelet/credentialbroker.go b/cmd/atelet/credentialbroker.go index d75124a19..1bbaa2823 100644 --- a/cmd/atelet/credentialbroker.go +++ b/cmd/atelet/credentialbroker.go @@ -30,7 +30,9 @@ import ( type credentialBroker struct { ateletpb.UnimplementedCredentialBrokerServer - control ateapipb.ControlClient + // control resolves the authenticated worker Pod to its current assignment. + control ateapipb.ControlClient + // identity revalidates that assignment and signs the actor JWT. identity ateapipb.ActorIdentityClient } diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 5ccbab7aa..0fd6481e5 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -58,11 +58,11 @@ var ( // TODO(liorlieberman) have a sub package for all atunnel releated things like that atunnelListenAddress = pflag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "PEM credential bundle for actor ingress HTTPS") - atunnelTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for actor ingress clients") + workerCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") + podIdentityTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = pflag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") atunnelEgressListenAddress = pflag.String("atunnel-egress-listen-address", "0.0.0.0:15001", "Address for transparently intercepted actor egress TCP") - atunnelEgressTrustBundle = pflag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for the egress gateway") + egressGatewayTrustBundle = pflag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "Service DNS trust bundle for the remote egress gateway") showVersion = pflag.Bool("version", false, "Print version and exit.") logLevelFlag = pflag.String("log-level", "info", "Minimum log level: debug, info, warn, or error.") @@ -162,12 +162,12 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while parsing atunnel upstream: %w", err) } - atunnelIngress, atunnelEgress, atunnelEgressPort, err := runAtunnel(ctx, upstream) + atunnelIngress, atunnelEgress, egressProxyPort, err := runAtunnel(ctx, upstream) if err != nil { return err } - ateomService := NewService(interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelTrustBundle, *atunnelEgressTrustBundle) + ateomService := NewService(interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, egressProxyPort, *workerCredentialBundle, *podIdentityTrustBundle, *egressGatewayTrustBundle) svr := grpc.NewServer( grpc.StatsHandler(otelgrpc.NewServerHandler()), @@ -186,8 +186,8 @@ func do(ctx context.Context) error { func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunnel.Egress, uint16, error) { atunnelIngress, err := atunnel.NewServer(atunnel.Config{ - CredentialBundlePath: *atunnelCredentialBundle, - TrustBundlePath: *atunnelTrustBundle, + CredentialBundlePath: *workerCredentialBundle, + TrustBundlePath: *podIdentityTrustBundle, AllowedClientID: *atunnelClientIdentity, Upstream: upstream, }) @@ -218,14 +218,14 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn _ = egressListener.Close() return nil, nil, 0, fmt.Errorf("atunnel egress listener has invalid address %q", egressListener.Addr()) } - atunnelEgressPort := uint16(egressTCPAddr.Port) + egressProxyPort := uint16(egressTCPAddr.Port) go func() { if err := atunnelEgress.Serve(ctx, egressListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor egress", err) } }() slog.InfoContext(ctx, "atunnel egress serving", slog.String("address", *atunnelEgressListenAddress)) - return atunnelIngress, atunnelEgress, atunnelEgressPort, nil + return atunnelIngress, atunnelEgress, egressProxyPort, nil } // AteomService is a service for shepherding single microvm. @@ -240,26 +240,32 @@ type AteomService struct { actorLogger *actorlog.ActorLogger atunnelIngress *atunnel.Server atunnelEgress *atunnel.Egress - // Actor TCP connections are transparently redirected to atunnelEgressPort. - atunnelEgressPort uint16 - atunnelCredentialBundle string - atunnelTrustBundle string - atunnelEgressTrustBundle string + + // egressProxyPort is the local atunnel listener used as the target of the + // actor network's transparent TCP redirect. + egressProxyPort uint16 + // workerCredentialBundlePath contains the worker Pod certificate and key. + // Atunnel presents it to both the egress gateway and the atelet broker. + workerCredentialBundlePath string + // podIdentityTrustBundlePath verifies the node-local atelet's Pod identity. + podIdentityTrustBundlePath string + // egressGatewayTrustBundlePath verifies the remote gateway's serving cert. + egressGatewayTrustBundlePath string } var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, trustBundle, egressTrustBundle string) *AteomService { +func NewService(interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, egressProxyPort uint16, workerCredentialBundlePath, podIdentityTrustBundlePath, egressGatewayTrustBundlePath string) *AteomService { return &AteomService{ - interiorNetNS: interiorNetNS, - actorLogger: actorLogger, - atunnelIngress: atunnelIngress, - atunnelEgress: atunnelEgress, - atunnelEgressPort: atunnelEgressPort, - atunnelCredentialBundle: credentialBundle, - atunnelTrustBundle: trustBundle, - atunnelEgressTrustBundle: egressTrustBundle, + interiorNetNS: interiorNetNS, + actorLogger: actorLogger, + atunnelIngress: atunnelIngress, + atunnelEgress: atunnelEgress, + egressProxyPort: egressProxyPort, + workerCredentialBundlePath: workerCredentialBundlePath, + podIdentityTrustBundlePath: podIdentityTrustBundlePath, + egressGatewayTrustBundlePath: egressGatewayTrustBundlePath, } } @@ -625,9 +631,12 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } type actorEgress struct { - egressClient atunnel.EgressDialer - jwtSource *atunnel.BrokerJWTSource - jwt atunnel.ActorJWT + // client authenticates the worker to the remote egress gateway. + client atunnel.EgressDialer + // jwtSource authenticates the worker to atelet for renewal. + jwtSource *atunnel.BrokerJWTSource + // jwt identifies the actor assignment to the egress gateway. + jwt atunnel.ActorJWT } func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { @@ -641,19 +650,21 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) } - egressClient, err := atunnel.NewClient(atunnel.ClientConfig{ + // The worker credential authenticates both connections, but each peer is + // verified against the trust domain that issued its serving certificate. + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ GatewayAddress: egressGatewayAddress, ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.egressGatewayTrustBundlePath, }) if err != nil { return nil, fmt.Errorf("while configuring actor egress client: %w", err) } jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ SocketPath: ateompath.CredentialBrokerSocket, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelTrustBundle, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.podIdentityTrustBundlePath, Audience: egressGatewayAudience, }) if err != nil { @@ -665,7 +676,7 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("while obtaining actor JWT: %w", err) } - return &actorEgress{egressClient: egressClient, jwtSource: jwtSource, jwt: jwt}, nil + return &actorEgress{client: gatewayClient, jwtSource: jwtSource, jwt: jwt}, nil } func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { @@ -675,7 +686,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, egres if egress == nil { return nil } - if err := s.atunnelEgress.Activate(egress.egressClient, egress.jwtSource, egress.jwt); err != nil { + if err := s.atunnelEgress.Activate(egress.client, egress.jwtSource, egress.jwt); err != nil { return fmt.Errorf("while activating actor egress: %w", err) } return nil @@ -707,7 +718,7 @@ func (s *AteomService) egressRedirectPort(redirectEgress bool) uint16 { if !redirectEgress { return 0 } - return s.atunnelEgressPort + return s.egressProxyPort } // setupCgroupDelegation prepares the worker pod's cgroup so runsc can create a diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 9a4335d86..6321a8c06 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -61,11 +61,11 @@ var ( logLevelFlag = flag.String("log-level", "info", "Minimum log level: debug, info, warn, or error.") atunnelListenAddress = flag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "PEM credential bundle for actor ingress HTTPS") - atunnelTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for actor ingress clients") + workerCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") + podIdentityTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = flag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") atunnelEgressListenAddress = flag.String("atunnel-egress-listen-address", "0.0.0.0:15001", "Address for transparently intercepted actor egress TCP") - atunnelEgressTrustBundle = flag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "PEM trust bundle for the egress gateway") + egressGatewayTrustBundle = flag.String("atunnel-egress-trust-bundle", "/run/servicedns.podcert.ate.dev/trust-bundle.pem", "Service DNS trust bundle for the remote egress gateway") ) const ( @@ -169,8 +169,8 @@ func do(ctx context.Context) error { return fmt.Errorf("while parsing atunnel upstream: %w", err) } atunnelIngress, err := atunnel.NewServer(atunnel.Config{ - CredentialBundlePath: *atunnelCredentialBundle, - TrustBundlePath: *atunnelTrustBundle, + CredentialBundlePath: *workerCredentialBundle, + TrustBundlePath: *podIdentityTrustBundle, AllowedClientID: *atunnelClientIdentity, Upstream: upstream, }) @@ -200,7 +200,7 @@ func do(ctx context.Context) error { _ = egressListener.Close() return fmt.Errorf("atunnel egress listener has invalid address %q", egressListener.Addr()) } - atunnelEgressPort := uint16(egressTCPAddr.Port) + egressProxyPort := uint16(egressTCPAddr.Port) go func() { if err := atunnelEgress.Serve(ctx, egressListener); err != nil { serverboot.Fatal(ctx, "Failed to serve actor egress", err) @@ -212,7 +212,7 @@ func do(ctx context.Context) error { grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.UnaryInterceptor(ateinterceptors.InternalServerUnaryInterceptor), ) - ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelTrustBundle, *atunnelEgressTrustBundle)) + ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelIngress, atunnelEgress, egressProxyPort, *workerCredentialBundle, *podIdentityTrustBundle, *egressGatewayTrustBundle)) reflection.Register(svr) slog.InfoContext(ctx, "ateom-microvm serving", slog.String("socket", sockPath)) @@ -273,11 +273,17 @@ type AteomService struct { actorLogger *actorlog.ActorLogger atunnelIngress *atunnel.Server atunnelEgress *atunnel.Egress - // Actor TCP connections are transparently redirected to atunnelEgressPort. - atunnelEgressPort uint16 - atunnelCredentialBundle string - atunnelTrustBundle string - atunnelEgressTrustBundle string + + // egressProxyPort is the local atunnel listener used as the target of the + // actor network's transparent TCP redirect. + egressProxyPort uint16 + // workerCredentialBundlePath contains the worker Pod certificate and key. + // Atunnel presents it to both the egress gateway and the atelet broker. + workerCredentialBundlePath string + // podIdentityTrustBundlePath verifies the node-local atelet's Pod identity. + podIdentityTrustBundlePath string + // egressGatewayTrustBundlePath verifies the remote gateway's serving cert. + egressGatewayTrustBundlePath string // running maps actor UID -> the live micro-VM, kept so CheckpointWorkload can // pause+snapshot+teardown the same sandbox (and RestoreWorkload can track the @@ -288,28 +294,31 @@ type AteomService struct { var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, trustBundle, egressTrustBundle string) *AteomService { +func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelIngress *atunnel.Server, atunnelEgress *atunnel.Egress, egressProxyPort uint16, workerCredentialBundlePath, podIdentityTrustBundlePath, egressGatewayTrustBundlePath string) *AteomService { return &AteomService{ - podUID: podUID, - chBinary: chBinary, - kataConfig: kataConfig, - kataDebug: kataDebug, - interiorNetNS: interiorNetNS, - actorLogger: actorLogger, - atunnelIngress: atunnelIngress, - atunnelEgress: atunnelEgress, - atunnelEgressPort: atunnelEgressPort, - atunnelCredentialBundle: credentialBundle, - atunnelTrustBundle: trustBundle, - atunnelEgressTrustBundle: egressTrustBundle, - running: map[string]*runningActor{}, + podUID: podUID, + chBinary: chBinary, + kataConfig: kataConfig, + kataDebug: kataDebug, + interiorNetNS: interiorNetNS, + actorLogger: actorLogger, + atunnelIngress: atunnelIngress, + atunnelEgress: atunnelEgress, + egressProxyPort: egressProxyPort, + workerCredentialBundlePath: workerCredentialBundlePath, + podIdentityTrustBundlePath: podIdentityTrustBundlePath, + egressGatewayTrustBundlePath: egressGatewayTrustBundlePath, + running: map[string]*runningActor{}, } } type actorEgress struct { - egressClient atunnel.EgressDialer - jwtSource *atunnel.BrokerJWTSource - jwt atunnel.ActorJWT + // client authenticates the worker to the remote egress gateway. + client atunnel.EgressDialer + // jwtSource authenticates the worker to atelet for renewal. + jwtSource *atunnel.BrokerJWTSource + // jwt identifies the actor assignment to the egress gateway. + jwt atunnel.ActorJWT } func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { @@ -323,19 +332,21 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) } - egressClient, err := atunnel.NewClient(atunnel.ClientConfig{ + // The worker credential authenticates both connections, but each peer is + // verified against the trust domain that issued its serving certificate. + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ GatewayAddress: egressGatewayAddress, ServerName: serverName, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelEgressTrustBundle, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.egressGatewayTrustBundlePath, }) if err != nil { return nil, fmt.Errorf("while configuring actor egress client: %w", err) } jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ SocketPath: ateompath.CredentialBrokerSocket, - CredentialBundlePath: s.atunnelCredentialBundle, - TrustBundlePath: s.atunnelTrustBundle, + CredentialBundlePath: s.workerCredentialBundlePath, + TrustBundlePath: s.podIdentityTrustBundlePath, Audience: egressGatewayAudience, }) if err != nil { @@ -347,7 +358,7 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr if err != nil { return nil, fmt.Errorf("while obtaining actor JWT: %w", err) } - return &actorEgress{egressClient: egressClient, jwtSource: jwtSource, jwt: jwt}, nil + return &actorEgress{client: gatewayClient, jwtSource: jwtSource, jwt: jwt}, nil } func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { @@ -357,7 +368,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, egres if egress == nil { return nil } - if err := s.atunnelEgress.Activate(egress.egressClient, egress.jwtSource, egress.jwt); err != nil { + if err := s.atunnelEgress.Activate(egress.client, egress.jwtSource, egress.jwt); err != nil { return fmt.Errorf("while activating actor egress: %w", err) } return nil @@ -380,5 +391,5 @@ func (s *AteomService) egressRedirectPort(redirectEgress bool) uint16 { if !redirectEgress { return 0 } - return s.atunnelEgressPort + return s.egressProxyPort } diff --git a/internal/atunnel/credential.go b/internal/atunnel/credential.go index 6bcb77feb..422d0881f 100644 --- a/internal/atunnel/credential.go +++ b/internal/atunnel/credential.go @@ -47,10 +47,14 @@ type BrokerJWTSource struct { // BrokerConfig configures the node-local atelet credential broker client. type BrokerConfig struct { - SocketPath string + // SocketPath is the atelet-owned Unix socket shared with this worker. + SocketPath string + // CredentialBundlePath is the worker Pod certificate and private key. CredentialBundlePath string - TrustBundlePath string - Audience string + // TrustBundlePath verifies atelet's Pod certificate. + TrustBundlePath string + // Audience identifies the egress PEP that will consume the minted JWT. + Audience string } // NewBrokerJWTSource returns a source that mutually authenticates with atelet @@ -111,6 +115,8 @@ func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { // Mint requests a fresh actor JWT from atelet. func (s *BrokerJWTSource) Mint(ctx context.Context) (ActorJWT, error) { + // A fresh connection picks up rotated worker credentials and forces atelet's + // current certificate and node identity to be verified for every mint. conn, err := grpc.NewClient("passthrough:///credential-broker", grpc.WithTransportCredentials(credentials.NewTLS(s.tlsConfig)), grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index ad152cc4c..45631d785 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -52,9 +52,12 @@ type egressActivation struct { dialer EgressDialer jwtSource actorJWTSource jwt ActorJWT - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup + + // ctx scopes JWT renewal and every tunnel opened by this activation. wg + // lets Deactivate wait until both renewal and tunnel forwarding have exited. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } // NewEgress creates an activation-aware egress proxy. From cca71429142f4b58dbb2162445447ede3b531a86 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 13:25:12 +0000 Subject: [PATCH 4/7] atunnel: keep egress dialer private --- cmd/ateom-gvisor/main.go | 2 +- cmd/ateom-microvm/main.go | 2 +- internal/atunnel/client.go | 4 ++-- internal/atunnel/egress.go | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 0fd6481e5..a4f6cad2e 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -632,7 +632,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore type actorEgress struct { // client authenticates the worker to the remote egress gateway. - client atunnel.EgressDialer + client *atunnel.Client // jwtSource authenticates the worker to atelet for renewal. jwtSource *atunnel.BrokerJWTSource // jwt identifies the actor assignment to the egress gateway. diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 6321a8c06..d6741545d 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -314,7 +314,7 @@ func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNet type actorEgress struct { // client authenticates the worker to the remote egress gateway. - client atunnel.EgressDialer + client *atunnel.Client // jwtSource authenticates the worker to atelet for renewal. jwtSource *atunnel.BrokerJWTSource // jwt identifies the actor assignment to the egress gateway. diff --git a/internal/atunnel/client.go b/internal/atunnel/client.go index ecfe06366..60cae8576 100644 --- a/internal/atunnel/client.go +++ b/internal/atunnel/client.go @@ -62,8 +62,8 @@ type Client struct { dialContext DialFunc } -// Client implements EgressDialer. -var _ EgressDialer = (*Client)(nil) +// Client implements egressDialer. +var _ egressDialer = (*Client)(nil) // NewClient creates an egress CONNECT client and validates its TLS material. func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index 45631d785..ae6601006 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -25,8 +25,8 @@ import ( "time" ) -// EgressDialer opens an authenticated tunnel to an original destination. -type EgressDialer interface { +// egressDialer opens an authenticated tunnel to an original destination. +type egressDialer interface { DialContext(context.Context, string, string) (net.Conn, error) } @@ -49,7 +49,7 @@ type Egress struct { } type egressActivation struct { - dialer EgressDialer + dialer egressDialer jwtSource actorJWTSource jwt ActorJWT @@ -96,7 +96,7 @@ func (e *Egress) Serve(ctx context.Context, listener net.Listener) error { } // Activate allows egress with a previously obtained actor JWT and renews it until deactivation. -func (e *Egress) Activate(dialer EgressDialer, jwtSource actorJWTSource, jwt ActorJWT) error { +func (e *Egress) Activate(dialer egressDialer, jwtSource actorJWTSource, jwt ActorJWT) error { if dialer == nil { return fmt.Errorf("atunnel: egress dialer is required") } From 25706f78146bbc2d89880436658d0bfb34ed8095 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 13:30:15 +0000 Subject: [PATCH 5/7] atunnel: clarify JWT expiry check --- internal/atunnel/egress.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index ae6601006..af177bbe8 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -215,7 +215,7 @@ func (e *Egress) handle(downstream net.Conn) { _ = downstream.Close() return } - if !time.Now().Before(active.jwt.ExpiresAt) { + if time.Now().Compare(active.jwt.ExpiresAt) >= 0 { // Expiry blocks only new tunnels. Connections admitted with a valid JWT // retain their copied token and are allowed to drain normally. e.mu.Unlock() From 713741660a7027c546d7e516b90a9eb90b0222c7 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 3 Aug 2026 13:35:31 +0000 Subject: [PATCH 6/7] atunnel: group egress gateway configuration --- .../internal/controlapi/workflow_resume.go | 17 +- cmd/atelet/main.go | 13 +- cmd/atelet/main_test.go | 11 + cmd/ateom-gvisor/main.go | 27 +- cmd/ateom-microvm/main.go | 19 +- cmd/ateom-microvm/restore.go | 7 +- cmd/ateom-microvm/run.go | 13 +- internal/proto/ateletpb/atelet.pb.go | 369 ++++++++++-------- internal/proto/ateletpb/atelet.proto | 20 +- internal/proto/ateompb/ateom.pb.go | 267 +++++++------ internal/proto/ateompb/ateom.proto | 22 +- 11 files changed, 437 insertions(+), 348 deletions(-) diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index 3c0d32204..cf86ec86a 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -518,7 +518,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, if err != nil { return err } - egressGatewayAddress, egressGatewayAudience := s.egressGateway() + egressGateway := s.egressGateway() if local := state.Actor.GetLocalSnapshotInfo(); local != nil { slog.InfoContext(ctx, "Actor has snapshot; Restoring from snapshot") @@ -531,8 +531,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, ActorTemplateName: state.Actor.GetActorTemplateName(), Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, - EgressGatewayAddress: egressGatewayAddress, - EgressGatewayAudience: egressGatewayAudience, + EgressGateway: egressGateway, } req.Type = ateletpb.CheckpointType_CHECKPOINT_TYPE_LOCAL req.Config = &ateletpb.RestoreRequest_LocalConfig{ @@ -579,8 +578,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, // Empty unless this is a Golden data resume. GoldenSnapshotUriPrefix: state.GoldenSnapshotLocation, ActorUid: state.Actor.GetMetadata().Uid, - EgressGatewayAddress: egressGatewayAddress, - EgressGatewayAudience: egressGatewayAudience, + EgressGateway: egressGateway, } _, err = client.Restore(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while restoring durable snapshot") @@ -604,8 +602,7 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, SandboxAssets: sandboxAssets, Spec: workloadSpec, ActorUid: state.Actor.GetMetadata().Uid, - EgressGatewayAddress: egressGatewayAddress, - EgressGatewayAudience: egressGatewayAudience, + EgressGateway: egressGateway, } _, err = client.Run(ctx, req) return maybeCrashActor(ctx, s.store, input.ActorRef, err, "while creating workload from spec") @@ -615,11 +612,11 @@ func (s *CallAteletRestoreStep) Execute(ctx context.Context, input *ResumeInput, func (s *CallAteletRestoreStep) RetryBackoff() *wait.Backoff { return nil } -func (s *CallAteletRestoreStep) egressGateway() (*string, *string) { +func (s *CallAteletRestoreStep) egressGateway() *ateletpb.EgressGateway { if s.egressGatewayAddress == "" { - return nil, nil + return nil } - return &s.egressGatewayAddress, &s.egressGatewayAudience + return &ateletpb.EgressGateway{Address: s.egressGatewayAddress, Audience: s.egressGatewayAudience} } type FinalizeRunningStep struct { diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index 75c04982c..cc86ceddc 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -345,8 +345,7 @@ func (s *AteomHerder) Run(ctx context.Context, req *ateletpb.RunRequest) (resp * RuntimeAssetPaths: assetPaths, Spec: buildAteomWorkloadSpec(req.GetSpec()), ActorUid: actorUID, - EgressGatewayAddress: req.EgressGatewayAddress, - EgressGatewayAudience: req.EgressGatewayAudience, + EgressGateway: toAteomEgressGateway(req.GetEgressGateway()), }); err != nil { return nil, fmt.Errorf("while calling ateom.RunWorkload: %w", err) } @@ -722,8 +721,7 @@ func (s *AteomHerder) Restore(ctx context.Context, req *ateletpb.RestoreRequest) Spec: buildAteomWorkloadSpec(req.GetSpec()), Scope: toAteomSnapshotScope(req.GetScope()), ActorUid: req.GetActorUid(), - EgressGatewayAddress: req.EgressGatewayAddress, - EgressGatewayAudience: req.EgressGatewayAudience, + EgressGateway: toAteomEgressGateway(req.GetEgressGateway()), // Informational: for DATA_ON_GOLDEN the golden snapshot's files are // already staged into the restore dir by the combined download above; // ateom restores from the shared dir and never fetches this URI. @@ -994,6 +992,13 @@ func buildAteomWorkloadSpec(spec *ateletpb.WorkloadSpec) *ateompb.WorkloadSpec { return out } +func toAteomEgressGateway(gateway *ateletpb.EgressGateway) *ateompb.EgressGateway { + if gateway == nil { + return nil + } + return &ateompb.EgressGateway{Address: gateway.GetAddress(), Audience: gateway.GetAudience()} +} + // toAteomReadyz converts an ateletpb readyz probe into the ateompb wire // type. Returns nil when the source is nil so containers without a probe // stay unchanged on the wire to ateom. diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index 50fe8a82d..b27cc9094 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -666,6 +666,17 @@ func TestBuildAteomWorkloadSpecForwardsDurableDirMounts(t *testing.T) { } } +func TestToAteomEgressGateway(t *testing.T) { + if got := toAteomEgressGateway(nil); got != nil { + t.Fatalf("toAteomEgressGateway(nil) = %v, want nil", got) + } + want := &ateompb.EgressGateway{Address: "egress.example:443", Audience: "egress-pep"} + got := toAteomEgressGateway(&ateletpb.EgressGateway{Address: want.Address, Audience: want.Audience}) + if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { + t.Errorf("toAteomEgressGateway mismatch (-want +got):\n%s", diff) + } +} + func TestIsTerminalFileErr(t *testing.T) { tests := []struct { name string diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index a4f6cad2e..dce206acf 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -284,14 +284,14 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload // * Correct runsc version is downloaded and placed on disk. // * All OCI bundles are set up, including for "pause" container. - egress, err := s.prepareActorEgress(ctx, req.GetEgressGatewayAddress(), req.GetEgressGatewayAudience()) + egress, err := s.prepareActorEgress(ctx, req.GetEgressGateway()) if err != nil { return nil, err } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, - EgressRedirectPort: s.egressRedirectPort(req.GetEgressGatewayAddress() != ""), + EgressRedirectPort: s.egressRedirectPort(req.GetEgressGateway() != nil), }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } @@ -517,14 +517,14 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore // * All OCI bundles are set up, including for "pause" container. // * Checkpoint downloaded and placed on disk - egress, err := s.prepareActorEgress(ctx, req.GetEgressGatewayAddress(), req.GetEgressGatewayAudience()) + egress, err := s.prepareActorEgress(ctx, req.GetEgressGateway()) if err != nil { return nil, err } if err := ateomnet.SetupActorNetwork(ctx, ateomnet.NetworkConfig{ InteriorNetNS: s.interiorNetNS, DumpNetInfo: true, - EgressRedirectPort: s.egressRedirectPort(req.GetEgressGatewayAddress() != ""), + EgressRedirectPort: s.egressRedirectPort(req.GetEgressGateway() != nil), }); err != nil { return nil, fmt.Errorf("while setting up actor network: %w", err) } @@ -639,21 +639,24 @@ type actorEgress struct { jwt atunnel.ActorJWT } -func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { - if egressGatewayAddress == "" { +func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { + if gateway == nil { return nil, nil } - if egressGatewayAudience == "" { - return nil, fmt.Errorf("egress gateway audience is required with an egress gateway") + if gateway.GetAddress() == "" { + return nil, fmt.Errorf("egress gateway address is required") } - serverName, _, err := net.SplitHostPort(egressGatewayAddress) + if gateway.GetAudience() == "" { + return nil, fmt.Errorf("egress gateway audience is required") + } + serverName, _, err := net.SplitHostPort(gateway.GetAddress()) if err != nil { - return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) + return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) } // The worker credential authenticates both connections, but each peer is // verified against the trust domain that issued its serving certificate. gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, + GatewayAddress: gateway.GetAddress(), ServerName: serverName, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.egressGatewayTrustBundlePath, @@ -665,7 +668,7 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr SocketPath: ateompath.CredentialBrokerSocket, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.podIdentityTrustBundlePath, - Audience: egressGatewayAudience, + Audience: gateway.GetAudience(), }) if err != nil { return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index d6741545d..7de7485c3 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -321,21 +321,24 @@ type actorEgress struct { jwt atunnel.ActorJWT } -func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddress, egressGatewayAudience string) (*actorEgress, error) { - if egressGatewayAddress == "" { +func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { + if gateway == nil { return nil, nil } - if egressGatewayAudience == "" { - return nil, fmt.Errorf("egress gateway audience is required with an egress gateway") + if gateway.GetAddress() == "" { + return nil, fmt.Errorf("egress gateway address is required") } - serverName, _, err := net.SplitHostPort(egressGatewayAddress) + if gateway.GetAudience() == "" { + return nil, fmt.Errorf("egress gateway audience is required") + } + serverName, _, err := net.SplitHostPort(gateway.GetAddress()) if err != nil { - return nil, fmt.Errorf("invalid egress gateway address %q: %w", egressGatewayAddress, err) + return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) } // The worker credential authenticates both connections, but each peer is // verified against the trust domain that issued its serving certificate. gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: egressGatewayAddress, + GatewayAddress: gateway.GetAddress(), ServerName: serverName, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.egressGatewayTrustBundlePath, @@ -347,7 +350,7 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, egressGatewayAddr SocketPath: ateompath.CredentialBrokerSocket, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.podIdentityTrustBundlePath, - Audience: egressGatewayAudience, + Audience: gateway.GetAudience(), }) if err != nil { return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) diff --git a/cmd/ateom-microvm/restore.go b/cmd/ateom-microvm/restore.go index e9f3fa94f..583311680 100644 --- a/cmd/ateom-microvm/restore.go +++ b/cmd/ateom-microvm/restore.go @@ -70,8 +70,7 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - egressGatewayAddress: req.GetEgressGatewayAddress(), - egressGatewayAudience: req.GetEgressGatewayAudience(), + egressGateway: req.GetEgressGateway(), } restoreDir := ateompath.RestoreStateDir(p.actorUID) durableDir := ateompath.DurableDirVolumeMountsDir(p.actorUID) @@ -133,7 +132,7 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, templateNS, templateName := p.templateNS, p.templateName rr := s.resolveRuntime(p.assetPaths) - egress, err := s.prepareActorEgress(ctx, p.egressGatewayAddress, p.egressGatewayAudience) + egress, err := s.prepareActorEgress(ctx, p.egressGateway) if err != nil { return err } @@ -207,7 +206,7 @@ func (s *AteomService) restoreFullScope(ctx context.Context, p actorBootParams, InteriorNetNS: s.interiorNetNS, HostVethHWAddr: hostVethHWAddr, SweepInteriorLinks: true, - EgressRedirectPort: s.egressRedirectPort(p.egressGatewayAddress != ""), + EgressRedirectPort: s.egressRedirectPort(p.egressGateway != nil), }); err != nil { return fmt.Errorf("while setting up actor network: %w", err) } diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index ae6d36703..7fdea7f2b 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -212,8 +212,7 @@ func (s *AteomService) RunWorkload(ctx context.Context, req *ateompb.RunWorkload containers: req.GetSpec().GetContainers(), assetPaths: req.GetRuntimeAssetPaths(), - egressGatewayAddress: req.GetEgressGatewayAddress(), - egressGatewayAudience: req.GetEgressGatewayAudience(), + egressGateway: req.GetEgressGateway(), } s.actorLogger.EmitLifecycleLog("Actor starting", p.actorRef, p.actorUID, p.templateNS, p.templateName) @@ -235,10 +234,8 @@ type actorBootParams struct { templateName string containers []*ateompb.Container assetPaths map[string]string - // egressGatewayAddress is empty unless an egress gateway is configured, in - // which case actor TCP egress is redirected to atunnel's local listener. - egressGatewayAddress string - egressGatewayAudience string + // egressGateway is nil unless actor TCP should be redirected through atunnel. + egressGateway *ateompb.EgressGateway } // coldBootAttempts is how many times a cold boot is tried when the micro-VM @@ -296,7 +293,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re return fmt.Errorf("ateom-microvm requires %q and %q asset paths", assetKernel, assetImage) } rr := s.resolveRuntime(paths) - egress, err := s.prepareActorEgress(ctx, p.egressGatewayAddress, p.egressGatewayAudience) + egress, err := s.prepareActorEgress(ctx, p.egressGateway) if err != nil { return err } @@ -307,7 +304,7 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re InteriorNetNS: s.interiorNetNS, HostVethHWAddr: hostVethHWAddr, SweepInteriorLinks: true, - EgressRedirectPort: s.egressRedirectPort(p.egressGatewayAddress != ""), + EgressRedirectPort: s.egressRedirectPort(p.egressGateway != nil), }); err != nil { return fmt.Errorf("while setting up actor network: %w", err) } diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index 7385e1c44..d1f7e90db 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -310,12 +310,10 @@ type RunRequest struct { // fetches the relevant assets and records them with the actor's on-node state // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets *SandboxAssets `protobuf:"bytes,8,opt,name=sandbox_assets,json=sandboxAssets,proto3" json:"sandbox_assets,omitempty"` - // Configured together to enable tunneled egress for this activation. - EgressGatewayAddress *string `protobuf:"bytes,9,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` - // Logical PEP audience placed in the actor JWT, not a network address. - EgressGatewayAudience *string `protobuf:"bytes,10,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,9,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunRequest) Reset() { @@ -404,16 +402,64 @@ func (x *RunRequest) GetSandboxAssets() *SandboxAssets { return nil } -func (x *RunRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress +func (x *RunRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway + } + return nil +} + +// EgressGateway configures tunneled egress for one actor activation. +type EgressGateway struct { + state protoimpl.MessageState `protogen:"open.v1"` + // address is the remote gateway's host:port. + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + // audience is the logical PEP audience placed in the actor JWT. + Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressGateway) Reset() { + *x = EgressGateway{} + mi := &file_atelet_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressGateway) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressGateway) ProtoMessage() {} + +func (x *EgressGateway) ProtoReflect() protoreflect.Message { + mi := &file_atelet_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EgressGateway.ProtoReflect.Descriptor instead. +func (*EgressGateway) Descriptor() ([]byte, []int) { + return file_atelet_proto_rawDescGZIP(), []int{3} +} + +func (x *EgressGateway) GetAddress() string { + if x != nil { + return x.Address } return "" } -func (x *RunRequest) GetEgressGatewayAudience() string { - if x != nil && x.EgressGatewayAudience != nil { - return *x.EgressGatewayAudience +func (x *EgressGateway) GetAudience() string { + if x != nil { + return x.Audience } return "" } @@ -432,7 +478,7 @@ type AssetFile struct { func (x *AssetFile) Reset() { *x = AssetFile{} - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -444,7 +490,7 @@ func (x *AssetFile) String() string { func (*AssetFile) ProtoMessage() {} func (x *AssetFile) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[3] + mi := &file_atelet_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -457,7 +503,7 @@ func (x *AssetFile) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetFile.ProtoReflect.Descriptor instead. func (*AssetFile) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{3} + return file_atelet_proto_rawDescGZIP(), []int{4} } func (x *AssetFile) GetUrl() string { @@ -485,7 +531,7 @@ type ArchAssets struct { func (x *ArchAssets) Reset() { *x = ArchAssets{} - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -497,7 +543,7 @@ func (x *ArchAssets) String() string { func (*ArchAssets) ProtoMessage() {} func (x *ArchAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[4] + mi := &file_atelet_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -510,7 +556,7 @@ func (x *ArchAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use ArchAssets.ProtoReflect.Descriptor instead. func (*ArchAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{4} + return file_atelet_proto_rawDescGZIP(), []int{5} } func (x *ArchAssets) GetFiles() map[string]*AssetFile { @@ -534,7 +580,7 @@ type SandboxAssets struct { func (x *SandboxAssets) Reset() { *x = SandboxAssets{} - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +592,7 @@ func (x *SandboxAssets) String() string { func (*SandboxAssets) ProtoMessage() {} func (x *SandboxAssets) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[5] + mi := &file_atelet_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,7 +605,7 @@ func (x *SandboxAssets) ProtoReflect() protoreflect.Message { // Deprecated: Use SandboxAssets.ProtoReflect.Descriptor instead. func (*SandboxAssets) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{5} + return file_atelet_proto_rawDescGZIP(), []int{6} } func (x *SandboxAssets) GetSandboxClass() string { @@ -588,7 +634,7 @@ type WorkloadSpec struct { func (x *WorkloadSpec) Reset() { *x = WorkloadSpec{} - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -600,7 +646,7 @@ func (x *WorkloadSpec) String() string { func (*WorkloadSpec) ProtoMessage() {} func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[6] + mi := &file_atelet_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -613,7 +659,7 @@ func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadSpec.ProtoReflect.Descriptor instead. func (*WorkloadSpec) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{6} + return file_atelet_proto_rawDescGZIP(), []int{7} } func (x *WorkloadSpec) GetContainers() []*Container { @@ -645,7 +691,7 @@ type DurableDirVolume struct { func (x *DurableDirVolume) Reset() { *x = DurableDirVolume{} - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -657,7 +703,7 @@ func (x *DurableDirVolume) String() string { func (*DurableDirVolume) ProtoMessage() {} func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[7] + mi := &file_atelet_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -670,7 +716,7 @@ func (x *DurableDirVolume) ProtoReflect() protoreflect.Message { // Deprecated: Use DurableDirVolume.ProtoReflect.Descriptor instead. func (*DurableDirVolume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{7} + return file_atelet_proto_rawDescGZIP(), []int{8} } type ExternalVolumeSource struct { @@ -683,7 +729,7 @@ type ExternalVolumeSource struct { func (x *ExternalVolumeSource) Reset() { *x = ExternalVolumeSource{} - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -695,7 +741,7 @@ func (x *ExternalVolumeSource) String() string { func (*ExternalVolumeSource) ProtoMessage() {} func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[8] + mi := &file_atelet_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -708,7 +754,7 @@ func (x *ExternalVolumeSource) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalVolumeSource.ProtoReflect.Descriptor instead. func (*ExternalVolumeSource) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{8} + return file_atelet_proto_rawDescGZIP(), []int{9} } func (x *ExternalVolumeSource) GetStorageVolumeId() string { @@ -740,7 +786,7 @@ type Volume struct { func (x *Volume) Reset() { *x = Volume{} - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -752,7 +798,7 @@ func (x *Volume) String() string { func (*Volume) ProtoMessage() {} func (x *Volume) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[9] + mi := &file_atelet_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -765,7 +811,7 @@ func (x *Volume) ProtoReflect() protoreflect.Message { // Deprecated: Use Volume.ProtoReflect.Descriptor instead. func (*Volume) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{9} + return file_atelet_proto_rawDescGZIP(), []int{10} } func (x *Volume) GetName() string { @@ -833,7 +879,7 @@ type VolumeMount struct { func (x *VolumeMount) Reset() { *x = VolumeMount{} - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -845,7 +891,7 @@ func (x *VolumeMount) String() string { func (*VolumeMount) ProtoMessage() {} func (x *VolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[10] + mi := &file_atelet_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -858,7 +904,7 @@ func (x *VolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeMount.ProtoReflect.Descriptor instead. func (*VolumeMount) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{10} + return file_atelet_proto_rawDescGZIP(), []int{11} } func (x *VolumeMount) GetName() string { @@ -890,7 +936,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -902,7 +948,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[11] + mi := &file_atelet_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -915,7 +961,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{11} + return file_atelet_proto_rawDescGZIP(), []int{12} } func (x *Container) GetName() string { @@ -977,7 +1023,7 @@ type EnvEntry struct { func (x *EnvEntry) Reset() { *x = EnvEntry{} - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -989,7 +1035,7 @@ func (x *EnvEntry) String() string { func (*EnvEntry) ProtoMessage() {} func (x *EnvEntry) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[12] + mi := &file_atelet_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1002,7 +1048,7 @@ func (x *EnvEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use EnvEntry.ProtoReflect.Descriptor instead. func (*EnvEntry) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{12} + return file_atelet_proto_rawDescGZIP(), []int{13} } func (x *EnvEntry) GetName() string { @@ -1030,7 +1076,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1042,7 +1088,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[13] + mi := &file_atelet_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1055,7 +1101,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{13} + return file_atelet_proto_rawDescGZIP(), []int{14} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -1078,7 +1124,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1090,7 +1136,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[14] + mi := &file_atelet_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1103,7 +1149,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{14} + return file_atelet_proto_rawDescGZIP(), []int{15} } func (x *HTTPGetAction) GetPath() string { @@ -1128,7 +1174,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1140,7 +1186,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[15] + mi := &file_atelet_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1153,7 +1199,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{15} + return file_atelet_proto_rawDescGZIP(), []int{16} } type LocalCheckpointConfiguration struct { @@ -1167,7 +1213,7 @@ type LocalCheckpointConfiguration struct { func (x *LocalCheckpointConfiguration) Reset() { *x = LocalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1179,7 +1225,7 @@ func (x *LocalCheckpointConfiguration) String() string { func (*LocalCheckpointConfiguration) ProtoMessage() {} func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[16] + mi := &file_atelet_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1192,7 +1238,7 @@ func (x *LocalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*LocalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{16} + return file_atelet_proto_rawDescGZIP(), []int{17} } func (x *LocalCheckpointConfiguration) GetSnapshotPrefix() string { @@ -1221,7 +1267,7 @@ type ExternalCheckpointConfiguration struct { func (x *ExternalCheckpointConfiguration) Reset() { *x = ExternalCheckpointConfiguration{} - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1233,7 +1279,7 @@ func (x *ExternalCheckpointConfiguration) String() string { func (*ExternalCheckpointConfiguration) ProtoMessage() {} func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[17] + mi := &file_atelet_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1246,7 +1292,7 @@ func (x *ExternalCheckpointConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use ExternalCheckpointConfiguration.ProtoReflect.Descriptor instead. func (*ExternalCheckpointConfiguration) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{17} + return file_atelet_proto_rawDescGZIP(), []int{18} } func (x *ExternalCheckpointConfiguration) GetSnapshotUriPrefix() string { @@ -1284,7 +1330,7 @@ type CheckpointRequest struct { func (x *CheckpointRequest) Reset() { *x = CheckpointRequest{} - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1296,7 +1342,7 @@ func (x *CheckpointRequest) String() string { func (*CheckpointRequest) ProtoMessage() {} func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[18] + mi := &file_atelet_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1309,7 +1355,7 @@ func (x *CheckpointRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointRequest.ProtoReflect.Descriptor instead. func (*CheckpointRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{18} + return file_atelet_proto_rawDescGZIP(), []int{19} } func (x *CheckpointRequest) GetTargetAteomUid() string { @@ -1424,7 +1470,7 @@ type CheckpointResponse struct { func (x *CheckpointResponse) Reset() { *x = CheckpointResponse{} - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1436,7 +1482,7 @@ func (x *CheckpointResponse) String() string { func (*CheckpointResponse) ProtoMessage() {} func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[19] + mi := &file_atelet_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1449,7 +1495,7 @@ func (x *CheckpointResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointResponse.ProtoReflect.Descriptor instead. func (*CheckpointResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{19} + return file_atelet_proto_rawDescGZIP(), []int{20} } type RestoreRequest struct { @@ -1481,17 +1527,15 @@ type RestoreRequest struct { // of the `config` oneof: the actor's snapshot may be local (a pause // checkpoint) while the golden snapshot is always external. GoldenSnapshotUriPrefix string `protobuf:"bytes,12,opt,name=golden_snapshot_uri_prefix,json=goldenSnapshotUriPrefix,proto3" json:"golden_snapshot_uri_prefix,omitempty"` - // Configured together to enable tunneled egress for this activation. - EgressGatewayAddress *string `protobuf:"bytes,13,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` - // Logical PEP audience placed in the actor JWT, not a network address. - EgressGatewayAudience *string `protobuf:"bytes,14,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,13,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreRequest) Reset() { *x = RestoreRequest{} - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1503,7 +1547,7 @@ func (x *RestoreRequest) String() string { func (*RestoreRequest) ProtoMessage() {} func (x *RestoreRequest) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[20] + mi := &file_atelet_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1516,7 +1560,7 @@ func (x *RestoreRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. func (*RestoreRequest) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{20} + return file_atelet_proto_rawDescGZIP(), []int{21} } func (x *RestoreRequest) GetTargetAteomUid() string { @@ -1614,18 +1658,11 @@ func (x *RestoreRequest) GetGoldenSnapshotUriPrefix() string { return "" } -func (x *RestoreRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress - } - return "" -} - -func (x *RestoreRequest) GetEgressGatewayAudience() string { - if x != nil && x.EgressGatewayAudience != nil { - return *x.EgressGatewayAudience +func (x *RestoreRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway } - return "" + return nil } type isRestoreRequest_Config interface { @@ -1652,7 +1689,7 @@ type RestoreResponse struct { func (x *RestoreResponse) Reset() { *x = RestoreResponse{} - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1664,7 +1701,7 @@ func (x *RestoreResponse) String() string { func (*RestoreResponse) ProtoMessage() {} func (x *RestoreResponse) ProtoReflect() protoreflect.Message { - mi := &file_atelet_proto_msgTypes[21] + mi := &file_atelet_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1677,7 +1714,7 @@ func (x *RestoreResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreResponse.ProtoReflect.Descriptor instead. func (*RestoreResponse) Descriptor() ([]byte, []int) { - return file_atelet_proto_rawDescGZIP(), []int{21} + return file_atelet_proto_rawDescGZIP(), []int{22} } var File_atelet_proto protoreflect.FileDescriptor @@ -1689,7 +1726,7 @@ const file_atelet_proto_rawDesc = "" + "\baudience\x18\x01 \x01(\tR\baudience\"x\n" + "\x14MintActorJWTResponse\x12\x1b\n" + "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\x12C\n" + - "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\x8f\x04\n" + + "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\x9e\x03\n" + "\n" + "RunRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + @@ -1700,12 +1737,11 @@ const file_atelet_proto_rawDesc = "" + "\x18actor_template_namespace\x18\x05 \x01(\tR\x16actorTemplateNamespace\x12.\n" + "\x13actor_template_name\x18\x06 \x01(\tR\x11actorTemplateName\x12(\n" + "\x04spec\x18\a \x01(\v2\x14.atelet.WorkloadSpecR\x04spec\x12<\n" + - "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x129\n" + - "\x16egress_gateway_address\x18\t \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + - "\x17egress_gateway_audience\x18\n" + - " \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01B\x19\n" + - "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audience\"5\n" + + "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x12<\n" + + "\x0eegress_gateway\x18\t \x01(\v2\x15.atelet.EgressGatewayR\regressGateway\"E\n" + + "\rEgressGateway\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" + + "\baudience\x18\x02 \x01(\tR\baudience\"5\n" + "\tAssetFile\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x8e\x01\n" + @@ -1781,7 +1817,7 @@ const file_atelet_proto_rawDesc = "" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scopeB\b\n" + "\x06config\"\x14\n" + - "\x12CheckpointResponse\"\x94\x06\n" + + "\x12CheckpointResponse\"\xa3\x05\n" + "\x0eRestoreRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + @@ -1796,12 +1832,9 @@ const file_atelet_proto_rawDesc = "" + "\x0fexternal_config\x18\n" + " \x01(\v2'.atelet.ExternalCheckpointConfigurationH\x00R\x0eexternalConfig\x12+\n" + "\x05scope\x18\v \x01(\x0e2\x15.atelet.SnapshotScopeR\x05scope\x12;\n" + - "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefix\x129\n" + - "\x16egress_gateway_address\x18\r \x01(\tH\x01R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + - "\x17egress_gateway_audience\x18\x0e \x01(\tH\x02R\x15egressGatewayAudience\x88\x01\x01B\b\n" + - "\x06configB\x19\n" + - "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audience\"\x11\n" + + "\x1agolden_snapshot_uri_prefix\x18\f \x01(\tR\x17goldenSnapshotUriPrefix\x12<\n" + + "\x0eegress_gateway\x18\r \x01(\v2\x15.atelet.EgressGatewayR\regressGatewayB\b\n" + + "\x06config\"\x11\n" + "\x0fRestoreResponse*`\n" + "\n" + "VolumeType\x12\x1b\n" + @@ -1838,7 +1871,7 @@ func file_atelet_proto_rawDescGZIP() []byte { } var file_atelet_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_atelet_proto_msgTypes = make([]protoimpl.MessageInfo, 25) var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType @@ -1846,69 +1879,72 @@ var file_atelet_proto_goTypes = []any{ (*MintActorJWTRequest)(nil), // 3: atelet.MintActorJWTRequest (*MintActorJWTResponse)(nil), // 4: atelet.MintActorJWTResponse (*RunRequest)(nil), // 5: atelet.RunRequest - (*AssetFile)(nil), // 6: atelet.AssetFile - (*ArchAssets)(nil), // 7: atelet.ArchAssets - (*SandboxAssets)(nil), // 8: atelet.SandboxAssets - (*WorkloadSpec)(nil), // 9: atelet.WorkloadSpec - (*DurableDirVolume)(nil), // 10: atelet.DurableDirVolume - (*ExternalVolumeSource)(nil), // 11: atelet.ExternalVolumeSource - (*Volume)(nil), // 12: atelet.Volume - (*VolumeMount)(nil), // 13: atelet.VolumeMount - (*Container)(nil), // 14: atelet.Container - (*EnvEntry)(nil), // 15: atelet.EnvEntry - (*Readyz)(nil), // 16: atelet.Readyz - (*HTTPGetAction)(nil), // 17: atelet.HTTPGetAction - (*RunResponse)(nil), // 18: atelet.RunResponse - (*LocalCheckpointConfiguration)(nil), // 19: atelet.LocalCheckpointConfiguration - (*ExternalCheckpointConfiguration)(nil), // 20: atelet.ExternalCheckpointConfiguration - (*CheckpointRequest)(nil), // 21: atelet.CheckpointRequest - (*CheckpointResponse)(nil), // 22: atelet.CheckpointResponse - (*RestoreRequest)(nil), // 23: atelet.RestoreRequest - (*RestoreResponse)(nil), // 24: atelet.RestoreResponse - nil, // 25: atelet.ArchAssets.FilesEntry - nil, // 26: atelet.SandboxAssets.AssetsEntry - (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp + (*EgressGateway)(nil), // 6: atelet.EgressGateway + (*AssetFile)(nil), // 7: atelet.AssetFile + (*ArchAssets)(nil), // 8: atelet.ArchAssets + (*SandboxAssets)(nil), // 9: atelet.SandboxAssets + (*WorkloadSpec)(nil), // 10: atelet.WorkloadSpec + (*DurableDirVolume)(nil), // 11: atelet.DurableDirVolume + (*ExternalVolumeSource)(nil), // 12: atelet.ExternalVolumeSource + (*Volume)(nil), // 13: atelet.Volume + (*VolumeMount)(nil), // 14: atelet.VolumeMount + (*Container)(nil), // 15: atelet.Container + (*EnvEntry)(nil), // 16: atelet.EnvEntry + (*Readyz)(nil), // 17: atelet.Readyz + (*HTTPGetAction)(nil), // 18: atelet.HTTPGetAction + (*RunResponse)(nil), // 19: atelet.RunResponse + (*LocalCheckpointConfiguration)(nil), // 20: atelet.LocalCheckpointConfiguration + (*ExternalCheckpointConfiguration)(nil), // 21: atelet.ExternalCheckpointConfiguration + (*CheckpointRequest)(nil), // 22: atelet.CheckpointRequest + (*CheckpointResponse)(nil), // 23: atelet.CheckpointResponse + (*RestoreRequest)(nil), // 24: atelet.RestoreRequest + (*RestoreResponse)(nil), // 25: atelet.RestoreResponse + nil, // 26: atelet.ArchAssets.FilesEntry + nil, // 27: atelet.SandboxAssets.AssetsEntry + (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp } var file_atelet_proto_depIdxs = []int32{ - 27, // 0: atelet.MintActorJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp - 9, // 1: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec - 8, // 2: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets - 25, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 26, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 14, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 12, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 0, // 7: atelet.Volume.type:type_name -> atelet.VolumeType - 10, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 11, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 15, // 10: atelet.Container.env:type_name -> atelet.EnvEntry - 16, // 11: atelet.Container.readyz:type_name -> atelet.Readyz - 13, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 17, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 9, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 19, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 20, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 9, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 19, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 20, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 6, // 24: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 7, // 25: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 26: atelet.CredentialBroker.MintActorJWT:input_type -> atelet.MintActorJWTRequest - 5, // 27: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 21, // 28: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 23, // 29: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 4, // 30: atelet.CredentialBroker.MintActorJWT:output_type -> atelet.MintActorJWTResponse - 18, // 31: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 22, // 32: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 24, // 33: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 30, // [30:34] is the sub-list for method output_type - 26, // [26:30] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 28, // 0: atelet.MintActorJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp + 10, // 1: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec + 9, // 2: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets + 6, // 3: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway + 26, // 4: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 27, // 5: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 15, // 6: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 13, // 7: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 0, // 8: atelet.Volume.type:type_name -> atelet.VolumeType + 11, // 9: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 10: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 16, // 11: atelet.Container.env:type_name -> atelet.EnvEntry + 17, // 12: atelet.Container.readyz:type_name -> atelet.Readyz + 14, // 13: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 18, // 14: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 15: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 16: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 20, // 17: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 18: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 19: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 10, // 20: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 21: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 20, // 22: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 23: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 24: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 25: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 26: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 27: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 28: atelet.CredentialBroker.MintActorJWT:input_type -> atelet.MintActorJWTRequest + 5, // 29: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 22, // 30: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 24, // 31: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 4, // 32: atelet.CredentialBroker.MintActorJWT:output_type -> atelet.MintActorJWTResponse + 19, // 33: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 23, // 34: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 25, // 35: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 32, // [32:36] is the sub-list for method output_type + 28, // [28:32] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_atelet_proto_init() } @@ -1916,16 +1952,15 @@ func file_atelet_proto_init() { if File_atelet_proto != nil { return } - file_atelet_proto_msgTypes[2].OneofWrappers = []any{} - file_atelet_proto_msgTypes[9].OneofWrappers = []any{ + file_atelet_proto_msgTypes[10].OneofWrappers = []any{ (*Volume_DurableDir)(nil), (*Volume_External)(nil), } - file_atelet_proto_msgTypes[18].OneofWrappers = []any{ + file_atelet_proto_msgTypes[19].OneofWrappers = []any{ (*CheckpointRequest_LocalConfig)(nil), (*CheckpointRequest_ExternalConfig)(nil), } - file_atelet_proto_msgTypes[20].OneofWrappers = []any{ + file_atelet_proto_msgTypes[21].OneofWrappers = []any{ (*RestoreRequest_LocalConfig)(nil), (*RestoreRequest_ExternalConfig)(nil), } @@ -1935,7 +1970,7 @@ func file_atelet_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_atelet_proto_rawDesc), len(file_atelet_proto_rawDesc)), NumEnums: 3, - NumMessages: 24, + NumMessages: 25, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 50ffcb20d..380df47f3 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -65,10 +65,16 @@ message RunRequest { // so a later Checkpoint can pin the same version into the snapshot manifest. SandboxAssets sandbox_assets = 8; - // Configured together to enable tunneled egress for this activation. - optional string egress_gateway_address = 9; - // Logical PEP audience placed in the actor JWT, not a network address. - optional string egress_gateway_audience = 10; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 9; +} + +// EgressGateway configures tunneled egress for one actor activation. +message EgressGateway { + // address is the remote gateway's host:port. + string address = 1; + // audience is the logical PEP audience placed in the actor JWT. + string audience = 2; } // AssetFile is one content-addressed file atelet fetches for a sandbox runtime @@ -276,10 +282,8 @@ message RestoreRequest { // checkpoint) while the golden snapshot is always external. string golden_snapshot_uri_prefix = 12; - // Configured together to enable tunneled egress for this activation. - optional string egress_gateway_address = 13; - // Logical PEP audience placed in the actor JWT, not a network address. - optional string egress_gateway_audience = 14; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 13; } message RestoreResponse { diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index 9a71a52c9..2dcdfc20f 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -112,13 +112,10 @@ type RunWorkloadRequest struct { // to the local on-disk path atelet fetched it to (content-addressed, like // runsc_path). Empty for the gVisor runtime, which uses runsc_path. RuntimeAssetPaths map[string]string `protobuf:"bytes,8,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - EgressGatewayAddress *string `protobuf:"bytes,10,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` - // Logical PEP audience placed in the actor JWT. Required with the address. - EgressGatewayAudience *string `protobuf:"bytes,11,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,10,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RunWorkloadRequest) Reset() { @@ -207,16 +204,64 @@ func (x *RunWorkloadRequest) GetRuntimeAssetPaths() map[string]string { return nil } -func (x *RunWorkloadRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress +func (x *RunWorkloadRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway + } + return nil +} + +// EgressGateway configures tunneled egress for one actor activation. +type EgressGateway struct { + state protoimpl.MessageState `protogen:"open.v1"` + // address is the remote gateway's host:port. + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + // audience is the logical PEP audience placed in the actor JWT. + Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EgressGateway) Reset() { + *x = EgressGateway{} + mi := &file_ateom_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EgressGateway) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EgressGateway) ProtoMessage() {} + +func (x *EgressGateway) ProtoReflect() protoreflect.Message { + mi := &file_ateom_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EgressGateway.ProtoReflect.Descriptor instead. +func (*EgressGateway) Descriptor() ([]byte, []int) { + return file_ateom_proto_rawDescGZIP(), []int{1} +} + +func (x *EgressGateway) GetAddress() string { + if x != nil { + return x.Address } return "" } -func (x *RunWorkloadRequest) GetEgressGatewayAudience() string { - if x != nil && x.EgressGatewayAudience != nil { - return *x.EgressGatewayAudience +func (x *EgressGateway) GetAudience() string { + if x != nil { + return x.Audience } return "" } @@ -231,7 +276,7 @@ type WorkloadSpec struct { func (x *WorkloadSpec) Reset() { *x = WorkloadSpec{} - mi := &file_ateom_proto_msgTypes[1] + mi := &file_ateom_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -243,7 +288,7 @@ func (x *WorkloadSpec) String() string { func (*WorkloadSpec) ProtoMessage() {} func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[1] + mi := &file_ateom_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -256,7 +301,7 @@ func (x *WorkloadSpec) ProtoReflect() protoreflect.Message { // Deprecated: Use WorkloadSpec.ProtoReflect.Descriptor instead. func (*WorkloadSpec) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{1} + return file_ateom_proto_rawDescGZIP(), []int{2} } func (x *WorkloadSpec) GetContainers() []*Container { @@ -279,7 +324,7 @@ type Container struct { func (x *Container) Reset() { *x = Container{} - mi := &file_ateom_proto_msgTypes[2] + mi := &file_ateom_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -291,7 +336,7 @@ func (x *Container) String() string { func (*Container) ProtoMessage() {} func (x *Container) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[2] + mi := &file_ateom_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -304,7 +349,7 @@ func (x *Container) ProtoReflect() protoreflect.Message { // Deprecated: Use Container.ProtoReflect.Descriptor instead. func (*Container) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{2} + return file_ateom_proto_rawDescGZIP(), []int{3} } func (x *Container) GetName() string { @@ -342,7 +387,7 @@ type DurableDirVolumeMount struct { func (x *DurableDirVolumeMount) Reset() { *x = DurableDirVolumeMount{} - mi := &file_ateom_proto_msgTypes[3] + mi := &file_ateom_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -354,7 +399,7 @@ func (x *DurableDirVolumeMount) String() string { func (*DurableDirVolumeMount) ProtoMessage() {} func (x *DurableDirVolumeMount) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[3] + mi := &file_ateom_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -367,7 +412,7 @@ func (x *DurableDirVolumeMount) ProtoReflect() protoreflect.Message { // Deprecated: Use DurableDirVolumeMount.ProtoReflect.Descriptor instead. func (*DurableDirVolumeMount) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{3} + return file_ateom_proto_rawDescGZIP(), []int{4} } func (x *DurableDirVolumeMount) GetVolumeName() string { @@ -395,7 +440,7 @@ type Readyz struct { func (x *Readyz) Reset() { *x = Readyz{} - mi := &file_ateom_proto_msgTypes[4] + mi := &file_ateom_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -407,7 +452,7 @@ func (x *Readyz) String() string { func (*Readyz) ProtoMessage() {} func (x *Readyz) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[4] + mi := &file_ateom_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -420,7 +465,7 @@ func (x *Readyz) ProtoReflect() protoreflect.Message { // Deprecated: Use Readyz.ProtoReflect.Descriptor instead. func (*Readyz) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{4} + return file_ateom_proto_rawDescGZIP(), []int{5} } func (x *Readyz) GetHttpGet() *HTTPGetAction { @@ -443,7 +488,7 @@ type HTTPGetAction struct { func (x *HTTPGetAction) Reset() { *x = HTTPGetAction{} - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -455,7 +500,7 @@ func (x *HTTPGetAction) String() string { func (*HTTPGetAction) ProtoMessage() {} func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[5] + mi := &file_ateom_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -468,7 +513,7 @@ func (x *HTTPGetAction) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGetAction.ProtoReflect.Descriptor instead. func (*HTTPGetAction) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{5} + return file_ateom_proto_rawDescGZIP(), []int{6} } func (x *HTTPGetAction) GetPath() string { @@ -493,7 +538,7 @@ type RunWorkloadResponse struct { func (x *RunWorkloadResponse) Reset() { *x = RunWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -505,7 +550,7 @@ func (x *RunWorkloadResponse) String() string { func (*RunWorkloadResponse) ProtoMessage() {} func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[6] + mi := &file_ateom_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -518,7 +563,7 @@ func (x *RunWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunWorkloadResponse.ProtoReflect.Descriptor instead. func (*RunWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{6} + return file_ateom_proto_rawDescGZIP(), []int{7} } type CheckpointWorkloadRequest struct { @@ -550,7 +595,7 @@ type CheckpointWorkloadRequest struct { func (x *CheckpointWorkloadRequest) Reset() { *x = CheckpointWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -562,7 +607,7 @@ func (x *CheckpointWorkloadRequest) String() string { func (*CheckpointWorkloadRequest) ProtoMessage() {} func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[7] + mi := &file_ateom_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -575,7 +620,7 @@ func (x *CheckpointWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadRequest.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{7} + return file_ateom_proto_rawDescGZIP(), []int{8} } func (x *CheckpointWorkloadRequest) GetAtespace() string { @@ -660,7 +705,7 @@ type CheckpointWorkloadResponse struct { func (x *CheckpointWorkloadResponse) Reset() { *x = CheckpointWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -672,7 +717,7 @@ func (x *CheckpointWorkloadResponse) String() string { func (*CheckpointWorkloadResponse) ProtoMessage() {} func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[8] + mi := &file_ateom_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -685,7 +730,7 @@ func (x *CheckpointWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckpointWorkloadResponse.ProtoReflect.Descriptor instead. func (*CheckpointWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{8} + return file_ateom_proto_rawDescGZIP(), []int{9} } func (x *CheckpointWorkloadResponse) GetSnapshotFiles() []string { @@ -711,22 +756,19 @@ type RestoreWorkloadRequest struct { RuntimeAssetPaths map[string]string `protobuf:"bytes,9,rep,name=runtime_asset_paths,json=runtimeAssetPaths,proto3" json:"runtime_asset_paths,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // What content to restore from the snapshot. Scope SnapshotScope `protobuf:"varint,10,opt,name=scope,proto3,enum=ateom.SnapshotScope" json:"scope,omitempty"` - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - EgressGatewayAddress *string `protobuf:"bytes,12,opt,name=egress_gateway_address,json=egressGatewayAddress,proto3,oneof" json:"egress_gateway_address,omitempty"` + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway *EgressGateway `protobuf:"bytes,12,opt,name=egress_gateway,json=egressGateway,proto3" json:"egress_gateway,omitempty"` // The object storage URI prefix of the ActorTemplate's golden snapshot. // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the // snapshot_uri_prefix contract (field 8). GoldenSnapshotUriPrefix string `protobuf:"bytes,13,opt,name=golden_snapshot_uri_prefix,json=goldenSnapshotUriPrefix,proto3" json:"golden_snapshot_uri_prefix,omitempty"` - // Logical PEP audience placed in the actor JWT. Required with the address. - EgressGatewayAudience *string `protobuf:"bytes,14,opt,name=egress_gateway_audience,json=egressGatewayAudience,proto3,oneof" json:"egress_gateway_audience,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RestoreWorkloadRequest) Reset() { *x = RestoreWorkloadRequest{} - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -738,7 +780,7 @@ func (x *RestoreWorkloadRequest) String() string { func (*RestoreWorkloadRequest) ProtoMessage() {} func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[9] + mi := &file_ateom_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -751,7 +793,7 @@ func (x *RestoreWorkloadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadRequest.ProtoReflect.Descriptor instead. func (*RestoreWorkloadRequest) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{9} + return file_ateom_proto_rawDescGZIP(), []int{10} } func (x *RestoreWorkloadRequest) GetAtespace() string { @@ -824,11 +866,11 @@ func (x *RestoreWorkloadRequest) GetScope() SnapshotScope { return SnapshotScope_SNAPSHOT_SCOPE_UNSPECIFIED } -func (x *RestoreWorkloadRequest) GetEgressGatewayAddress() string { - if x != nil && x.EgressGatewayAddress != nil { - return *x.EgressGatewayAddress +func (x *RestoreWorkloadRequest) GetEgressGateway() *EgressGateway { + if x != nil { + return x.EgressGateway } - return "" + return nil } func (x *RestoreWorkloadRequest) GetGoldenSnapshotUriPrefix() string { @@ -838,13 +880,6 @@ func (x *RestoreWorkloadRequest) GetGoldenSnapshotUriPrefix() string { return "" } -func (x *RestoreWorkloadRequest) GetEgressGatewayAudience() string { - if x != nil && x.EgressGatewayAudience != nil { - return *x.EgressGatewayAudience - } - return "" -} - type RestoreWorkloadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -853,7 +888,7 @@ type RestoreWorkloadResponse struct { func (x *RestoreWorkloadResponse) Reset() { *x = RestoreWorkloadResponse{} - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -865,7 +900,7 @@ func (x *RestoreWorkloadResponse) String() string { func (*RestoreWorkloadResponse) ProtoMessage() {} func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { - mi := &file_ateom_proto_msgTypes[10] + mi := &file_ateom_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -878,14 +913,14 @@ func (x *RestoreWorkloadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreWorkloadResponse.ProtoReflect.Descriptor instead. func (*RestoreWorkloadResponse) Descriptor() ([]byte, []int) { - return file_ateom_proto_rawDescGZIP(), []int{10} + return file_ateom_proto_rawDescGZIP(), []int{11} } var File_ateom_proto protoreflect.FileDescriptor const file_ateom_proto_rawDesc = "" + "\n" + - "\vateom.proto\x12\x05ateom\"\xf5\x04\n" + + "\vateom.proto\x12\x05ateom\"\x83\x04\n" + "\x12RunWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -896,15 +931,15 @@ const file_ateom_proto_rawDesc = "" + "\n" + "runsc_path\x18\x06 \x01(\tR\trunscPath\x12'\n" + "\x04spec\x18\a \x01(\v2\x13.ateom.WorkloadSpecR\x04spec\x12`\n" + - "\x13runtime_asset_paths\x18\b \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x129\n" + - "\x16egress_gateway_address\x18\n" + - " \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + - "\x17egress_gateway_audience\x18\v \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01\x1aD\n" + + "\x13runtime_asset_paths\x18\b \x03(\v20.ateom.RunWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12;\n" + + "\x0eegress_gateway\x18\n" + + " \x01(\v2\x14.ateom.EgressGatewayR\regressGateway\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audience\"@\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"E\n" + + "\rEgressGateway\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" + + "\baudience\x18\x02 \x01(\tR\baudience\"@\n" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + @@ -942,7 +977,7 @@ const file_ateom_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"C\n" + "\x1aCheckpointWorkloadResponse\x12%\n" + - "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\x96\x06\n" + + "\x0esnapshot_files\x18\x01 \x03(\tR\rsnapshotFiles\"\xa4\x05\n" + "\x16RestoreWorkloadRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + @@ -956,15 +991,12 @@ const file_ateom_proto_rawDesc = "" + "\x13snapshot_uri_prefix\x18\b \x01(\tR\x11snapshotUriPrefix\x12d\n" + "\x13runtime_asset_paths\x18\t \x03(\v24.ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntryR\x11runtimeAssetPaths\x12*\n" + "\x05scope\x18\n" + - " \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x129\n" + - "\x16egress_gateway_address\x18\f \x01(\tH\x00R\x14egressGatewayAddress\x88\x01\x01\x12;\n" + - "\x1agolden_snapshot_uri_prefix\x18\r \x01(\tR\x17goldenSnapshotUriPrefix\x12;\n" + - "\x17egress_gateway_audience\x18\x0e \x01(\tH\x01R\x15egressGatewayAudience\x88\x01\x01\x1aD\n" + + " \x01(\x0e2\x14.ateom.SnapshotScopeR\x05scope\x12;\n" + + "\x0eegress_gateway\x18\f \x01(\v2\x14.ateom.EgressGatewayR\regressGateway\x12;\n" + + "\x1agolden_snapshot_uri_prefix\x18\r \x01(\tR\x17goldenSnapshotUriPrefix\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + - "\x17_egress_gateway_addressB\x1a\n" + - "\x18_egress_gateway_audience\"\x19\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x19\n" + "\x17RestoreWorkloadResponse*\x84\x01\n" + "\rSnapshotScope\x12\x1e\n" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + @@ -989,48 +1021,51 @@ func file_ateom_proto_rawDescGZIP() []byte { } var file_ateom_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_ateom_proto_msgTypes = make([]protoimpl.MessageInfo, 15) var file_ateom_proto_goTypes = []any{ (SnapshotScope)(0), // 0: ateom.SnapshotScope (*RunWorkloadRequest)(nil), // 1: ateom.RunWorkloadRequest - (*WorkloadSpec)(nil), // 2: ateom.WorkloadSpec - (*Container)(nil), // 3: ateom.Container - (*DurableDirVolumeMount)(nil), // 4: ateom.DurableDirVolumeMount - (*Readyz)(nil), // 5: ateom.Readyz - (*HTTPGetAction)(nil), // 6: ateom.HTTPGetAction - (*RunWorkloadResponse)(nil), // 7: ateom.RunWorkloadResponse - (*CheckpointWorkloadRequest)(nil), // 8: ateom.CheckpointWorkloadRequest - (*CheckpointWorkloadResponse)(nil), // 9: ateom.CheckpointWorkloadResponse - (*RestoreWorkloadRequest)(nil), // 10: ateom.RestoreWorkloadRequest - (*RestoreWorkloadResponse)(nil), // 11: ateom.RestoreWorkloadResponse - nil, // 12: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry - nil, // 13: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - nil, // 14: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + (*EgressGateway)(nil), // 2: ateom.EgressGateway + (*WorkloadSpec)(nil), // 3: ateom.WorkloadSpec + (*Container)(nil), // 4: ateom.Container + (*DurableDirVolumeMount)(nil), // 5: ateom.DurableDirVolumeMount + (*Readyz)(nil), // 6: ateom.Readyz + (*HTTPGetAction)(nil), // 7: ateom.HTTPGetAction + (*RunWorkloadResponse)(nil), // 8: ateom.RunWorkloadResponse + (*CheckpointWorkloadRequest)(nil), // 9: ateom.CheckpointWorkloadRequest + (*CheckpointWorkloadResponse)(nil), // 10: ateom.CheckpointWorkloadResponse + (*RestoreWorkloadRequest)(nil), // 11: ateom.RestoreWorkloadRequest + (*RestoreWorkloadResponse)(nil), // 12: ateom.RestoreWorkloadResponse + nil, // 13: ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + nil, // 14: ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + nil, // 15: ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry } var file_ateom_proto_depIdxs = []int32{ - 2, // 0: ateom.RunWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 12, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry - 3, // 2: ateom.WorkloadSpec.containers:type_name -> ateom.Container - 5, // 3: ateom.Container.readyz:type_name -> ateom.Readyz - 4, // 4: ateom.Container.durable_dir_volume_mounts:type_name -> ateom.DurableDirVolumeMount - 6, // 5: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction - 2, // 6: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 13, // 7: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry - 0, // 8: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 2, // 9: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec - 14, // 10: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry - 0, // 11: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope - 1, // 12: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest - 8, // 13: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest - 10, // 14: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest - 7, // 15: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse - 9, // 16: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse - 11, // 17: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse - 15, // [15:18] is the sub-list for method output_type - 12, // [12:15] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 3, // 0: ateom.RunWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 13, // 1: ateom.RunWorkloadRequest.runtime_asset_paths:type_name -> ateom.RunWorkloadRequest.RuntimeAssetPathsEntry + 2, // 2: ateom.RunWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway + 4, // 3: ateom.WorkloadSpec.containers:type_name -> ateom.Container + 6, // 4: ateom.Container.readyz:type_name -> ateom.Readyz + 5, // 5: ateom.Container.durable_dir_volume_mounts:type_name -> ateom.DurableDirVolumeMount + 7, // 6: ateom.Readyz.http_get:type_name -> ateom.HTTPGetAction + 3, // 7: ateom.CheckpointWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 14, // 8: ateom.CheckpointWorkloadRequest.runtime_asset_paths:type_name -> ateom.CheckpointWorkloadRequest.RuntimeAssetPathsEntry + 0, // 9: ateom.CheckpointWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 3, // 10: ateom.RestoreWorkloadRequest.spec:type_name -> ateom.WorkloadSpec + 15, // 11: ateom.RestoreWorkloadRequest.runtime_asset_paths:type_name -> ateom.RestoreWorkloadRequest.RuntimeAssetPathsEntry + 0, // 12: ateom.RestoreWorkloadRequest.scope:type_name -> ateom.SnapshotScope + 2, // 13: ateom.RestoreWorkloadRequest.egress_gateway:type_name -> ateom.EgressGateway + 1, // 14: ateom.Ateom.RunWorkload:input_type -> ateom.RunWorkloadRequest + 9, // 15: ateom.Ateom.CheckpointWorkload:input_type -> ateom.CheckpointWorkloadRequest + 11, // 16: ateom.Ateom.RestoreWorkload:input_type -> ateom.RestoreWorkloadRequest + 8, // 17: ateom.Ateom.RunWorkload:output_type -> ateom.RunWorkloadResponse + 10, // 18: ateom.Ateom.CheckpointWorkload:output_type -> ateom.CheckpointWorkloadResponse + 12, // 19: ateom.Ateom.RestoreWorkload:output_type -> ateom.RestoreWorkloadResponse + 17, // [17:20] is the sub-list for method output_type + 14, // [14:17] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_ateom_proto_init() } @@ -1038,15 +1073,13 @@ func file_ateom_proto_init() { if File_ateom_proto != nil { return } - file_ateom_proto_msgTypes[0].OneofWrappers = []any{} - file_ateom_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ateom_proto_rawDesc), len(file_ateom_proto_rawDesc)), NumEnums: 1, - NumMessages: 14, + NumMessages: 15, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 29fcecb14..1b56101d5 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -64,11 +64,16 @@ message RunWorkloadRequest { // runsc_path). Empty for the gVisor runtime, which uses runsc_path. map runtime_asset_paths = 8; - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - optional string egress_gateway_address = 10; - // Logical PEP audience placed in the actor JWT. Required with the address. - optional string egress_gateway_audience = 11; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 10; +} + +// EgressGateway configures tunneled egress for one actor activation. +message EgressGateway { + // address is the remote gateway's host:port. + string address = 1; + // audience is the logical PEP audience placed in the actor JWT. + string audience = 2; } // WorkloadSpec parallels Pod, but with far fewer configurable fields. @@ -192,16 +197,13 @@ message RestoreWorkloadRequest { // What content to restore from the snapshot. SnapshotScope scope = 10; - // Remote egress gateway selected for this activation. When absent, actor - // traffic uses direct egress instead of being redirected through atunnel. - optional string egress_gateway_address = 12; + // When absent, actor traffic uses direct egress instead of atunnel. + EgressGateway egress_gateway = 12; // The object storage URI prefix of the ActorTemplate's golden snapshot. // Set only when scope is SNAPSHOT_SCOPE_DATA_ON_GOLDEN. Mirrors the // snapshot_uri_prefix contract (field 8). string golden_snapshot_uri_prefix = 13; - // Logical PEP audience placed in the actor JWT. Required with the address. - optional string egress_gateway_audience = 14; } message RestoreWorkloadResponse { From facebf3629bdad589caeac651a2d8b8c63111a64 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 4 Aug 2026 01:01:05 +0000 Subject: [PATCH 7/7] atunnel: authenticate egress with actor certificates --- .../internal/actoridentity/actoridentity.go | 113 ++++++----- .../actoridentity/actoridentity_test.go | 96 ++------- cmd/ateapi/internal/actoridjwt/actoridjwt.go | 24 +-- .../internal/controlapi/functional_test.go | 2 +- cmd/ateapi/internal/controlapi/service.go | 4 +- cmd/ateapi/internal/controlapi/workflow.go | 46 ++--- .../internal/controlapi/workflow_resume.go | 19 +- .../controlapi/workflow_testutil_test.go | 2 +- cmd/ateapi/main.go | 20 +- cmd/atelet/credentialbroker.go | 24 ++- cmd/atelet/credentialbroker_test.go | 20 +- cmd/atelet/main.go | 2 +- cmd/atelet/main_test.go | 4 +- cmd/ateom-gvisor/main.go | 49 ++--- cmd/ateom-microvm/main.go | 50 ++--- internal/atunnel/client.go | 27 +-- internal/atunnel/client_test.go | 26 +-- internal/atunnel/credential.go | 99 ++++++--- internal/atunnel/credential_test.go | 101 ++++++---- internal/atunnel/egress.go | 62 +++--- internal/atunnel/egress_test.go | 88 ++++---- internal/proto/ateletpb/atelet.pb.go | 189 ++++++++---------- internal/proto/ateletpb/atelet.proto | 18 +- internal/proto/ateletpb/atelet_grpc.pb.go | 30 +-- internal/proto/ateompb/ateom.pb.go | 16 +- internal/proto/ateompb/ateom.proto | 2 - manifests/ate-install/ate-api-server.yaml | 1 - pkg/proto/ateapipb/ateapi.pb.go | 153 +++++++------- pkg/proto/ateapipb/ateapi.proto | 23 ++- pkg/proto/ateapipb/ateapi_grpc.pb.go | 24 ++- 30 files changed, 632 insertions(+), 702 deletions(-) diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 0703d2e9b..73c2b68d7 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -22,12 +22,15 @@ import ( "errors" "fmt" "log/slog" + "net/http" "net/url" "os" "path" + "strings" "time" "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/k8sjwt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/localjwtauthority" @@ -36,20 +39,24 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" ) // Server implements ateapipb.ActorIdentityServer type Server struct { ateapipb.UnimplementedActorIdentityServer + clientJWTIssuer string + clientJWTAudience string + // TODO: Cache the signing keys in memory, so we don't read from a file every time. - actorIDJWTPoolFile string - actorIDCAPoolFile string - egressGatewayAudience string - actorJWTLifetime time.Duration + actorIDJWTPoolFile string + actorIDCAPoolFile string + + workerCACerts string + httpClient *http.Client // store is the actor database. MintCert consults it to confirm the caller // is entitled to the actor it is asking for a credential for. @@ -58,13 +65,15 @@ type Server struct { var _ ateapipb.ActorIdentityServer = (*Server)(nil) -func New(actorIDJWTPoolFile, actorIDCAPoolFile, egressGatewayAudience string, actorJWTLifetime time.Duration, store store.Interface) *Server { +func New(clientJWTIssuer, clientJWTAudience, actorIDJWTPoolFile, actorIDCAPoolFile, workerCACerts string, httpClient *http.Client, store store.Interface) *Server { return &Server{ - actorIDJWTPoolFile: actorIDJWTPoolFile, - actorIDCAPoolFile: actorIDCAPoolFile, - egressGatewayAudience: egressGatewayAudience, - actorJWTLifetime: actorJWTLifetime, - store: store, + clientJWTIssuer: clientJWTIssuer, + clientJWTAudience: clientJWTAudience, + actorIDJWTPoolFile: actorIDJWTPoolFile, + actorIDCAPoolFile: actorIDCAPoolFile, + workerCACerts: workerCACerts, + httpClient: httpClient, + store: store, } } @@ -76,33 +85,37 @@ func New(actorIDJWTPoolFile, actorIDCAPoolFile, egressGatewayAudience string, ac // imported so that this package does not depend on controlapi for three // strings; if a third pkg that need these constants appears, they should move to a shared package. const ( - ateletTrustDomain = "cluster.local" - ateletNamespace = "ate-system" - ateletSA = "atelet" + ateletTrustDomain = "cluster.local" + ateletNamespace = "ate-system" + ateletSA = "atelet" + actorCertificateLifetime = time.Hour ) func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*ateapipb.MintJWTResponse, error) { - // Authentication identifies atelet and its node; no actor identity supplied - // by the worker-side caller is trusted until authorizeActor checks storage. - caller, err := authenticateAtelet(ctx) - if err != nil { - return nil, err - } - if req.GetAudience() == "" || req.GetAudience() != s.egressGatewayAudience { - return nil, status.Error(codes.PermissionDenied, "requested audience is not permitted") + reqMetadata, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, fmt.Errorf("no metadata found") } - if req.GetWorkerPodUid() == "" || req.GetAtespace() == "" || req.GetActorName() == "" || req.GetActorUid() == "" { - return nil, status.Error(codes.InvalidArgument, "worker_pod_uid and actor identity are required") + + authorization := reqMetadata["authorization"] + if len(authorization) != 1 { + return nil, status.Errorf(codes.Unauthenticated, "Need authorization header") } - actorRef := resources.ActorRef{Atespace: req.GetAtespace(), Name: req.GetActorName()} - actor, err := s.authorizeActor(ctx, caller, actorRef, req.GetWorkerPodUid()) + + clientJWT := strings.TrimPrefix(authorization[0], "Bearer ") + + clientClaims, err := k8sjwt.Verify(ctx, s.httpClient, clientJWT, s.clientJWTIssuer, s.clientJWTAudience, time.Now()) if err != nil { - return nil, err - } - if actor.GetMetadata().GetUid() != req.GetActorUid() { - return nil, status.Error(codes.PermissionDenied, "caller is not permitted to mint credentials for this actor") + slog.ErrorContext(ctx, "Error while verifying client JWT", slog.Any("err", err)) + return nil, status.Errorf(codes.Unauthenticated, "Unauthenticated") } + slog.InfoContext(ctx, "Verified client JWT", slog.Any("claims", clientClaims)) + + // TODO: Extract K8s identity from incoming JWT + + // TODO: Cross-check requested actor and user claims against the actor database. + // TODO: Cache signing keys in memory, so we don't read from disk every time. signingPoolBytes, err := os.ReadFile(s.actorIDJWTPoolFile) if err != nil { @@ -113,31 +126,26 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at if err != nil { return nil, fmt.Errorf("while unmarshaling signing pool: %w", err) } - if len(signingPool.Authorities) == 0 { - return nil, fmt.Errorf("actor JWT signing pool is empty") - } - // We only issue tokens with audience bindings. - now := time.Now() - expires := now.Add(s.actorJWTLifetime) + if len(req.GetAudience()) == 0 { + return nil, fmt.Errorf("at least one audience must be requested") + } actorClaims := &actoridjwt.Claims{ // TODO: This is currently API but it has to be a globally unique, oidc-compliant and accsible DNS name Issuer: "https://api.ate-system.svc", // TODO: this format is very likely going to change. Subject: fmt.Sprintf("atespaces:%s:actors:%s", req.GetAtespace(), req.GetActorName()), - Audiences: []string{req.GetAudience()}, - Expiration: expires, - NotBefore: now.Add(-5 * time.Minute), - IssuedAt: now, + Audiences: req.GetAudience(), + Expiration: time.Now().Add(15 * time.Minute), + NotBefore: time.Now().Add(-5 * time.Minute), + IssuedAt: time.Now(), JTI: rand.Text(), Substrate: actoridjwt.SubstrateClaims{ - Atespace: req.GetAtespace(), - ActorName: req.GetActorName(), - ActorUid: req.GetActorUid(), - ActorResourceVersion: actor.GetMetadata().GetVersion(), - WorkerPodUid: req.GetWorkerPodUid(), + Atespace: req.GetAtespace(), + ActorName: req.GetActorName(), + ActorUid: req.GetActorUid(), }, } @@ -153,8 +161,7 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at } return &ateapipb.MintJWTResponse{ - ActorJwt: actorJWT, - ExpirationTime: timestamppb.New(expires), + ActorJwt: actorJWT, }, nil } @@ -172,7 +179,10 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* } actorRef := resources.ActorRef{Atespace: atespace, Name: actorName} - actor, err := s.authorizeActor(ctx, caller, actorRef, "") + if req.GetWorkerPodUid() == "" { + return nil, status.Error(codes.InvalidArgument, "worker_pod_uid is required") + } + actor, err := s.authorizeActor(ctx, caller, actorRef, req.GetWorkerPodUid()) if err != nil { return nil, err } @@ -225,7 +235,7 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* template := &x509.Certificate{ URIs: []*url.URL{spiffeURI}, NotBefore: time.Now().Add(-5 * time.Minute), - NotAfter: time.Now().Add(15 * time.Minute), + NotAfter: time.Now().Add(actorCertificateLifetime), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, @@ -326,8 +336,7 @@ func authenticateAtelet(ctx context.Context) (*ateletCaller, error) { // An atelet is therefore confined to the actors it is actually hosting, and an // actor that has been suspended, paused or migrated elsewhere can no longer // have credentials minted for it. -// expectedWorkerPodUID is required for JWTs. The legacy certificate path -// leaves it empty because its request does not carry a worker Pod UID. +// expectedWorkerPodUID binds the request to the exact worker incarnation. func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actorRef resources.ActorRef, expectedWorkerPodUID string) (*ateapipb.Actor, error) { // Denials are deliberately indistinguishable from each other: a caller that // is not entitled to an actor should not be able to use this RPC to learn @@ -378,7 +387,7 @@ func (s *Server) authorizeActor(ctx context.Context, caller *ateletCaller, actor if worker.GetNodeName() != caller.nodeName { return nil, deny("actor is hosted on a different node", slog.String("actorNode", worker.GetNodeName())) } - if expectedWorkerPodUID != "" && worker.GetWorkerPodUid() != expectedWorkerPodUID { + if worker.GetWorkerPodUid() != expectedWorkerPodUID { return nil, deny("worker Pod UID does not match", slog.String("workerPodUID", expectedWorkerPodUID)) } diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index f9538a568..5d92aff68 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -21,22 +21,17 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" - "encoding/base64" - "encoding/json" "math/big" "net/url" "os" "path" "path/filepath" - "strings" "testing" "time" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/actoridjwt" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store" "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/storetest" "github.com/agent-substrate/substrate/internal/localca" - "github.com/agent-substrate/substrate/internal/localjwtauthority" "github.com/agent-substrate/substrate/internal/resources" "github.com/agent-substrate/substrate/internal/substratex509" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -152,72 +147,7 @@ func newTestServer(t *testing.T, st store.Interface) *Server { t.Fatalf("write CA pool: %v", err) } - authority, err := localjwtauthority.GenerateECDSAP256Authority("test") - if err != nil { - t.Fatalf("generate JWT authority: %v", err) - } - jwtPool, err := localjwtauthority.Marshal(&localjwtauthority.Pool{Authorities: []*localjwtauthority.Authority{authority}}) - if err != nil { - t.Fatalf("marshal JWT authority: %v", err) - } - jwtPoolFile := filepath.Join(t.TempDir(), "actor-jwt-pool.json") - if err := os.WriteFile(jwtPoolFile, jwtPool, 0o600); err != nil { - t.Fatalf("write JWT pool: %v", err) - } - - return New(jwtPoolFile, poolFile, "egress.test", time.Hour, st) -} - -func TestMintJWTAuthorizesAndBindsWorker(t *testing.T) { - ctx := context.Background() - st, cleanup := storetest.SetupTestStore(t) - defer cleanup() - seedActor(t, ctx, st, runningOnNode(testNode)) - actor, err := st.GetActor(ctx, resources.ActorRef{Atespace: testAtespace, Name: testActorName}) - if err != nil { - t.Fatal(err) - } - srv := newTestServer(t, st) - request := func() *ateapipb.MintJWTRequest { - return &ateapipb.MintJWTRequest{ - Audience: "egress.test", Atespace: testAtespace, ActorName: testActorName, - ActorUid: actor.GetMetadata().GetUid(), WorkerPodUid: "worker-uid", - } - } - - resp, err := srv.MintJWT(ctxWithCert(ateletCertOn(t, testNode)), request()) - if err != nil { - t.Fatal(err) - } - parts := strings.Split(resp.GetActorJwt(), ".") - if len(parts) != 3 { - t.Fatalf("JWT has %d parts", len(parts)) - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - t.Fatal(err) - } - var claims actoridjwt.WireClaims - if err := json.Unmarshal(payload, &claims); err != nil { - t.Fatal(err) - } - if claims.Substrate.WorkerPodUid != "worker-uid" || claims.Substrate.ActorResourceVersion != actor.GetMetadata().GetVersion() { - t.Errorf("JWT binding = %+v", claims.Substrate) - } - - for name, mutate := range map[string]func(*ateapipb.MintJWTRequest){ - "wrong audience": func(r *ateapipb.MintJWTRequest) { r.Audience = "other" }, - "sibling worker": func(r *ateapipb.MintJWTRequest) { r.WorkerPodUid = "other-worker" }, - "stale actor UID": func(r *ateapipb.MintJWTRequest) { r.ActorUid = "old-actor" }, - } { - t.Run(name, func(t *testing.T) { - req := request() - mutate(req) - if _, err := srv.MintJWT(ctxWithCert(ateletCertOn(t, testNode)), req); status.Code(err) != codes.PermissionDenied { - t.Fatalf("MintJWT() error = %v, want PermissionDenied", err) - } - }) - } + return New("issuer", "audience", "", poolFile, "", nil, st) } // newCSR returns a DER-encoded, correctly self-signed CSR. @@ -317,9 +247,10 @@ func TestMintCertAuthorization(t *testing.T) { fixture actorFixture - // atespace and actorName override the request fields when non-nil. - atespace *string - actorName *string + // Request fields override their defaults when non-nil. + atespace *string + actorName *string + workerPodUID *string wantCode codes.Code }{ @@ -378,6 +309,11 @@ func TestMintCertAuthorization(t *testing.T) { fixture: runningOnNode(testOtherNode), wantCode: codes.PermissionDenied, }, + "worker Pod UID does not match": { + fixture: runningOnNode(testNode), + workerPodUID: ptr("sibling-worker-uid"), + wantCode: codes.PermissionDenied, + }, "worker is assigned to a different actor": { fixture: actorFixture{ status: ateapipb.Actor_STATUS_RUNNING, @@ -446,10 +382,15 @@ func TestMintCertAuthorization(t *testing.T) { actorName = *tc.actorName } + workerPodUID := "worker-uid" + if tc.workerPodUID != nil { + workerPodUID = *tc.workerPodUID + } resp, err := srv.MintCert(ctxWithCert(callerCert), &ateapipb.MintCertRequest{ Atespace: atespace, ActorName: actorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: workerPodUID, }) if got := status.Code(err); got != tc.wantCode { t.Fatalf("MintCert() code = %v (err = %v), want %v", got, err, tc.wantCode) @@ -518,6 +459,7 @@ func TestMintCertEmbedsActorIdentity(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", } }) if err != nil { @@ -563,6 +505,7 @@ func TestMintCertActorUID(t *testing.T) { ActorName: testActorName, ActorUid: tc.requestUID(actorUID), CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", } }) if got := status.Code(err); got != tc.wantCode { @@ -619,6 +562,7 @@ func TestMintCertActorStatus(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != wantCode { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, wantCode) @@ -654,6 +598,7 @@ func TestMintCertDeniesUnassignedActorWhateverItsStatus(t *testing.T) { Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: newCSR(t), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != codes.PermissionDenied { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, codes.PermissionDenied) @@ -675,12 +620,13 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { // A server whose CA pool file does not exist: reaching the signing path at // all would surface as Internal rather than PermissionDenied. - srv := New("", filepath.Join(t.TempDir(), "missing.json"), "egress.test", time.Hour, st) + srv := New("issuer", "audience", "", filepath.Join(t.TempDir(), "missing.json"), "", nil, st) _, err := srv.MintCert(ctxWithCert(ateletCertOn(t, testNode)), &ateapipb.MintCertRequest{ Atespace: testAtespace, ActorName: testActorName, CertificateSigningRequest: []byte("not a CSR"), + WorkerPodUid: "worker-uid", }) if got := status.Code(err); got != codes.PermissionDenied { t.Errorf("MintCert() code = %v (err = %v), want %v", got, err, codes.PermissionDenied) diff --git a/cmd/ateapi/internal/actoridjwt/actoridjwt.go b/cmd/ateapi/internal/actoridjwt/actoridjwt.go index ca65556be..a6a00a48d 100644 --- a/cmd/ateapi/internal/actoridjwt/actoridjwt.go +++ b/cmd/ateapi/internal/actoridjwt/actoridjwt.go @@ -42,11 +42,9 @@ type Claims struct { } type SubstrateClaims struct { - Atespace string - ActorName string - ActorUid string - ActorResourceVersion int64 - WorkerPodUid string + Atespace string + ActorName string + ActorUid string } type wireHeader struct { @@ -70,11 +68,9 @@ type WireClaims struct { } type WireSubstrateClaims struct { - Atespace string `json:"atespace,omitempty"` - ActorName string `json:"actorName,omitempty"` - ActorUid string `json:"actorUid,omitempty"` - ActorResourceVersion int64 `json:"actorResourceVersion,omitempty"` - WorkerPodUid string `json:"workerPodUid,omitempty"` + Atespace string `json:"atespace,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorUid string `json:"actorUid,omitempty"` } func ClaimsToWire(claims *Claims) (*WireClaims, error) { @@ -92,11 +88,9 @@ func ClaimsToWire(claims *Claims) (*WireClaims, error) { IssuedAt: float64(claims.IssuedAt.Unix()), JTI: claims.JTI, Substrate: WireSubstrateClaims{ - Atespace: claims.Substrate.Atespace, - ActorName: claims.Substrate.ActorName, - ActorUid: claims.Substrate.ActorUid, - ActorResourceVersion: claims.Substrate.ActorResourceVersion, - WorkerPodUid: claims.Substrate.WorkerPodUid, + Atespace: claims.Substrate.Atespace, + ActorName: claims.Substrate.ActorName, + ActorUid: claims.Substrate.ActorUid, }, } diff --git a/cmd/ateapi/internal/controlapi/functional_test.go b/cmd/ateapi/internal/controlapi/functional_test.go index 18667fbf5..bcacc127a 100644 --- a/cmd/ateapi/internal/controlapi/functional_test.go +++ b/cmd/ateapi/internal/controlapi/functional_test.go @@ -324,7 +324,7 @@ func setupTest(t *testing.T, ns string) *testContext { return insecure.NewCredentials(), nil } - service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, "", "") + service := NewService(persistence, wc, actorTemplateLister, workerPoolLister, sandboxConfigLister, dialer, k8sClient, "") // 5. Start REAL gRPC Server for ATE API grpcServer := grpc.NewServer(grpc.UnaryInterceptor(ateinterceptors.ServerUnaryInterceptor)) diff --git a/cmd/ateapi/internal/controlapi/service.go b/cmd/ateapi/internal/controlapi/service.go index 64b5047f2..27c1a1536 100644 --- a/cmd/ateapi/internal/controlapi/service.go +++ b/cmd/ateapi/internal/controlapi/service.go @@ -43,14 +43,14 @@ func NewService( sandboxConfigLister listersv1alpha1.SandboxConfigLister, dialer *AteletDialer, kubeClient kubernetes.Interface, - egressGatewayAddress, egressGatewayAudience string, + egressGatewayAddress string, ) *Service { s := &Service{ persistence: persistence, actorTemplateLister: actorTemplateLister, workerPoolLister: workerPoolLister, dialer: dialer, - actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, egressGatewayAddress, egressGatewayAudience), + actorWorkflow: NewActorWorkflow(persistence, workerCache, dialer, actorTemplateLister, workerPoolLister, sandboxConfigLister, kubeClient, egressGatewayAddress), } return s } diff --git a/cmd/ateapi/internal/controlapi/workflow.go b/cmd/ateapi/internal/controlapi/workflow.go index 28c9e6a07..359d233e6 100644 --- a/cmd/ateapi/internal/controlapi/workflow.go +++ b/cmd/ateapi/internal/controlapi/workflow.go @@ -130,17 +130,16 @@ func runStep[Params any, Context any](ctx context.Context, params Params, wCtx C // ActorWorkflow handles the workflows for actor's resume / suspend operations. type ActorWorkflow struct { - store store.Interface - workerCache *workercache.Cache - scheduler scheduling.Scheduler - dialer *AteletDialer - actorTemplateLister listersv1alpha1.ActorTemplateLister - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - kubeClient kubernetes.Interface - secretCache *envSecretCache - egressGatewayAddress string - egressGatewayAudience string + store store.Interface + workerCache *workercache.Cache + scheduler scheduling.Scheduler + dialer *AteletDialer + actorTemplateLister listersv1alpha1.ActorTemplateLister + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + kubeClient kubernetes.Interface + secretCache *envSecretCache + egressGatewayAddress string } // NewActorWorkflow creates a new ActorWorkflow. @@ -152,20 +151,19 @@ func NewActorWorkflow( workerPoolLister listersv1alpha1.WorkerPoolLister, sandboxConfigLister listersv1alpha1.SandboxConfigLister, kubeClient kubernetes.Interface, - egressGatewayAddress, egressGatewayAudience string, + egressGatewayAddress string, ) *ActorWorkflow { return &ActorWorkflow{ - store: store, - workerCache: workerCache, - scheduler: scheduling.New(workerCache), - dialer: dialer, - actorTemplateLister: actorTemplateLister, - workerPoolLister: workerPoolLister, - sandboxConfigLister: sandboxConfigLister, - kubeClient: kubeClient, - secretCache: newEnvSecretCache(envSecretCacheTTL), - egressGatewayAddress: egressGatewayAddress, - egressGatewayAudience: egressGatewayAudience, + store: store, + workerCache: workerCache, + scheduler: scheduling.New(workerCache), + dialer: dialer, + actorTemplateLister: actorTemplateLister, + workerPoolLister: workerPoolLister, + sandboxConfigLister: sandboxConfigLister, + kubeClient: kubeClient, + secretCache: newEnvSecretCache(envSecretCacheTTL), + egressGatewayAddress: egressGatewayAddress, } } @@ -188,7 +186,7 @@ func (w *ActorWorkflow) ResumeActor(ctx context.Context, actorRef resources.Acto &CreateVolumesStep{store: w.store}, &AssignWorkerStep{store: w.store, workerCache: w.workerCache, scheduler: w.scheduler}, &AttachVolumesStep{store: w.store}, - &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler, egressGatewayAddress: w.egressGatewayAddress, egressGatewayAudience: w.egressGatewayAudience}, + &CallAteletRestoreStep{store: w.store, dialer: w.dialer, kubeClient: w.kubeClient, secretCache: w.secretCache, workerPoolLister: w.workerPoolLister, sandboxConfigLister: w.sandboxConfigLister, scheduler: w.scheduler, egressGatewayAddress: w.egressGatewayAddress}, &FinalizeRunningStep{store: w.store}, } diff --git a/cmd/ateapi/internal/controlapi/workflow_resume.go b/cmd/ateapi/internal/controlapi/workflow_resume.go index cf86ec86a..272cae162 100644 --- a/cmd/ateapi/internal/controlapi/workflow_resume.go +++ b/cmd/ateapi/internal/controlapi/workflow_resume.go @@ -452,15 +452,14 @@ func (s *AttachVolumesStep) Execute(ctx context.Context, input *ResumeInput, sta func (s *AttachVolumesStep) RetryBackoff() *wait.Backoff { return nil } type CallAteletRestoreStep struct { - store store.Interface - dialer *AteletDialer - kubeClient kubernetes.Interface - secretCache *envSecretCache - workerPoolLister listersv1alpha1.WorkerPoolLister - sandboxConfigLister listersv1alpha1.SandboxConfigLister - scheduler scheduling.Scheduler - egressGatewayAddress string - egressGatewayAudience string + store store.Interface + dialer *AteletDialer + kubeClient kubernetes.Interface + secretCache *envSecretCache + workerPoolLister listersv1alpha1.WorkerPoolLister + sandboxConfigLister listersv1alpha1.SandboxConfigLister + scheduler scheduling.Scheduler + egressGatewayAddress string } func (s *CallAteletRestoreStep) Name() string { return "CallAteletRestore" } @@ -616,7 +615,7 @@ func (s *CallAteletRestoreStep) egressGateway() *ateletpb.EgressGateway { if s.egressGatewayAddress == "" { return nil } - return &ateletpb.EgressGateway{Address: s.egressGatewayAddress, Audience: s.egressGatewayAudience} + return &ateletpb.EgressGateway{Address: s.egressGatewayAddress} } type FinalizeRunningStep struct { diff --git a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go index 6f01ae061..d8a10523d 100644 --- a/cmd/ateapi/internal/controlapi/workflow_testutil_test.go +++ b/cmd/ateapi/internal/controlapi/workflow_testutil_test.go @@ -42,7 +42,7 @@ func newTestActorWorkflow(t *testing.T, st store.Interface, tmplNamespace, tmplN }); err != nil { t.Fatalf("add template to indexer: %v", err) } - return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, "", "") + return NewActorWorkflow(st, nil, nil, listersv1alpha1.NewActorTemplateLister(indexer), nil, nil, nil, "") } // seedWorkflowActor stores an actor with the given status, bound to the given diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index 1883c854e..45f4908b5 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -71,12 +71,10 @@ var ( redisTLSServerName = pflag.String("redis-tls-server-name", "", "The ServerName to use for Redis TLS hostname verification.") redisClientCert = pflag.String("redis-client-cert", "", "The file containing client TLS certificate/key credential bundle for Redis/Valkey.") - clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") - clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") - actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") - egressGatewayAddress = pflag.String("egress-gateway-address", "", "Address of the egress PEP. Empty disables tunneled egress.") - egressGatewayAudience = pflag.String("egress-gateway-audience", "", "Audience allowed for atunnel actor JWTs.") - actorJWTLifetime = pflag.Duration("actor-jwt-lifetime", time.Hour, "Lifetime of actor JWTs minted for atunnel.") + clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") + clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") + actorIDJWTPoolFile = pflag.String("actor-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing actor JWTs") + egressGatewayAddress = pflag.String("egress-gateway-address", "", "Address of the egress PEP. Empty disables tunneled egress.") actorIDCAPoolFile = pflag.String("actor-id-ca-pool", "", "The file that contains the CA pool for signing actor JWTs") podIdentityCACerts = pflag.String("pod-identity-ca-certs", "", "The file that contains the pod-identity CA bundle, used both for verifying client certificates presented to the gRPC server and for verifying atelet serving certificates when dialing atelet. If empty, client-cert verification is disabled and atelet dials will fail.") @@ -98,12 +96,6 @@ func main() { } ctx := context.Background() serverboot.InitLogger() - if *egressGatewayAddress != "" && *egressGatewayAudience == "" { - serverboot.Fatal(ctx, "Invalid egress gateway configuration", fmt.Errorf("--egress-gateway-audience is required with --egress-gateway-address")) - } - if *actorJWTLifetime <= 0 { - serverboot.Fatal(ctx, "Invalid actor JWT lifetime", fmt.Errorf("--actor-jwt-lifetime must be positive")) - } if err := serverboot.SetLogLevel(*logLevelFlag); err != nil { serverboot.Fatal(ctx, "Invalid --log-level", err) } @@ -180,11 +172,11 @@ func main() { } ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) - sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset, *egressGatewayAddress, *egressGatewayAudience) + sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset, *egressGatewayAddress) jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) - actorIdentitySrv := actoridentity.New(*actorIDJWTPoolFile, *actorIDCAPoolFile, *egressGatewayAudience, *actorJWTLifetime, redisPersistence) + actorIdentitySrv := actoridentity.New(*clientJWTIssuer, *clientJWTAudience, *actorIDJWTPoolFile, *actorIDCAPoolFile, *podIdentityCACerts, jwtIssuerDiscoveryClient, redisPersistence) debugSrv := debugapi.NewService(redisPersistence) lisCfg := &net.ListenConfig{} diff --git a/cmd/atelet/credentialbroker.go b/cmd/atelet/credentialbroker.go index 1bbaa2823..9bdaf1dcf 100644 --- a/cmd/atelet/credentialbroker.go +++ b/cmd/atelet/credentialbroker.go @@ -32,11 +32,13 @@ type credentialBroker struct { ateletpb.UnimplementedCredentialBrokerServer // control resolves the authenticated worker Pod to its current assignment. control ateapipb.ControlClient - // identity revalidates that assignment and signs the actor JWT. + // identity revalidates that assignment and signs the actor certificate. identity ateapipb.ActorIdentityClient } -func (b *credentialBroker) MintActorJWT(ctx context.Context, req *ateletpb.MintActorJWTRequest) (*ateletpb.MintActorJWTResponse, error) { +func (b *credentialBroker) MintActorCertificate(ctx context.Context, req *ateletpb.MintActorCertificateRequest) (*ateletpb.MintActorCertificateResponse, error) { + // TODO: Before release, request an atunnel-specific MintCert purpose and + // require the egress PEP to reject generic actor certificates. // The request deliberately carries no actor identity. The authenticated Pod // UID is the only input used to select a worker and its current assignment. workerUID, err := authenticatedWorkerUID(ctx) @@ -71,19 +73,19 @@ func (b *credentialBroker) MintActorJWT(ctx context.Context, req *ateletpb.MintA if err != nil { return nil, fmt.Errorf("get assigned actor: %w", err) } - // MintJWT revalidates this assignment in ateapi. That second check closes + // MintCert revalidates this assignment in ateapi. That second check closes // the race where the worker is reassigned after ListWorkers returns. - resp, err := b.identity.MintJWT(ctx, &ateapipb.MintJWTRequest{ - Audience: req.GetAudience(), - Atespace: actor.GetMetadata().GetAtespace(), - ActorName: actor.GetMetadata().GetName(), - ActorUid: actor.GetMetadata().GetUid(), - WorkerPodUid: workerUID, + resp, err := b.identity.MintCert(ctx, &ateapipb.MintCertRequest{ + Atespace: actor.GetMetadata().GetAtespace(), + ActorName: actor.GetMetadata().GetName(), + ActorUid: actor.GetMetadata().GetUid(), + CertificateSigningRequest: req.GetCertificateSigningRequest(), + WorkerPodUid: workerUID, }) if err != nil { - return nil, fmt.Errorf("mint actor JWT: %w", err) + return nil, fmt.Errorf("mint actor certificate: %w", err) } - return &ateletpb.MintActorJWTResponse{ActorJwt: resp.GetActorJwt(), ExpirationTime: resp.GetExpirationTime()}, nil + return &ateletpb.MintActorCertificateResponse{ActorCertificates: resp.GetActorCertificates()}, nil } func authenticatedWorkerUID(ctx context.Context) (string, error) { diff --git a/cmd/atelet/credentialbroker_test.go b/cmd/atelet/credentialbroker_test.go index a07384281..a71c6fdd2 100644 --- a/cmd/atelet/credentialbroker_test.go +++ b/cmd/atelet/credentialbroker_test.go @@ -31,7 +31,6 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" ) type brokerControlClient struct { @@ -50,12 +49,12 @@ func (c *brokerControlClient) GetActor(context.Context, *ateapipb.GetActorReques type brokerIdentityClient struct { ateapipb.ActorIdentityClient - request *ateapipb.MintJWTRequest + request *ateapipb.MintCertRequest } -func (c *brokerIdentityClient) MintJWT(_ context.Context, req *ateapipb.MintJWTRequest, _ ...grpc.CallOption) (*ateapipb.MintJWTResponse, error) { +func (c *brokerIdentityClient) MintCert(_ context.Context, req *ateapipb.MintCertRequest, _ ...grpc.CallOption) (*ateapipb.MintCertResponse, error) { c.request = req - return &ateapipb.MintJWTResponse{ActorJwt: "jwt", ExpirationTime: timestamppb.New(time.Now().Add(time.Hour))}, nil + return &ateapipb.MintCertResponse{ActorCertificates: [][]byte{{1, 2, 3}}}, nil } func TestCredentialBrokerDerivesActorFromWorkerCertificate(t *testing.T) { @@ -68,22 +67,23 @@ func TestCredentialBrokerDerivesActorFromWorkerCertificate(t *testing.T) { } identity := &brokerIdentityClient{} broker := &credentialBroker{control: control, identity: identity} - resp, err := broker.MintActorJWT(workerContext(t, "worker-uid"), &ateletpb.MintActorJWTRequest{Audience: "pep"}) + csr := []byte{4, 5, 6} + resp, err := broker.MintActorCertificate(workerContext(t, "worker-uid"), &ateletpb.MintActorCertificateRequest{CertificateSigningRequest: csr}) if err != nil { t.Fatal(err) } - if resp.GetActorJwt() != "jwt" { - t.Fatalf("JWT = %q", resp.GetActorJwt()) + if !proto.Equal(resp, &ateletpb.MintActorCertificateResponse{ActorCertificates: [][]byte{{1, 2, 3}}}) { + t.Fatalf("response = %+v", resp) } - want := &ateapipb.MintJWTRequest{Audience: "pep", Atespace: "team", ActorName: "actor", ActorUid: "actor-uid", WorkerPodUid: "worker-uid"} + want := &ateapipb.MintCertRequest{Atespace: "team", ActorName: "actor", ActorUid: "actor-uid", WorkerPodUid: "worker-uid", CertificateSigningRequest: csr} if !proto.Equal(identity.request, want) { - t.Fatalf("MintJWT request = %+v, want %+v", identity.request, want) + t.Fatalf("MintCert request = %+v, want %+v", identity.request, want) } } func TestCredentialBrokerRejectsUnknownWorker(t *testing.T) { broker := &credentialBroker{control: &brokerControlClient{}} - if _, err := broker.MintActorJWT(workerContext(t, "unknown-worker"), &ateletpb.MintActorJWTRequest{Audience: "pep"}); err == nil { + if _, err := broker.MintActorCertificate(workerContext(t, "unknown-worker"), &ateletpb.MintActorCertificateRequest{}); err == nil { t.Fatal("unknown worker was accepted") } } diff --git a/cmd/atelet/main.go b/cmd/atelet/main.go index cc86ceddc..778facaef 100644 --- a/cmd/atelet/main.go +++ b/cmd/atelet/main.go @@ -996,7 +996,7 @@ func toAteomEgressGateway(gateway *ateletpb.EgressGateway) *ateompb.EgressGatewa if gateway == nil { return nil } - return &ateompb.EgressGateway{Address: gateway.GetAddress(), Audience: gateway.GetAudience()} + return &ateompb.EgressGateway{Address: gateway.GetAddress()} } // toAteomReadyz converts an ateletpb readyz probe into the ateompb wire diff --git a/cmd/atelet/main_test.go b/cmd/atelet/main_test.go index b27cc9094..fd936c4a2 100644 --- a/cmd/atelet/main_test.go +++ b/cmd/atelet/main_test.go @@ -670,8 +670,8 @@ func TestToAteomEgressGateway(t *testing.T) { if got := toAteomEgressGateway(nil); got != nil { t.Fatalf("toAteomEgressGateway(nil) = %v, want nil", got) } - want := &ateompb.EgressGateway{Address: "egress.example:443", Audience: "egress-pep"} - got := toAteomEgressGateway(&ateletpb.EgressGateway{Address: want.Address, Audience: want.Audience}) + want := &ateompb.EgressGateway{Address: "egress.example:443"} + got := toAteomEgressGateway(&ateletpb.EgressGateway{Address: want.Address}) if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { t.Errorf("toAteomEgressGateway mismatch (-want +got):\n%s", diff) } diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index dce206acf..3429d9020 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -631,12 +631,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore } type actorEgress struct { - // client authenticates the worker to the remote egress gateway. + // client presents the actor certificate to the remote egress gateway. client *atunnel.Client - // jwtSource authenticates the worker to atelet for renewal. - jwtSource *atunnel.BrokerJWTSource - // jwt identifies the actor assignment to the egress gateway. - jwt atunnel.ActorJWT + // certificateSource owns the actor key and renews its certificate via atelet. + certificateSource *atunnel.BrokerCertificateSource + expiresAt time.Time } func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { @@ -646,40 +645,34 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb. if gateway.GetAddress() == "" { return nil, fmt.Errorf("egress gateway address is required") } - if gateway.GetAudience() == "" { - return nil, fmt.Errorf("egress gateway audience is required") - } serverName, _, err := net.SplitHostPort(gateway.GetAddress()) if err != nil { return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) } - // The worker credential authenticates both connections, but each peer is - // verified against the trust domain that issued its serving certificate. - gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: gateway.GetAddress(), - ServerName: serverName, - CredentialBundlePath: s.workerCredentialBundlePath, - TrustBundlePath: s.egressGatewayTrustBundlePath, - }) - if err != nil { - return nil, fmt.Errorf("while configuring actor egress client: %w", err) - } - jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ + certificateSource, err := atunnel.NewBrokerCertificateSource(atunnel.BrokerConfig{ SocketPath: ateompath.CredentialBrokerSocket, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.podIdentityTrustBundlePath, - Audience: gateway.GetAudience(), }) if err != nil { - return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) + return nil, fmt.Errorf("while configuring actor certificate broker: %w", err) } - // Mint before starting the workload so configured tunneled egress fails the - // whole activation closed. The source is retained for background renewal. - jwt, err := jwtSource.Mint(ctx) + // Mint before starting the workload so configured tunneled egress fails + // closed. The source retains the private key for mTLS and renewal. + expiresAt, err := certificateSource.Mint(ctx) if err != nil { - return nil, fmt.Errorf("while obtaining actor JWT: %w", err) + return nil, fmt.Errorf("while obtaining actor certificate: %w", err) + } + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: gateway.GetAddress(), + ServerName: serverName, + GetClientCertificate: certificateSource.GetClientCertificate, + TrustBundlePath: s.egressGatewayTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) } - return &actorEgress{client: gatewayClient, jwtSource: jwtSource, jwt: jwt}, nil + return &actorEgress{client: gatewayClient, certificateSource: certificateSource, expiresAt: expiresAt}, nil } func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { @@ -689,7 +682,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, egres if egress == nil { return nil } - if err := s.atunnelEgress.Activate(egress.client, egress.jwtSource, egress.jwt); err != nil { + if err := s.atunnelEgress.Activate(egress.client, egress.certificateSource, egress.expiresAt); err != nil { return fmt.Errorf("while activating actor egress: %w", err) } return nil diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 7de7485c3..1005bc354 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -33,6 +33,7 @@ import ( "os" "strings" "sync" + "time" "cloud.google.com/go/compute/metadata" "github.com/agent-substrate/substrate/cmd/ateom-microvm/internal/reaper" @@ -313,12 +314,11 @@ func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNet } type actorEgress struct { - // client authenticates the worker to the remote egress gateway. + // client presents the actor certificate to the remote egress gateway. client *atunnel.Client - // jwtSource authenticates the worker to atelet for renewal. - jwtSource *atunnel.BrokerJWTSource - // jwt identifies the actor assignment to the egress gateway. - jwt atunnel.ActorJWT + // certificateSource owns the actor key and renews its certificate via atelet. + certificateSource *atunnel.BrokerCertificateSource + expiresAt time.Time } func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb.EgressGateway) (*actorEgress, error) { @@ -328,40 +328,34 @@ func (s *AteomService) prepareActorEgress(ctx context.Context, gateway *ateompb. if gateway.GetAddress() == "" { return nil, fmt.Errorf("egress gateway address is required") } - if gateway.GetAudience() == "" { - return nil, fmt.Errorf("egress gateway audience is required") - } serverName, _, err := net.SplitHostPort(gateway.GetAddress()) if err != nil { return nil, fmt.Errorf("invalid egress gateway address %q: %w", gateway.GetAddress(), err) } - // The worker credential authenticates both connections, but each peer is - // verified against the trust domain that issued its serving certificate. - gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ - GatewayAddress: gateway.GetAddress(), - ServerName: serverName, - CredentialBundlePath: s.workerCredentialBundlePath, - TrustBundlePath: s.egressGatewayTrustBundlePath, - }) - if err != nil { - return nil, fmt.Errorf("while configuring actor egress client: %w", err) - } - jwtSource, err := atunnel.NewBrokerJWTSource(atunnel.BrokerConfig{ + certificateSource, err := atunnel.NewBrokerCertificateSource(atunnel.BrokerConfig{ SocketPath: ateompath.CredentialBrokerSocket, CredentialBundlePath: s.workerCredentialBundlePath, TrustBundlePath: s.podIdentityTrustBundlePath, - Audience: gateway.GetAudience(), }) if err != nil { - return nil, fmt.Errorf("while configuring actor JWT broker: %w", err) + return nil, fmt.Errorf("while configuring actor certificate broker: %w", err) } - // Mint before starting the workload so configured tunneled egress fails the - // whole activation closed. The source is retained for background renewal. - jwt, err := jwtSource.Mint(ctx) + // Mint before starting the workload so configured tunneled egress fails + // closed. The source retains the private key for mTLS and renewal. + expiresAt, err := certificateSource.Mint(ctx) if err != nil { - return nil, fmt.Errorf("while obtaining actor JWT: %w", err) + return nil, fmt.Errorf("while obtaining actor certificate: %w", err) + } + gatewayClient, err := atunnel.NewClient(atunnel.ClientConfig{ + GatewayAddress: gateway.GetAddress(), + ServerName: serverName, + GetClientCertificate: certificateSource.GetClientCertificate, + TrustBundlePath: s.egressGatewayTrustBundlePath, + }) + if err != nil { + return nil, fmt.Errorf("while configuring actor egress client: %w", err) } - return &actorEgress{client: gatewayClient, jwtSource: jwtSource, jwt: jwt}, nil + return &actorEgress{client: gatewayClient, certificateSource: certificateSource, expiresAt: expiresAt}, nil } func (s *AteomService) activateActorNetworking(atespace, actorName string, egress *actorEgress) error { @@ -371,7 +365,7 @@ func (s *AteomService) activateActorNetworking(atespace, actorName string, egres if egress == nil { return nil } - if err := s.atunnelEgress.Activate(egress.client, egress.jwtSource, egress.jwt); err != nil { + if err := s.atunnelEgress.Activate(egress.client, egress.certificateSource, egress.expiresAt); err != nil { return fmt.Errorf("while activating actor egress: %w", err) } return nil diff --git a/internal/atunnel/client.go b/internal/atunnel/client.go index 60cae8576..666431bbf 100644 --- a/internal/atunnel/client.go +++ b/internal/atunnel/client.go @@ -34,7 +34,7 @@ import ( type ClientConfig struct { GatewayAddress string ServerName string - CredentialBundlePath string + GetClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error) TrustBundlePath string } @@ -73,15 +73,12 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { if cfg.ServerName == "" { return nil, fmt.Errorf("atunnel: egress gateway server name is required") } - if cfg.CredentialBundlePath == "" { - return nil, fmt.Errorf("atunnel: credential bundle path is required") + if cfg.GetClientCertificate == nil { + return nil, fmt.Errorf("atunnel: client certificate source is required") } if cfg.TrustBundlePath == "" { return nil, fmt.Errorf("atunnel: trust bundle path is required") } - if _, err := loadCredentialBundle(cfg.CredentialBundlePath); err != nil { - return nil, err - } trustPEM, err := os.ReadFile(cfg.TrustBundlePath) if err != nil { return nil, fmt.Errorf("atunnel: reading trust bundle: %w", err) @@ -91,17 +88,14 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { return nil, fmt.Errorf("atunnel: trust bundle %q contains no certificates", cfg.TrustBundlePath) } - credentialBundlePath := cfg.CredentialBundlePath client := &Client{ gatewayAddress: cfg.GatewayAddress, dialContext: (&net.Dialer{}).DialContext, tlsConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: rootCAs, - ServerName: cfg.ServerName, - GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - return loadCredentialBundle(credentialBundlePath) - }, + MinVersion: tls.VersionTLS12, + RootCAs: rootCAs, + ServerName: cfg.ServerName, + GetClientCertificate: cfg.GetClientCertificate, }, } for _, opt := range opts { @@ -112,14 +106,10 @@ func NewClient(cfg ClientConfig, opts ...ClientOption) (*Client, error) { // DialContext opens a CONNECT tunnel to destination. destination becomes the // request authority, so it must include an explicit port. -func (c *Client) DialContext(ctx context.Context, destination, bearerToken string) (net.Conn, error) { +func (c *Client) DialContext(ctx context.Context, destination string) (net.Conn, error) { if err := validateDestination(destination); err != nil { return nil, err } - if bearerToken == "" { - return nil, fmt.Errorf("atunnel: actor bearer token is required") - } - rawConn, err := c.dialContext(ctx, "tcp", c.gatewayAddress) if err != nil { return nil, fmt.Errorf("atunnel: connecting to egress gateway: %w", err) @@ -134,7 +124,6 @@ func (c *Client) DialContext(ctx context.Context, destination, bearerToken strin Method: http.MethodConnect, URL: &url.URL{Host: destination}, Host: destination, - Header: http.Header{"Authorization": []string{"Bearer " + bearerToken}}, } if err := req.Write(tlsConn); err != nil { _ = tlsConn.Close() diff --git a/internal/atunnel/client_test.go b/internal/atunnel/client_test.go index f54370fd3..4f9dc78e4 100644 --- a/internal/atunnel/client_test.go +++ b/internal/atunnel/client_test.go @@ -55,7 +55,7 @@ func TestClientDialContext(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - conn, err := client.DialContext(context.Background(), "192.0.2.10:443", "actor-token") + conn, err := client.DialContext(context.Background(), "192.0.2.10:443") if err != nil { t.Fatal(err) } @@ -73,8 +73,8 @@ func TestClientDialContext(t *testing.T) { t.Errorf("legacy identity header %q was sent", name) } } - if got := gotRequest.Header.Get("Authorization"); got != "Bearer actor-token" { - t.Errorf("Authorization = %q, want Bearer actor-token", got) + if got := gotRequest.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty", got) } buffered := make([]byte, len("hello")) @@ -97,7 +97,7 @@ func TestClientDialContextRejected(t *testing.T) { }) client := newTestClient(t, ca, WithDialer(dialFixedAddress(gatewayAddress))) - _, err := client.DialContext(context.Background(), "192.0.2.10:443", "actor-token") + _, err := client.DialContext(context.Background(), "192.0.2.10:443") if err == nil || !strings.Contains(err.Error(), "denied by policy") { t.Fatalf("DialContext error = %v, want policy rejection", err) } @@ -109,26 +109,19 @@ func TestClientDialContextValidatesInput(t *testing.T) { tests := []struct { name string destination string - bearerToken string }{ { name: "destination has no port", destination: "192.0.2.10", - bearerToken: "actor-token", }, { name: "destination is a hostname", destination: "example.com:443", - bearerToken: "actor-token", - }, - { - name: "missing bearer token", - destination: "192.0.2.10:443", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if _, err := client.DialContext(context.Background(), tt.destination, tt.bearerToken); err == nil { + if _, err := client.DialContext(context.Background(), tt.destination); err == nil { t.Fatal("DialContext unexpectedly succeeded") } }) @@ -146,19 +139,18 @@ func dialFixedAddress(address string) DialFunc { func newTestClient(t *testing.T, ca *testCA, opts ...ClientOption) *Client { t.Helper() dir := t.TempDir() - bundlePath := filepath.Join(dir, "client.pem") trustPath := filepath.Join(dir, "trust.pem") - writeCredentialBundle(t, bundlePath, ca.issue(t, - "spiffe://cluster.local/ns/ate-demo/sa/ateom", + certificate := ca.issue(t, + "spiffe://substrate-actor.local/atespace/team/actor/actor", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - )) + ) if err := os.WriteFile(trustPath, ca.certPEM, 0o600); err != nil { t.Fatal(err) } client, err := NewClient(ClientConfig{ GatewayAddress: "127.0.0.1:1", ServerName: "egress.test", - CredentialBundlePath: bundlePath, + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &certificate, nil }, TrustBundlePath: trustPath, }, opts...) if err != nil { diff --git a/internal/atunnel/credential.go b/internal/atunnel/credential.go index 422d0881f..1d85ad4bb 100644 --- a/internal/atunnel/credential.go +++ b/internal/atunnel/credential.go @@ -16,6 +16,9 @@ package atunnel import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "crypto/x509" "fmt" @@ -23,6 +26,8 @@ import ( "net/url" "os" "path" + "slices" + "sync" "time" "github.com/agent-substrate/substrate/internal/credbundle" @@ -32,36 +37,34 @@ import ( "google.golang.org/grpc/credentials" ) -// ActorJWT is a short-lived credential for one actor assignment. -type ActorJWT struct { - Token string - ExpiresAt time.Time -} - -// BrokerJWTSource obtains actor credentials from the node-local atelet broker. -type BrokerJWTSource struct { +// BrokerCertificateSource owns atunnel's actor private key and obtains the +// matching short-lived certificate from the node-local atelet. +type BrokerCertificateSource struct { socketPath string - audience string tlsConfig *tls.Config + privateKey *ecdsa.PrivateKey + + mu sync.RWMutex + certificate *tls.Certificate } // BrokerConfig configures the node-local atelet credential broker client. type BrokerConfig struct { // SocketPath is the atelet-owned Unix socket shared with this worker. SocketPath string - // CredentialBundlePath is the worker Pod certificate and private key. + // CredentialBundlePath is the worker Pod certificate and private key used + // only to authenticate atunnel to atelet. CredentialBundlePath string // TrustBundlePath verifies atelet's Pod certificate. TrustBundlePath string - // Audience identifies the egress PEP that will consume the minted JWT. - Audience string } -// NewBrokerJWTSource returns a source that mutually authenticates with atelet -// over its Unix socket and requests JWTs for Audience. -func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { - if cfg.SocketPath == "" || cfg.CredentialBundlePath == "" || cfg.TrustBundlePath == "" || cfg.Audience == "" { - return nil, fmt.Errorf("atunnel: credential broker socket, credentials, trust bundle, and audience are required") +// NewBrokerCertificateSource creates one actor key for this activation. The key +// is reused across renewals and never leaves atunnel; only its CSR crosses the +// credential broker socket. +func NewBrokerCertificateSource(cfg BrokerConfig) (*BrokerCertificateSource, error) { + if cfg.SocketPath == "" || cfg.CredentialBundlePath == "" || cfg.TrustBundlePath == "" { + return nil, fmt.Errorf("atunnel: credential broker socket, credentials, and trust bundle are required") } localCert, err := credbundle.Parse(cfg.CredentialBundlePath) if err != nil { @@ -79,6 +82,10 @@ func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { if !roots.AppendCertsFromPEM(trustPEM) { return nil, fmt.Errorf("atunnel: credential broker trust bundle contains no certificates") } + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("atunnel: generate actor private key: %w", err) + } expectedURI := (&url.URL{Scheme: "spiffe", Host: "cluster.local", Path: path.Join("ns", "ate-system", "sa", "atelet")}).String() tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS13, @@ -110,11 +117,16 @@ func NewBrokerJWTSource(cfg BrokerConfig) (*BrokerJWTSource, error) { }, } - return &BrokerJWTSource{socketPath: cfg.SocketPath, audience: cfg.Audience, tlsConfig: tlsConfig}, nil + return &BrokerCertificateSource{socketPath: cfg.SocketPath, tlsConfig: tlsConfig, privateKey: privateKey}, nil } -// Mint requests a fresh actor JWT from atelet. -func (s *BrokerJWTSource) Mint(ctx context.Context) (ActorJWT, error) { +// Mint requests and installs a fresh certificate for the source's existing +// actor key. It returns the new expiry for renewal scheduling. +func (s *BrokerCertificateSource) Mint(ctx context.Context) (time.Time, error) { + csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, s.privateKey) + if err != nil { + return time.Time{}, fmt.Errorf("atunnel: create actor CSR: %w", err) + } // A fresh connection picks up rotated worker credentials and forces atelet's // current certificate and node identity to be verified for every mint. conn, err := grpc.NewClient("passthrough:///credential-broker", @@ -124,16 +136,49 @@ func (s *BrokerJWTSource) Mint(ctx context.Context) (ActorJWT, error) { }), ) if err != nil { - return ActorJWT{}, err + return time.Time{}, err } defer conn.Close() - resp, err := ateletpb.NewCredentialBrokerClient(conn).MintActorJWT(ctx, &ateletpb.MintActorJWTRequest{Audience: s.audience}) + resp, err := ateletpb.NewCredentialBrokerClient(conn).MintActorCertificate(ctx, &ateletpb.MintActorCertificateRequest{CertificateSigningRequest: csr}) + if err != nil { + return time.Time{}, fmt.Errorf("atunnel: mint actor certificate: %w", err) + } + chain := resp.GetActorCertificates() + if len(chain) == 0 { + return time.Time{}, fmt.Errorf("atunnel: credential broker returned no actor certificate") + } + leaf, err := x509.ParseCertificate(chain[0]) if err != nil { - return ActorJWT{}, fmt.Errorf("atunnel: mint actor JWT: %w", err) + return time.Time{}, fmt.Errorf("atunnel: parse actor certificate: %w", err) + } + if !s.privateKey.PublicKey.Equal(leaf.PublicKey) { + return time.Time{}, fmt.Errorf("atunnel: actor certificate does not match private key") + } + now := time.Now() + if now.Before(leaf.NotBefore) || !leaf.NotAfter.After(now) { + return time.Time{}, fmt.Errorf("atunnel: credential broker returned an invalid actor certificate lifetime") } - expiresAt := resp.GetExpirationTime().AsTime() - if resp.GetActorJwt() == "" || !expiresAt.After(time.Now()) { - return ActorJWT{}, fmt.Errorf("atunnel: credential broker returned an invalid actor JWT") + if !slices.Contains(leaf.ExtKeyUsage, x509.ExtKeyUsageClientAuth) { + return time.Time{}, fmt.Errorf("atunnel: actor certificate cannot authenticate a TLS client") + } + identity, err := substratex509.ActorIdentityFromCertificate(leaf) + if err != nil || identity == nil { + return time.Time{}, fmt.Errorf("atunnel: actor certificate has no valid actor identity") + } + cert := &tls.Certificate{Certificate: chain, PrivateKey: s.privateKey, Leaf: leaf} + s.mu.Lock() + s.certificate = cert + s.mu.Unlock() + return leaf.NotAfter, nil +} + +// GetClientCertificate supplies the current actor certificate to the egress +// gateway TLS handshake and refuses to use it after expiry. +func (s *BrokerCertificateSource) GetClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.certificate == nil || !s.certificate.Leaf.NotAfter.After(time.Now()) { + return nil, fmt.Errorf("atunnel: no valid actor certificate") } - return ActorJWT{Token: resp.GetActorJwt(), ExpiresAt: expiresAt}, nil + return s.certificate, nil } diff --git a/internal/atunnel/credential_test.go b/internal/atunnel/credential_test.go index 244bd3dfa..1cb327ed3 100644 --- a/internal/atunnel/credential_test.go +++ b/internal/atunnel/credential_test.go @@ -20,6 +20,8 @@ import ( "crypto/rand" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" + "math/big" "net" "os" "path/filepath" @@ -30,75 +32,89 @@ import ( "github.com/agent-substrate/substrate/internal/proto/ateletpb" "github.com/agent-substrate/substrate/internal/substratex509" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" - "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/grpc/status" ) -func TestBrokerJWTSourceMint(t *testing.T) { - expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) - source, broker := newTestBrokerJWTSource(t, testAteletIdentity("node-a"), &ateletpb.MintActorJWTResponse{ - ActorJwt: "actor-token", - ExpirationTime: timestamppb.New(expiresAt), - }) - +func TestBrokerCertificateSourceMintsAndReusesKey(t *testing.T) { + source, broker := newTestBrokerCertificateSource(t, testAteletIdentity("node-a"), time.Hour) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - got, err := source.Mint(ctx) + + for range 2 { + if _, err := source.Mint(ctx); err != nil { + t.Fatal(err) + } + } + first, second := <-broker.publicKeys, <-broker.publicKeys + if string(first) != string(second) { + t.Fatal("renewal replaced the actor private key") + } + cert, err := source.GetClientCertificate(nil) if err != nil { t.Fatal(err) } - if got.Token != "actor-token" || !got.ExpiresAt.Equal(expiresAt) { - t.Errorf("Mint() = %+v, want actor-token expiring at %v", got, expiresAt) + identity, err := substratex509.ActorIdentityFromCertificate(cert.Leaf) + if err != nil { + t.Fatal(err) } - select { - case req := <-broker.requests: - if req.GetAudience() != "egress.test" { - t.Errorf("audience = %q, want egress.test", req.GetAudience()) - } - case <-ctx.Done(): - t.Fatal("credential broker received no request") + if identity == nil || identity.ActorUid != "actor-uid" { + t.Fatalf("actor identity = %+v", identity) } } -func TestBrokerJWTSourceRejectsAteletOnDifferentNode(t *testing.T) { - source, _ := newTestBrokerJWTSource(t, testAteletIdentity("node-b"), &ateletpb.MintActorJWTResponse{ - ActorJwt: "actor-token", - ExpirationTime: timestamppb.New(time.Now().Add(time.Hour)), - }) - +func TestBrokerCertificateSourceRejectsAteletOnDifferentNode(t *testing.T) { + source, _ := newTestBrokerCertificateSource(t, testAteletIdentity("node-b"), time.Hour) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - _, err := source.Mint(ctx) - if err == nil || !strings.Contains(err.Error(), "not on worker node") { + if _, err := source.Mint(ctx); err == nil || !strings.Contains(err.Error(), "not on worker node") { t.Fatalf("Mint() error = %v, want node identity rejection", err) } } -func TestBrokerJWTSourceRejectsInvalidJWT(t *testing.T) { - source, _ := newTestBrokerJWTSource(t, testAteletIdentity("node-a"), &ateletpb.MintActorJWTResponse{ - ExpirationTime: timestamppb.New(time.Now().Add(time.Hour)), - }) - +func TestBrokerCertificateSourceRejectsExpiredCertificate(t *testing.T) { + source, _ := newTestBrokerCertificateSource(t, testAteletIdentity("node-a"), -time.Minute) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - _, err := source.Mint(ctx) - if err == nil || !strings.Contains(err.Error(), "invalid actor JWT") { - t.Fatalf("Mint() error = %v, want invalid JWT rejection", err) + if _, err := source.Mint(ctx); err == nil || !strings.Contains(err.Error(), "invalid actor certificate lifetime") { + t.Fatalf("Mint() error = %v, want expired certificate rejection", err) } } type credentialBrokerStub struct { ateletpb.UnimplementedCredentialBrokerServer - response *ateletpb.MintActorJWTResponse - requests chan *ateletpb.MintActorJWTRequest + ca *testCA + lifetime time.Duration + publicKeys chan []byte } -func (s *credentialBrokerStub) MintActorJWT(_ context.Context, req *ateletpb.MintActorJWTRequest) (*ateletpb.MintActorJWTResponse, error) { - s.requests <- req - return s.response, nil +func (s *credentialBrokerStub) MintActorCertificate(_ context.Context, req *ateletpb.MintActorCertificateRequest) (*ateletpb.MintActorCertificateResponse, error) { + csr, err := x509.ParseCertificateRequest(req.GetCertificateSigningRequest()) + if err != nil || csr.CheckSignature() != nil { + return nil, status.Error(codes.InvalidArgument, "invalid CSR") + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: "actor"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(s.lifetime), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + if err := substratex509.AddActorIdentityToCertificate(&substratex509.ActorIdentity{Atespace: "team", ActorName: "actor", ActorUid: "actor-uid"}, template); err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + der, err := x509.CreateCertificate(rand.Reader, template, s.ca.cert, csr.PublicKey, s.ca.key) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + s.publicKeys <- csr.RawSubjectPublicKeyInfo + return &ateletpb.MintActorCertificateResponse{ActorCertificates: [][]byte{der}}, nil } -func newTestBrokerJWTSource(t *testing.T, ateletIdentity *substratex509.PodIdentity, response *ateletpb.MintActorJWTResponse) (*BrokerJWTSource, *credentialBrokerStub) { +func newTestBrokerCertificateSource(t *testing.T, ateletIdentity *substratex509.PodIdentity, lifetime time.Duration) (*BrokerCertificateSource, *credentialBrokerStub) { t.Helper() ca := newTestCA(t) workerCert := issueTestPodCertificate(t, ca, &substratex509.PodIdentity{ @@ -134,7 +150,7 @@ func newTestBrokerJWTSource(t *testing.T, ateletIdentity *substratex509.PodIdent ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs, }))) - broker := &credentialBrokerStub{response: response, requests: make(chan *ateletpb.MintActorJWTRequest, 1)} + broker := &credentialBrokerStub{ca: ca, lifetime: lifetime, publicKeys: make(chan []byte, 2)} ateletpb.RegisterCredentialBrokerServer(server, broker) go func() { _ = server.Serve(listener) }() t.Cleanup(func() { @@ -142,11 +158,10 @@ func newTestBrokerJWTSource(t *testing.T, ateletIdentity *substratex509.PodIdent _ = listener.Close() }) - source, err := NewBrokerJWTSource(BrokerConfig{ + source, err := NewBrokerCertificateSource(BrokerConfig{ SocketPath: socketPath, CredentialBundlePath: credentialPath, TrustBundlePath: trustPath, - Audience: "egress.test", }) if err != nil { t.Fatal(err) diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index af177bbe8..76ae00993 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -27,11 +27,11 @@ import ( // egressDialer opens an authenticated tunnel to an original destination. type egressDialer interface { - DialContext(context.Context, string, string) (net.Conn, error) + DialContext(context.Context, string) (net.Conn, error) } -type actorJWTSource interface { - Mint(context.Context) (ActorJWT, error) +type actorCertificateSource interface { + Mint(context.Context) (time.Time, error) } // OriginalDestination returns the address that a transparently intercepted @@ -49,11 +49,11 @@ type Egress struct { } type egressActivation struct { - dialer egressDialer - jwtSource actorJWTSource - jwt ActorJWT + dialer egressDialer + certificateSource actorCertificateSource + expiresAt time.Time - // ctx scopes JWT renewal and every tunnel opened by this activation. wg + // ctx scopes certificate renewal and every tunnel opened by this activation. wg // lets Deactivate wait until both renewal and tunnel forwarding have exited. ctx context.Context cancel context.CancelFunc @@ -95,16 +95,17 @@ func (e *Egress) Serve(ctx context.Context, listener net.Listener) error { } } -// Activate allows egress with a previously obtained actor JWT and renews it until deactivation. -func (e *Egress) Activate(dialer egressDialer, jwtSource actorJWTSource, jwt ActorJWT) error { +// Activate allows egress with a previously obtained actor certificate and +// renews it until deactivation. +func (e *Egress) Activate(dialer egressDialer, certificateSource actorCertificateSource, expiresAt time.Time) error { if dialer == nil { return fmt.Errorf("atunnel: egress dialer is required") } - if jwtSource == nil { - return fmt.Errorf("atunnel: actor JWT source is required") + if certificateSource == nil { + return fmt.Errorf("atunnel: actor certificate source is required") } - if jwt.Token == "" || !jwt.ExpiresAt.After(time.Now()) { - return fmt.Errorf("atunnel: valid actor JWT is required") + if !expiresAt.After(time.Now()) { + return fmt.Errorf("atunnel: valid actor certificate is required") } e.mu.Lock() defer e.mu.Unlock() @@ -113,44 +114,44 @@ func (e *Egress) Activate(dialer egressDialer, jwtSource actorJWTSource, jwt Act } activationCtx, cancel := context.WithCancel(context.Background()) active := &egressActivation{ - dialer: dialer, - jwtSource: jwtSource, - jwt: jwt, - ctx: activationCtx, - cancel: cancel, + dialer: dialer, + certificateSource: certificateSource, + expiresAt: expiresAt, + ctx: activationCtx, + cancel: cancel, } e.active = active active.wg.Add(1) - go e.renew(active, jwt.ExpiresAt) + go e.renew(active, expiresAt) return nil } func (e *Egress) renew(active *egressActivation, expiresAt time.Time) { defer active.wg.Done() // Schedule from the credential's remaining lifetime: renew at 90%, then - // retry failures only while the currently installed JWT is still valid. + // retry failures only while the currently installed certificate is valid. delay := renewAfter(expiresAt) for waitForRenewal(active.ctx, delay) { - next, err := active.jwtSource.Mint(active.ctx) + nextExpiry, err := active.certificateSource.Mint(active.ctx) if err != nil { delay = retryAfter(expiresAt) continue } - if next.Token == "" || !next.ExpiresAt.After(time.Now()) { + if !nextExpiry.After(time.Now()) { delay = retryAfter(expiresAt) continue } e.mu.Lock() // Check cancellation under the same lock as Deactivate. Whichever wins - // the lock last either installs a live JWT or leaves the activation empty; + // the lock last either installs a live expiry or leaves the activation empty; // renewal can never restore a credential after deactivation cleared it. if active.ctx.Err() != nil { e.mu.Unlock() return } - active.jwt = next + active.expiresAt = nextExpiry e.mu.Unlock() - expiresAt = next.ExpiresAt + expiresAt = nextExpiry delay = renewAfter(expiresAt) } } @@ -186,7 +187,7 @@ func (e *Egress) Deactivate(ctx context.Context) error { active := e.active e.active = nil if active != nil { - active.jwt = ActorJWT{} + active.expiresAt = time.Time{} active.cancel() } e.mu.Unlock() @@ -215,14 +216,13 @@ func (e *Egress) handle(downstream net.Conn) { _ = downstream.Close() return } - if time.Now().Compare(active.jwt.ExpiresAt) >= 0 { - // Expiry blocks only new tunnels. Connections admitted with a valid JWT - // retain their copied token and are allowed to drain normally. + if time.Now().Compare(active.expiresAt) >= 0 { + // Expiry blocks only new tunnels. Connections admitted with a valid + // certificate have completed mTLS and are allowed to drain normally. e.mu.Unlock() _ = downstream.Close() return } - bearerToken := active.jwt.Token active.wg.Add(1) e.mu.Unlock() @@ -235,7 +235,7 @@ func (e *Egress) handle(downstream net.Conn) { slog.WarnContext(active.ctx, "atunnel failed to resolve original egress destination", slog.Any("err", err)) return } - upstream, err := active.dialer.DialContext(active.ctx, destination, bearerToken) + upstream, err := active.dialer.DialContext(active.ctx, destination) if err != nil { slog.WarnContext(active.ctx, "atunnel failed to open egress tunnel", slog.String("destination", destination), slog.Any("err", err)) return diff --git a/internal/atunnel/egress_test.go b/internal/atunnel/egress_test.go index e3d6e2e4f..7c492f473 100644 --- a/internal/atunnel/egress_test.go +++ b/internal/atunnel/egress_test.go @@ -32,11 +32,11 @@ func TestEgressActivationFailsClosed(t *testing.T) { if err != nil { t.Fatal(err) } - dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { t.Fatal("dialed after failed activation") return nil, nil }) - if err := egress.Activate(dialer, fakeActorJWTSource{err: errors.New("renewal failed")}, ActorJWT{}); err == nil { + if err := egress.Activate(dialer, fakeActorCertificateSource{err: errors.New("renewal failed")}, time.Time{}); err == nil { t.Fatal("Activate() succeeded") } actor, proxy := net.Pipe() @@ -55,7 +55,7 @@ func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { defer upstreamGateway.Close() var dials atomic.Int32 var mints atomic.Int32 - dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { dials.Add(1) return upstreamProxy, nil }) @@ -63,7 +63,7 @@ func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { if err != nil { t.Fatal(err) } - if err := egress.Activate(dialer, fakeActorJWTSource{err: errors.New("renewal failed"), calls: &mints}, ActorJWT{Token: "short-lived", ExpiresAt: time.Now().Add(50 * time.Millisecond)}); err != nil { + if err := egress.Activate(dialer, fakeActorCertificateSource{err: errors.New("renewal failed"), calls: &mints}, time.Now().Add(50*time.Millisecond)); err != nil { t.Fatal(err) } actor, proxy := net.Pipe() @@ -80,7 +80,7 @@ func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { go func() { _, _ = actor.Write([]byte("still-open")) }() buf := make([]byte, len("still-open")) if _, err := io.ReadFull(upstreamGateway, buf); err != nil { - t.Fatalf("established tunnel closed after JWT expiry: %v", err) + t.Fatalf("established tunnel closed after certificate expiry: %v", err) } newActor, newProxy := net.Pipe() @@ -90,7 +90,7 @@ func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { } egress.handle(newProxy) if _, err := newActor.Read(make([]byte, 1)); err == nil { - t.Fatal("new tunnel admitted after JWT expiry") + t.Fatal("new tunnel admitted after certificate expiry") } if dials.Load() != 1 { t.Fatalf("dials = %d after expiry, want 1", dials.Load()) @@ -104,49 +104,45 @@ func TestEgressExpiryRejectsNewButPreservesEstablished(t *testing.T) { func TestEgressRenewsBeforeExpiry(t *testing.T) { var mints atomic.Int32 renewed := make(chan struct{}, 1) - source := fakeActorJWTSource{ - jwt: ActorJWT{Token: "renewed", ExpiresAt: time.Now().Add(time.Hour)}, - calls: &mints, - called: renewed, + renewedExpiry := time.Now().Add(time.Hour) + source := fakeActorCertificateSource{ + expiresAt: renewedExpiry, + calls: &mints, + called: renewed, } - got := make(chan string, 1) upstream, gateway := net.Pipe() defer gateway.Close() - dialer := egressDialerFunc(func(_ context.Context, _ string, bearerToken string) (net.Conn, error) { - got <- bearerToken + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { return upstream, nil }) egress, err := NewEgress(func(net.Conn) (string, error) { return "192.0.2.10:443", nil }) if err != nil { t.Fatal(err) } - if err := egress.Activate(dialer, source, ActorJWT{Token: "initial", ExpiresAt: time.Now().Add(80 * time.Millisecond)}); err != nil { + if err := egress.Activate(dialer, source, time.Now().Add(80*time.Millisecond)); err != nil { t.Fatal(err) } select { case <-renewed: case <-time.After(time.Second): - t.Fatal("JWT was not renewed") + t.Fatal("certificate was not renewed") } deadline := time.Now().Add(time.Second) for { egress.mu.Lock() - token := egress.active.jwt.Token + expiresAt := egress.active.expiresAt egress.mu.Unlock() - if token == "renewed" { + if expiresAt.Equal(renewedExpiry) { break } if time.Now().After(deadline) { - t.Fatal("renewed JWT was not installed") + t.Fatal("renewed certificate expiry was not installed") } time.Sleep(time.Millisecond) } actor, proxy := net.Pipe() defer actor.Close() egress.handle(proxy) - if bearerToken := <-got; bearerToken != "renewed" { - t.Fatalf("token = %q, want renewed", bearerToken) - } _ = egress.Deactivate(context.Background()) } @@ -157,12 +153,12 @@ func TestEgressDeactivationDropsConcurrentRenewal(t *testing.T) { if err != nil { t.Fatal(err) } - dialer := egressDialerFunc(func(context.Context, string, string) (net.Conn, error) { return nil, nil }) - if err := egress.Activate(dialer, fakeActorJWTSource{ - jwt: ActorJWT{Token: "renewed", ExpiresAt: time.Now().Add(time.Hour)}, - called: started, - release: release, - }, ActorJWT{Token: "initial", ExpiresAt: time.Now().Add(50 * time.Millisecond)}); err != nil { + dialer := egressDialerFunc(func(context.Context, string) (net.Conn, error) { return nil, nil }) + if err := egress.Activate(dialer, fakeActorCertificateSource{ + expiresAt: time.Now().Add(time.Hour), + called: started, + release: release, + }, time.Now().Add(50*time.Millisecond)); err != nil { t.Fatal(err) } egress.mu.Lock() @@ -171,7 +167,7 @@ func TestEgressDeactivationDropsConcurrentRenewal(t *testing.T) { select { case <-started: case <-time.After(time.Second): - t.Fatal("JWT renewal did not start") + t.Fatal("certificate renewal did not start") } done := make(chan error, 1) go func() { done <- egress.Deactivate(context.Background()) }() @@ -180,8 +176,8 @@ func TestEgressDeactivationDropsConcurrentRenewal(t *testing.T) { if err := <-done; err != nil { t.Fatal(err) } - if active.jwt != (ActorJWT{}) { - t.Fatalf("deactivated JWT = %+v, want empty", active.jwt) + if !active.expiresAt.IsZero() { + t.Fatalf("deactivated certificate expiry = %v, want zero", active.expiresAt) } } @@ -198,8 +194,8 @@ func TestEgressEndToEnd(t *testing.T) { t.Errorf("gateway connection has type %T, want *tls.Conn", conn) } else { peer := tlsConn.ConnectionState().PeerCertificates[0] - if len(peer.URIs) != 1 || peer.URIs[0].String() != "spiffe://cluster.local/ns/ate-demo/sa/ateom" { - t.Errorf("client identity = %v, want ateom SPIFFE ID", peer.URIs) + if len(peer.URIs) != 1 || peer.URIs[0].String() != "spiffe://substrate-actor.local/atespace/team/actor/actor" { + t.Errorf("client identity = %v, want actor SPIFFE ID", peer.URIs) } } @@ -227,7 +223,7 @@ func TestEgressEndToEnd(t *testing.T) { if err != nil { t.Fatal(err) } - if err := egress.Activate(client, fakeActorJWTSource{jwt: ActorJWT{Token: "actor-token", ExpiresAt: time.Now().Add(time.Hour)}}, ActorJWT{Token: "actor-token", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + if err := egress.Activate(client, fakeActorCertificateSource{expiresAt: time.Now().Add(time.Hour)}, time.Now().Add(time.Hour)); err != nil { t.Fatal(err) } @@ -242,8 +238,8 @@ func TestEgressEndToEnd(t *testing.T) { if req.Method != http.MethodConnect || req.Host != "192.0.2.10:443" { t.Errorf("request = %s %s, want CONNECT 192.0.2.10:443", req.Method, req.Host) } - if got := req.Header.Get("Authorization"); got != "Bearer actor-token" { - t.Errorf("Authorization = %q, want Bearer actor-token", got) + if got := req.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty", got) } for name := range req.Header { if strings.HasPrefix(strings.ToLower(name), "x-ate-") { @@ -290,21 +286,21 @@ func TestEgressRejectsInactiveConnection(t *testing.T) { } } -type egressDialerFunc func(context.Context, string, string) (net.Conn, error) +type egressDialerFunc func(context.Context, string) (net.Conn, error) -func (f egressDialerFunc) DialContext(ctx context.Context, destination, bearerToken string) (net.Conn, error) { - return f(ctx, destination, bearerToken) +func (f egressDialerFunc) DialContext(ctx context.Context, destination string) (net.Conn, error) { + return f(ctx, destination) } -type fakeActorJWTSource struct { - jwt ActorJWT - err error - calls *atomic.Int32 - called chan<- struct{} - release <-chan struct{} +type fakeActorCertificateSource struct { + expiresAt time.Time + err error + calls *atomic.Int32 + called chan<- struct{} + release <-chan struct{} } -func (s fakeActorJWTSource) Mint(context.Context) (ActorJWT, error) { +func (s fakeActorCertificateSource) Mint(context.Context) (time.Time, error) { if s.calls != nil { s.calls.Add(1) } @@ -317,5 +313,5 @@ func (s fakeActorJWTSource) Mint(context.Context) (ActorJWT, error) { if s.release != nil { <-s.release } - return s.jwt, s.err + return s.expiresAt, s.err } diff --git a/internal/proto/ateletpb/atelet.pb.go b/internal/proto/ateletpb/atelet.pb.go index d1f7e90db..2fc73a059 100644 --- a/internal/proto/ateletpb/atelet.pb.go +++ b/internal/proto/ateletpb/atelet.pb.go @@ -23,7 +23,6 @@ package ateletpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -201,27 +200,29 @@ func (SnapshotScope) EnumDescriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{2} } -type MintActorJWTRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Audience string `protobuf:"bytes,1,opt,name=audience,proto3" json:"audience,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type MintActorCertificateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // DER-encoded PKCS #10 certificate signing request. Atunnel retains the + // corresponding private key. + CertificateSigningRequest []byte `protobuf:"bytes,1,opt,name=certificate_signing_request,json=certificateSigningRequest,proto3" json:"certificate_signing_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *MintActorJWTRequest) Reset() { - *x = MintActorJWTRequest{} +func (x *MintActorCertificateRequest) Reset() { + *x = MintActorCertificateRequest{} mi := &file_atelet_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MintActorJWTRequest) String() string { +func (x *MintActorCertificateRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MintActorJWTRequest) ProtoMessage() {} +func (*MintActorCertificateRequest) ProtoMessage() {} -func (x *MintActorJWTRequest) ProtoReflect() protoreflect.Message { +func (x *MintActorCertificateRequest) ProtoReflect() protoreflect.Message { mi := &file_atelet_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -233,40 +234,40 @@ func (x *MintActorJWTRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MintActorJWTRequest.ProtoReflect.Descriptor instead. -func (*MintActorJWTRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use MintActorCertificateRequest.ProtoReflect.Descriptor instead. +func (*MintActorCertificateRequest) Descriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{0} } -func (x *MintActorJWTRequest) GetAudience() string { +func (x *MintActorCertificateRequest) GetCertificateSigningRequest() []byte { if x != nil { - return x.Audience + return x.CertificateSigningRequest } - return "" + return nil } -type MintActorJWTResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` - ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type MintActorCertificateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // DER-encoded leaf followed by any intermediate certificates. + ActorCertificates [][]byte `protobuf:"bytes,1,rep,name=actor_certificates,json=actorCertificates,proto3" json:"actor_certificates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *MintActorJWTResponse) Reset() { - *x = MintActorJWTResponse{} +func (x *MintActorCertificateResponse) Reset() { + *x = MintActorCertificateResponse{} mi := &file_atelet_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MintActorJWTResponse) String() string { +func (x *MintActorCertificateResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MintActorJWTResponse) ProtoMessage() {} +func (*MintActorCertificateResponse) ProtoMessage() {} -func (x *MintActorJWTResponse) ProtoReflect() protoreflect.Message { +func (x *MintActorCertificateResponse) ProtoReflect() protoreflect.Message { mi := &file_atelet_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -278,21 +279,14 @@ func (x *MintActorJWTResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MintActorJWTResponse.ProtoReflect.Descriptor instead. -func (*MintActorJWTResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use MintActorCertificateResponse.ProtoReflect.Descriptor instead. +func (*MintActorCertificateResponse) Descriptor() ([]byte, []int) { return file_atelet_proto_rawDescGZIP(), []int{1} } -func (x *MintActorJWTResponse) GetActorJwt() string { +func (x *MintActorCertificateResponse) GetActorCertificates() [][]byte { if x != nil { - return x.ActorJwt - } - return "" -} - -func (x *MintActorJWTResponse) GetExpirationTime() *timestamppb.Timestamp { - if x != nil { - return x.ExpirationTime + return x.ActorCertificates } return nil } @@ -413,9 +407,7 @@ func (x *RunRequest) GetEgressGateway() *EgressGateway { type EgressGateway struct { state protoimpl.MessageState `protogen:"open.v1"` // address is the remote gateway's host:port. - Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` - // audience is the logical PEP audience placed in the actor JWT. - Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -457,13 +449,6 @@ func (x *EgressGateway) GetAddress() string { return "" } -func (x *EgressGateway) GetAudience() string { - if x != nil { - return x.Audience - } - return "" -} - // AssetFile is one content-addressed file atelet fetches for a sandbox runtime // (e.g. the gVisor runsc binary). type AssetFile struct { @@ -1721,12 +1706,11 @@ var File_atelet_proto protoreflect.FileDescriptor const file_atelet_proto_rawDesc = "" + "\n" + - "\fatelet.proto\x12\x06atelet\x1a\x1fgoogle/protobuf/timestamp.proto\"1\n" + - "\x13MintActorJWTRequest\x12\x1a\n" + - "\baudience\x18\x01 \x01(\tR\baudience\"x\n" + - "\x14MintActorJWTResponse\x12\x1b\n" + - "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\x12C\n" + - "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\x9e\x03\n" + + "\fatelet.proto\x12\x06atelet\"]\n" + + "\x1bMintActorCertificateRequest\x12>\n" + + "\x1bcertificate_signing_request\x18\x01 \x01(\fR\x19certificateSigningRequest\"M\n" + + "\x1cMintActorCertificateResponse\x12-\n" + + "\x12actor_certificates\x18\x01 \x03(\fR\x11actorCertificates\"\x9e\x03\n" + "\n" + "RunRequest\x12(\n" + "\x10target_ateom_uid\x18\x01 \x01(\tR\x0etargetAteomUid\x12\x1a\n" + @@ -1738,10 +1722,9 @@ const file_atelet_proto_rawDesc = "" + "\x13actor_template_name\x18\x06 \x01(\tR\x11actorTemplateName\x12(\n" + "\x04spec\x18\a \x01(\v2\x14.atelet.WorkloadSpecR\x04spec\x12<\n" + "\x0esandbox_assets\x18\b \x01(\v2\x15.atelet.SandboxAssetsR\rsandboxAssets\x12<\n" + - "\x0eegress_gateway\x18\t \x01(\v2\x15.atelet.EgressGatewayR\regressGateway\"E\n" + + "\x0eegress_gateway\x18\t \x01(\v2\x15.atelet.EgressGatewayR\regressGateway\")\n" + "\rEgressGateway\x12\x18\n" + - "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" + - "\baudience\x18\x02 \x01(\tR\baudience\"5\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"5\n" + "\tAssetFile\x12\x10\n" + "\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n" + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x8e\x01\n" + @@ -1849,9 +1832,9 @@ const file_atelet_proto_rawDesc = "" + "\x1aSNAPSHOT_SCOPE_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13SNAPSHOT_SCOPE_FULL\x10\x01\x12\x17\n" + "\x13SNAPSHOT_SCOPE_DATA\x10\x02\x12!\n" + - "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032_\n" + - "\x10CredentialBroker\x12K\n" + - "\fMintActorJWT\x12\x1b.atelet.MintActorJWTRequest\x1a\x1c.atelet.MintActorJWTResponse\"\x002\xc4\x01\n" + + "\x1dSNAPSHOT_SCOPE_DATA_ON_GOLDEN\x10\x032w\n" + + "\x10CredentialBroker\x12c\n" + + "\x14MintActorCertificate\x12#.atelet.MintActorCertificateRequest\x1a$.atelet.MintActorCertificateResponse\"\x002\xc4\x01\n" + "\vAteomHerder\x120\n" + "\x03Run\x12\x12.atelet.RunRequest\x1a\x13.atelet.RunResponse\"\x00\x12E\n" + "\n" + @@ -1876,8 +1859,8 @@ var file_atelet_proto_goTypes = []any{ (VolumeType)(0), // 0: atelet.VolumeType (CheckpointType)(0), // 1: atelet.CheckpointType (SnapshotScope)(0), // 2: atelet.SnapshotScope - (*MintActorJWTRequest)(nil), // 3: atelet.MintActorJWTRequest - (*MintActorJWTResponse)(nil), // 4: atelet.MintActorJWTResponse + (*MintActorCertificateRequest)(nil), // 3: atelet.MintActorCertificateRequest + (*MintActorCertificateResponse)(nil), // 4: atelet.MintActorCertificateResponse (*RunRequest)(nil), // 5: atelet.RunRequest (*EgressGateway)(nil), // 6: atelet.EgressGateway (*AssetFile)(nil), // 7: atelet.AssetFile @@ -1901,50 +1884,48 @@ var file_atelet_proto_goTypes = []any{ (*RestoreResponse)(nil), // 25: atelet.RestoreResponse nil, // 26: atelet.ArchAssets.FilesEntry nil, // 27: atelet.SandboxAssets.AssetsEntry - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp } var file_atelet_proto_depIdxs = []int32{ - 28, // 0: atelet.MintActorJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp - 10, // 1: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec - 9, // 2: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets - 6, // 3: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway - 26, // 4: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry - 27, // 5: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry - 15, // 6: atelet.WorkloadSpec.containers:type_name -> atelet.Container - 13, // 7: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume - 0, // 8: atelet.Volume.type:type_name -> atelet.VolumeType - 11, // 9: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume - 12, // 10: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource - 16, // 11: atelet.Container.env:type_name -> atelet.EnvEntry - 17, // 12: atelet.Container.readyz:type_name -> atelet.Readyz - 14, // 13: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount - 18, // 14: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction - 10, // 15: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 16: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType - 20, // 17: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 21, // 18: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 19: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope - 10, // 20: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec - 1, // 21: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType - 20, // 22: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration - 21, // 23: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration - 2, // 24: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope - 6, // 25: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway - 7, // 26: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile - 8, // 27: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets - 3, // 28: atelet.CredentialBroker.MintActorJWT:input_type -> atelet.MintActorJWTRequest - 5, // 29: atelet.AteomHerder.Run:input_type -> atelet.RunRequest - 22, // 30: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest - 24, // 31: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest - 4, // 32: atelet.CredentialBroker.MintActorJWT:output_type -> atelet.MintActorJWTResponse - 19, // 33: atelet.AteomHerder.Run:output_type -> atelet.RunResponse - 23, // 34: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse - 25, // 35: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse - 32, // [32:36] is the sub-list for method output_type - 28, // [28:32] is the sub-list for method input_type - 28, // [28:28] is the sub-list for extension type_name - 28, // [28:28] is the sub-list for extension extendee - 0, // [0:28] is the sub-list for field type_name + 10, // 0: atelet.RunRequest.spec:type_name -> atelet.WorkloadSpec + 9, // 1: atelet.RunRequest.sandbox_assets:type_name -> atelet.SandboxAssets + 6, // 2: atelet.RunRequest.egress_gateway:type_name -> atelet.EgressGateway + 26, // 3: atelet.ArchAssets.files:type_name -> atelet.ArchAssets.FilesEntry + 27, // 4: atelet.SandboxAssets.assets:type_name -> atelet.SandboxAssets.AssetsEntry + 15, // 5: atelet.WorkloadSpec.containers:type_name -> atelet.Container + 13, // 6: atelet.WorkloadSpec.volumes:type_name -> atelet.Volume + 0, // 7: atelet.Volume.type:type_name -> atelet.VolumeType + 11, // 8: atelet.Volume.durable_dir:type_name -> atelet.DurableDirVolume + 12, // 9: atelet.Volume.external:type_name -> atelet.ExternalVolumeSource + 16, // 10: atelet.Container.env:type_name -> atelet.EnvEntry + 17, // 11: atelet.Container.readyz:type_name -> atelet.Readyz + 14, // 12: atelet.Container.volume_mounts:type_name -> atelet.VolumeMount + 18, // 13: atelet.Readyz.http_get:type_name -> atelet.HTTPGetAction + 10, // 14: atelet.CheckpointRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 15: atelet.CheckpointRequest.type:type_name -> atelet.CheckpointType + 20, // 16: atelet.CheckpointRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 17: atelet.CheckpointRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 18: atelet.CheckpointRequest.scope:type_name -> atelet.SnapshotScope + 10, // 19: atelet.RestoreRequest.spec:type_name -> atelet.WorkloadSpec + 1, // 20: atelet.RestoreRequest.type:type_name -> atelet.CheckpointType + 20, // 21: atelet.RestoreRequest.local_config:type_name -> atelet.LocalCheckpointConfiguration + 21, // 22: atelet.RestoreRequest.external_config:type_name -> atelet.ExternalCheckpointConfiguration + 2, // 23: atelet.RestoreRequest.scope:type_name -> atelet.SnapshotScope + 6, // 24: atelet.RestoreRequest.egress_gateway:type_name -> atelet.EgressGateway + 7, // 25: atelet.ArchAssets.FilesEntry.value:type_name -> atelet.AssetFile + 8, // 26: atelet.SandboxAssets.AssetsEntry.value:type_name -> atelet.ArchAssets + 3, // 27: atelet.CredentialBroker.MintActorCertificate:input_type -> atelet.MintActorCertificateRequest + 5, // 28: atelet.AteomHerder.Run:input_type -> atelet.RunRequest + 22, // 29: atelet.AteomHerder.Checkpoint:input_type -> atelet.CheckpointRequest + 24, // 30: atelet.AteomHerder.Restore:input_type -> atelet.RestoreRequest + 4, // 31: atelet.CredentialBroker.MintActorCertificate:output_type -> atelet.MintActorCertificateResponse + 19, // 32: atelet.AteomHerder.Run:output_type -> atelet.RunResponse + 23, // 33: atelet.AteomHerder.Checkpoint:output_type -> atelet.CheckpointResponse + 25, // 34: atelet.AteomHerder.Restore:output_type -> atelet.RestoreResponse + 31, // [31:35] is the sub-list for method output_type + 27, // [27:31] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_atelet_proto_init() } diff --git a/internal/proto/ateletpb/atelet.proto b/internal/proto/ateletpb/atelet.proto index 380df47f3..fa78e4a6b 100644 --- a/internal/proto/ateletpb/atelet.proto +++ b/internal/proto/ateletpb/atelet.proto @@ -18,20 +18,20 @@ package atelet; option go_package = "github.com/agent-substrate/substrate/internal/proto/ateletpb"; -import "google/protobuf/timestamp.proto"; - // CredentialBroker gives an authenticated worker its current actor credential. service CredentialBroker { - rpc MintActorJWT(MintActorJWTRequest) returns (MintActorJWTResponse) {} + rpc MintActorCertificate(MintActorCertificateRequest) returns (MintActorCertificateResponse) {} } -message MintActorJWTRequest { - string audience = 1; +message MintActorCertificateRequest { + // DER-encoded PKCS #10 certificate signing request. Atunnel retains the + // corresponding private key. + bytes certificate_signing_request = 1; } -message MintActorJWTResponse { - string actor_jwt = 1; - google.protobuf.Timestamp expiration_time = 2; +message MintActorCertificateResponse { + // DER-encoded leaf followed by any intermediate certificates. + repeated bytes actor_certificates = 1; } service AteomHerder { @@ -73,8 +73,6 @@ message RunRequest { message EgressGateway { // address is the remote gateway's host:port. string address = 1; - // audience is the logical PEP audience placed in the actor JWT. - string audience = 2; } // AssetFile is one content-addressed file atelet fetches for a sandbox runtime diff --git a/internal/proto/ateletpb/atelet_grpc.pb.go b/internal/proto/ateletpb/atelet_grpc.pb.go index 961d3584f..e4e6d1923 100644 --- a/internal/proto/ateletpb/atelet_grpc.pb.go +++ b/internal/proto/ateletpb/atelet_grpc.pb.go @@ -33,7 +33,7 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - CredentialBroker_MintActorJWT_FullMethodName = "/atelet.CredentialBroker/MintActorJWT" + CredentialBroker_MintActorCertificate_FullMethodName = "/atelet.CredentialBroker/MintActorCertificate" ) // CredentialBrokerClient is the client API for CredentialBroker service. @@ -42,7 +42,7 @@ const ( // // CredentialBroker gives an authenticated worker its current actor credential. type CredentialBrokerClient interface { - MintActorJWT(ctx context.Context, in *MintActorJWTRequest, opts ...grpc.CallOption) (*MintActorJWTResponse, error) + MintActorCertificate(ctx context.Context, in *MintActorCertificateRequest, opts ...grpc.CallOption) (*MintActorCertificateResponse, error) } type credentialBrokerClient struct { @@ -53,10 +53,10 @@ func NewCredentialBrokerClient(cc grpc.ClientConnInterface) CredentialBrokerClie return &credentialBrokerClient{cc} } -func (c *credentialBrokerClient) MintActorJWT(ctx context.Context, in *MintActorJWTRequest, opts ...grpc.CallOption) (*MintActorJWTResponse, error) { +func (c *credentialBrokerClient) MintActorCertificate(ctx context.Context, in *MintActorCertificateRequest, opts ...grpc.CallOption) (*MintActorCertificateResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(MintActorJWTResponse) - err := c.cc.Invoke(ctx, CredentialBroker_MintActorJWT_FullMethodName, in, out, cOpts...) + out := new(MintActorCertificateResponse) + err := c.cc.Invoke(ctx, CredentialBroker_MintActorCertificate_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -69,7 +69,7 @@ func (c *credentialBrokerClient) MintActorJWT(ctx context.Context, in *MintActor // // CredentialBroker gives an authenticated worker its current actor credential. type CredentialBrokerServer interface { - MintActorJWT(context.Context, *MintActorJWTRequest) (*MintActorJWTResponse, error) + MintActorCertificate(context.Context, *MintActorCertificateRequest) (*MintActorCertificateResponse, error) mustEmbedUnimplementedCredentialBrokerServer() } @@ -80,8 +80,8 @@ type CredentialBrokerServer interface { // pointer dereference when methods are called. type UnimplementedCredentialBrokerServer struct{} -func (UnimplementedCredentialBrokerServer) MintActorJWT(context.Context, *MintActorJWTRequest) (*MintActorJWTResponse, error) { - return nil, status.Error(codes.Unimplemented, "method MintActorJWT not implemented") +func (UnimplementedCredentialBrokerServer) MintActorCertificate(context.Context, *MintActorCertificateRequest) (*MintActorCertificateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method MintActorCertificate not implemented") } func (UnimplementedCredentialBrokerServer) mustEmbedUnimplementedCredentialBrokerServer() {} func (UnimplementedCredentialBrokerServer) testEmbeddedByValue() {} @@ -104,20 +104,20 @@ func RegisterCredentialBrokerServer(s grpc.ServiceRegistrar, srv CredentialBroke s.RegisterService(&CredentialBroker_ServiceDesc, srv) } -func _CredentialBroker_MintActorJWT_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(MintActorJWTRequest) +func _CredentialBroker_MintActorCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MintActorCertificateRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(CredentialBrokerServer).MintActorJWT(ctx, in) + return srv.(CredentialBrokerServer).MintActorCertificate(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: CredentialBroker_MintActorJWT_FullMethodName, + FullMethod: CredentialBroker_MintActorCertificate_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(CredentialBrokerServer).MintActorJWT(ctx, req.(*MintActorJWTRequest)) + return srv.(CredentialBrokerServer).MintActorCertificate(ctx, req.(*MintActorCertificateRequest)) } return interceptor(ctx, in, info, handler) } @@ -130,8 +130,8 @@ var CredentialBroker_ServiceDesc = grpc.ServiceDesc{ HandlerType: (*CredentialBrokerServer)(nil), Methods: []grpc.MethodDesc{ { - MethodName: "MintActorJWT", - Handler: _CredentialBroker_MintActorJWT_Handler, + MethodName: "MintActorCertificate", + Handler: _CredentialBroker_MintActorCertificate_Handler, }, }, Streams: []grpc.StreamDesc{}, diff --git a/internal/proto/ateompb/ateom.pb.go b/internal/proto/ateompb/ateom.pb.go index 2dcdfc20f..9022c5643 100644 --- a/internal/proto/ateompb/ateom.pb.go +++ b/internal/proto/ateompb/ateom.pb.go @@ -215,9 +215,7 @@ func (x *RunWorkloadRequest) GetEgressGateway() *EgressGateway { type EgressGateway struct { state protoimpl.MessageState `protogen:"open.v1"` // address is the remote gateway's host:port. - Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` - // audience is the logical PEP audience placed in the actor JWT. - Audience string `protobuf:"bytes,2,opt,name=audience,proto3" json:"audience,omitempty"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -259,13 +257,6 @@ func (x *EgressGateway) GetAddress() string { return "" } -func (x *EgressGateway) GetAudience() string { - if x != nil { - return x.Audience - } - return "" -} - // WorkloadSpec parallels Pod, but with far fewer configurable fields. type WorkloadSpec struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -936,10 +927,9 @@ const file_ateom_proto_rawDesc = "" + " \x01(\v2\x14.ateom.EgressGatewayR\regressGateway\x1aD\n" + "\x16RuntimeAssetPathsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"E\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\")\n" + "\rEgressGateway\x12\x18\n" + - "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" + - "\baudience\x18\x02 \x01(\tR\baudience\"@\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"@\n" + "\fWorkloadSpec\x120\n" + "\n" + "containers\x18\x01 \x03(\v2\x10.ateom.ContainerR\n" + diff --git a/internal/proto/ateompb/ateom.proto b/internal/proto/ateompb/ateom.proto index 1b56101d5..ed31ee971 100644 --- a/internal/proto/ateompb/ateom.proto +++ b/internal/proto/ateompb/ateom.proto @@ -72,8 +72,6 @@ message RunWorkloadRequest { message EgressGateway { // address is the remote gateway's host:port. string address = 1; - // audience is the logical PEP audience placed in the actor JWT. - string audience = 2; } // WorkloadSpec parallels Pod, but with far fewer configurable fields. diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index b0daba404..0bb913c86 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -99,7 +99,6 @@ spec: - --client-jwt-issuer=@env - --client-jwt-audience=api.ate-system.svc - --actor-id-jwt-pool=/run/actor-id-jwt-pool/pool.json - - --egress-gateway-audience=egress.ate-system.svc - --actor-id-ca-pool=/run/actor-id-ca-pool/pool.json - --atelet-client-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem - --pod-identity-ca-certs=/run/podidentity.podcert.ate.dev/trust-bundle.pem diff --git a/pkg/proto/ateapipb/ateapi.pb.go b/pkg/proto/ateapipb/ateapi.pb.go index c6e1ba458..3e27ec223 100644 --- a/pkg/proto/ateapipb/ateapi.pb.go +++ b/pkg/proto/ateapipb/ateapi.pb.go @@ -2707,11 +2707,10 @@ func (*DebugClearResponse) Descriptor() ([]byte, []int) { type MintJWTRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - Audience string `protobuf:"bytes,1,opt,name=audience,proto3" json:"audience,omitempty"` + Audience []string `protobuf:"bytes,1,rep,name=audience,proto3" json:"audience,omitempty"` Atespace string `protobuf:"bytes,2,opt,name=atespace,proto3" json:"atespace,omitempty"` ActorName string `protobuf:"bytes,3,opt,name=actor_name,json=actorName,proto3" json:"actor_name,omitempty"` ActorUid string `protobuf:"bytes,4,opt,name=actor_uid,json=actorUid,proto3" json:"actor_uid,omitempty"` - WorkerPodUid string `protobuf:"bytes,5,opt,name=worker_pod_uid,json=workerPodUid,proto3" json:"worker_pod_uid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2746,11 +2745,11 @@ func (*MintJWTRequest) Descriptor() ([]byte, []int) { return file_ateapi_proto_rawDescGZIP(), []int{41} } -func (x *MintJWTRequest) GetAudience() string { +func (x *MintJWTRequest) GetAudience() []string { if x != nil { return x.Audience } - return "" + return nil } func (x *MintJWTRequest) GetAtespace() string { @@ -2774,13 +2773,6 @@ func (x *MintJWTRequest) GetActorUid() string { return "" } -func (x *MintJWTRequest) GetWorkerPodUid() string { - if x != nil { - return x.WorkerPodUid - } - return "" -} - // TODO: check why k8s do ":" and not "/" as a seprator for the Subject format // TODO: whats the right format for the subject? kubernetes follow "system:serviceaccount::". type MintJWTResponse struct { @@ -2804,13 +2796,9 @@ type MintJWTResponse struct { // - `ate.dev`: Ate/Substrate Extension - JSON object // - atespace: (string) The atespace the actor belongs to // - actorName: (string) The actor's name, unique within its atespace - // - actorUid: (string) The actor incarnation - // - actorResourceVersion: (number) The actor version at issuance - // - workerPodUid: (string) The worker Pod bound to this token - ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` - ExpirationTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiration_time,json=expirationTime,proto3" json:"expiration_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ActorJwt string `protobuf:"bytes,1,opt,name=actor_jwt,json=actorJwt,proto3" json:"actor_jwt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MintJWTResponse) Reset() { @@ -2850,13 +2838,6 @@ func (x *MintJWTResponse) GetActorJwt() string { return "" } -func (x *MintJWTResponse) GetExpirationTime() *timestamppb.Timestamp { - if x != nil { - return x.ExpirationTime - } - return nil -} - type MintCertRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Atespace string `protobuf:"bytes,1,opt,name=atespace,proto3" json:"atespace,omitempty"` @@ -2866,8 +2847,11 @@ type MintCertRequest struct { // The signer will ignore the contents of the CSR except to extract the // subject public key. CertificateSigningRequest []byte `protobuf:"bytes,4,opt,name=certificate_signing_request,json=certificateSigningRequest,proto3" json:"certificate_signing_request,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Exact worker Pod requesting the certificate. Ateapi verifies that this + // worker is still assigned to the actor before signing. + WorkerPodUid string `protobuf:"bytes,5,opt,name=worker_pod_uid,json=workerPodUid,proto3" json:"worker_pod_uid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MintCertRequest) Reset() { @@ -2928,6 +2912,13 @@ func (x *MintCertRequest) GetCertificateSigningRequest() []byte { return nil } +func (x *MintCertRequest) GetWorkerPodUid() string { + if x != nil { + return x.WorkerPodUid + } + return "" +} + type MintCertResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Response contains a list of DER encoded certificates. The first entry is the @@ -3163,23 +3154,22 @@ const file_ateapi_proto_rawDesc = "" + "\tnamespace\x18\x01 \x01(\tR\tnamespace\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"\x13\n" + "\x11DebugClearRequest\"\x14\n" + - "\x12DebugClearResponse\"\xaa\x01\n" + + "\x12DebugClearResponse\"\x84\x01\n" + "\x0eMintJWTRequest\x12\x1a\n" + - "\baudience\x18\x01 \x01(\tR\baudience\x12\x1a\n" + + "\baudience\x18\x01 \x03(\tR\baudience\x12\x1a\n" + "\batespace\x18\x02 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x03 \x01(\tR\tactorName\x12\x1b\n" + - "\tactor_uid\x18\x04 \x01(\tR\bactorUid\x12$\n" + - "\x0eworker_pod_uid\x18\x05 \x01(\tR\fworkerPodUid\"s\n" + + "\tactor_uid\x18\x04 \x01(\tR\bactorUid\".\n" + "\x0fMintJWTResponse\x12\x1b\n" + - "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\x12C\n" + - "\x0fexpiration_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0eexpirationTime\"\xa9\x01\n" + + "\tactor_jwt\x18\x01 \x01(\tR\bactorJwt\"\xcf\x01\n" + "\x0fMintCertRequest\x12\x1a\n" + "\batespace\x18\x01 \x01(\tR\batespace\x12\x1d\n" + "\n" + "actor_name\x18\x02 \x01(\tR\tactorName\x12\x1b\n" + "\tactor_uid\x18\x03 \x01(\tR\bactorUid\x12>\n" + - "\x1bcertificate_signing_request\x18\x04 \x01(\fR\x19certificateSigningRequest\"A\n" + + "\x1bcertificate_signing_request\x18\x04 \x01(\fR\x19certificateSigningRequest\x12$\n" + + "\x0eworker_pod_uid\x18\x05 \x01(\tR\fworkerPodUid\"A\n" + "\x10MintCertResponse\x12-\n" + "\x12actor_certificates\x18\x01 \x03(\fR\x11actorCertificates*\x80\x01\n" + "\x14SnapshotContentScope\x12&\n" + @@ -3338,54 +3328,53 @@ var file_ateapi_proto_depIdxs = []int32{ 4, // 47: ateapi.Worker.state:type_name -> ateapi.Worker.State 43, // 48: ateapi.Assignment.actor_template:type_name -> ateapi.KubeNamespacedObjectRef 13, // 49: ateapi.Assignment.actor:type_name -> ateapi.ObjectRef - 52, // 50: ateapi.MintJWTResponse.expiration_time:type_name -> google.protobuf.Timestamp - 20, // 51: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest - 21, // 52: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest - 22, // 53: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest - 24, // 54: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest - 26, // 55: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest - 28, // 56: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest - 30, // 57: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest - 31, // 58: ateapi.Control.GetActorSnapshot:input_type -> ateapi.GetActorSnapshotRequest - 32, // 59: ateapi.Control.ListActorSnapshots:input_type -> ateapi.ListActorSnapshotsRequest - 34, // 60: ateapi.Control.TagActorSnapshot:input_type -> ateapi.TagActorSnapshotRequest - 35, // 61: ateapi.Control.UpdateActorSnapshotTag:input_type -> ateapi.UpdateActorSnapshotTagRequest - 36, // 62: ateapi.Control.DeleteActorSnapshotTag:input_type -> ateapi.DeleteActorSnapshotTagRequest - 37, // 63: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest - 39, // 64: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest - 15, // 65: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest - 16, // 66: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest - 17, // 67: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest - 19, // 68: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest - 44, // 69: ateapi.Debug.DebugClear:input_type -> ateapi.DebugClearRequest - 46, // 70: ateapi.ActorIdentity.MintJWT:input_type -> ateapi.MintJWTRequest - 48, // 71: ateapi.ActorIdentity.MintCert:input_type -> ateapi.MintCertRequest - 9, // 72: ateapi.Control.GetActor:output_type -> ateapi.Actor - 9, // 73: ateapi.Control.CreateActor:output_type -> ateapi.Actor - 23, // 74: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse - 25, // 75: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse - 27, // 76: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse - 29, // 77: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse - 9, // 78: ateapi.Control.DeleteActor:output_type -> ateapi.Actor - 10, // 79: ateapi.Control.GetActorSnapshot:output_type -> ateapi.ActorSnapshot - 33, // 80: ateapi.Control.ListActorSnapshots:output_type -> ateapi.ListActorSnapshotsResponse - 11, // 81: ateapi.Control.TagActorSnapshot:output_type -> ateapi.ActorSnapshotTag - 11, // 82: ateapi.Control.UpdateActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag - 11, // 83: ateapi.Control.DeleteActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag - 38, // 84: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse - 40, // 85: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse - 12, // 86: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace - 12, // 87: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace - 18, // 88: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse - 12, // 89: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace - 45, // 90: ateapi.Debug.DebugClear:output_type -> ateapi.DebugClearResponse - 47, // 91: ateapi.ActorIdentity.MintJWT:output_type -> ateapi.MintJWTResponse - 49, // 92: ateapi.ActorIdentity.MintCert:output_type -> ateapi.MintCertResponse - 72, // [72:93] is the sub-list for method output_type - 51, // [51:72] is the sub-list for method input_type - 51, // [51:51] is the sub-list for extension type_name - 51, // [51:51] is the sub-list for extension extendee - 0, // [0:51] is the sub-list for field type_name + 20, // 50: ateapi.Control.GetActor:input_type -> ateapi.GetActorRequest + 21, // 51: ateapi.Control.CreateActor:input_type -> ateapi.CreateActorRequest + 22, // 52: ateapi.Control.UpdateActor:input_type -> ateapi.UpdateActorRequest + 24, // 53: ateapi.Control.SuspendActor:input_type -> ateapi.SuspendActorRequest + 26, // 54: ateapi.Control.PauseActor:input_type -> ateapi.PauseActorRequest + 28, // 55: ateapi.Control.ResumeActor:input_type -> ateapi.ResumeActorRequest + 30, // 56: ateapi.Control.DeleteActor:input_type -> ateapi.DeleteActorRequest + 31, // 57: ateapi.Control.GetActorSnapshot:input_type -> ateapi.GetActorSnapshotRequest + 32, // 58: ateapi.Control.ListActorSnapshots:input_type -> ateapi.ListActorSnapshotsRequest + 34, // 59: ateapi.Control.TagActorSnapshot:input_type -> ateapi.TagActorSnapshotRequest + 35, // 60: ateapi.Control.UpdateActorSnapshotTag:input_type -> ateapi.UpdateActorSnapshotTagRequest + 36, // 61: ateapi.Control.DeleteActorSnapshotTag:input_type -> ateapi.DeleteActorSnapshotTagRequest + 37, // 62: ateapi.Control.ListWorkers:input_type -> ateapi.ListWorkersRequest + 39, // 63: ateapi.Control.ListActors:input_type -> ateapi.ListActorsRequest + 15, // 64: ateapi.Control.CreateAtespace:input_type -> ateapi.CreateAtespaceRequest + 16, // 65: ateapi.Control.GetAtespace:input_type -> ateapi.GetAtespaceRequest + 17, // 66: ateapi.Control.ListAtespaces:input_type -> ateapi.ListAtespacesRequest + 19, // 67: ateapi.Control.DeleteAtespace:input_type -> ateapi.DeleteAtespaceRequest + 44, // 68: ateapi.Debug.DebugClear:input_type -> ateapi.DebugClearRequest + 46, // 69: ateapi.ActorIdentity.MintJWT:input_type -> ateapi.MintJWTRequest + 48, // 70: ateapi.ActorIdentity.MintCert:input_type -> ateapi.MintCertRequest + 9, // 71: ateapi.Control.GetActor:output_type -> ateapi.Actor + 9, // 72: ateapi.Control.CreateActor:output_type -> ateapi.Actor + 23, // 73: ateapi.Control.UpdateActor:output_type -> ateapi.UpdateActorResponse + 25, // 74: ateapi.Control.SuspendActor:output_type -> ateapi.SuspendActorResponse + 27, // 75: ateapi.Control.PauseActor:output_type -> ateapi.PauseActorResponse + 29, // 76: ateapi.Control.ResumeActor:output_type -> ateapi.ResumeActorResponse + 9, // 77: ateapi.Control.DeleteActor:output_type -> ateapi.Actor + 10, // 78: ateapi.Control.GetActorSnapshot:output_type -> ateapi.ActorSnapshot + 33, // 79: ateapi.Control.ListActorSnapshots:output_type -> ateapi.ListActorSnapshotsResponse + 11, // 80: ateapi.Control.TagActorSnapshot:output_type -> ateapi.ActorSnapshotTag + 11, // 81: ateapi.Control.UpdateActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag + 11, // 82: ateapi.Control.DeleteActorSnapshotTag:output_type -> ateapi.ActorSnapshotTag + 38, // 83: ateapi.Control.ListWorkers:output_type -> ateapi.ListWorkersResponse + 40, // 84: ateapi.Control.ListActors:output_type -> ateapi.ListActorsResponse + 12, // 85: ateapi.Control.CreateAtespace:output_type -> ateapi.Atespace + 12, // 86: ateapi.Control.GetAtespace:output_type -> ateapi.Atespace + 18, // 87: ateapi.Control.ListAtespaces:output_type -> ateapi.ListAtespacesResponse + 12, // 88: ateapi.Control.DeleteAtespace:output_type -> ateapi.Atespace + 45, // 89: ateapi.Debug.DebugClear:output_type -> ateapi.DebugClearResponse + 47, // 90: ateapi.ActorIdentity.MintJWT:output_type -> ateapi.MintJWTResponse + 49, // 91: ateapi.ActorIdentity.MintCert:output_type -> ateapi.MintCertResponse + 71, // [71:92] is the sub-list for method output_type + 50, // [50:71] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_ateapi_proto_init() } diff --git a/pkg/proto/ateapipb/ateapi.proto b/pkg/proto/ateapipb/ateapi.proto index bf38d54ea..a58331bb2 100644 --- a/pkg/proto/ateapipb/ateapi.proto +++ b/pkg/proto/ateapipb/ateapi.proto @@ -471,13 +471,17 @@ message DebugClearRequest {} message DebugClearResponse {} -// ActorIdentity lets atelet mint credentials for actors currently assigned to -// workers on its node. Calls require atelet's Pod certificate over mTLS. +// ActorIdentity allows substrate workloads to exchange their +// infrastructure-level credentials (k8s service account token, etc.) for a +// substrate actor-level credential. A given substrate actor might migrate +// between many different physical workers over the course of its lifecycle, +// whereas the actor credential's identity will be stable for the life of the +// actor. service ActorIdentity { // Request an Actor Identity JWT. // - // Atelet supplies the exact worker Pod UID and actor assignment it observed; - // the server independently revalidates both before signing. + // To call this RPC, you must be authenticated as the Kubernetes Pod that is + // currently running the requested actor. rpc MintJWT(MintJWTRequest) returns (MintJWTResponse); // Request an Actor Identity Certificate for an actor. @@ -496,12 +500,11 @@ service ActorIdentity { } message MintJWTRequest { - string audience = 1; + repeated string audience = 1; string atespace = 2; string actor_name = 3; string actor_uid = 4; - string worker_pod_uid = 5; } // TODO: check why k8s do ":" and not "/" as a seprator for the Subject format @@ -524,11 +527,7 @@ message MintJWTResponse { // * `ate.dev`: Ate/Substrate Extension - JSON object // * atespace: (string) The atespace the actor belongs to // * actorName: (string) The actor's name, unique within its atespace - // * actorUid: (string) The actor incarnation - // * actorResourceVersion: (number) The actor version at issuance - // * workerPodUid: (string) The worker Pod bound to this token string actor_jwt = 1; - google.protobuf.Timestamp expiration_time = 2; } message MintCertRequest { @@ -540,6 +539,10 @@ message MintCertRequest { // The signer will ignore the contents of the CSR except to extract the // subject public key. bytes certificate_signing_request = 4; + + // Exact worker Pod requesting the certificate. Ateapi verifies that this + // worker is still assigned to the actor before signing. + string worker_pod_uid = 5; } message MintCertResponse { diff --git a/pkg/proto/ateapipb/ateapi_grpc.pb.go b/pkg/proto/ateapipb/ateapi_grpc.pb.go index c1dae3bd6..80b49c1e0 100644 --- a/pkg/proto/ateapipb/ateapi_grpc.pb.go +++ b/pkg/proto/ateapipb/ateapi_grpc.pb.go @@ -943,13 +943,17 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// ActorIdentity lets atelet mint credentials for actors currently assigned to -// workers on its node. Calls require atelet's Pod certificate over mTLS. +// ActorIdentity allows substrate workloads to exchange their +// infrastructure-level credentials (k8s service account token, etc.) for a +// substrate actor-level credential. A given substrate actor might migrate +// between many different physical workers over the course of its lifecycle, +// whereas the actor credential's identity will be stable for the life of the +// actor. type ActorIdentityClient interface { // Request an Actor Identity JWT. // - // Atelet supplies the exact worker Pod UID and actor assignment it observed; - // the server independently revalidates both before signing. + // To call this RPC, you must be authenticated as the Kubernetes Pod that is + // currently running the requested actor. MintJWT(ctx context.Context, in *MintJWTRequest, opts ...grpc.CallOption) (*MintJWTResponse, error) // Request an Actor Identity Certificate for an actor. // @@ -998,13 +1002,17 @@ func (c *actorIdentityClient) MintCert(ctx context.Context, in *MintCertRequest, // All implementations must embed UnimplementedActorIdentityServer // for forward compatibility. // -// ActorIdentity lets atelet mint credentials for actors currently assigned to -// workers on its node. Calls require atelet's Pod certificate over mTLS. +// ActorIdentity allows substrate workloads to exchange their +// infrastructure-level credentials (k8s service account token, etc.) for a +// substrate actor-level credential. A given substrate actor might migrate +// between many different physical workers over the course of its lifecycle, +// whereas the actor credential's identity will be stable for the life of the +// actor. type ActorIdentityServer interface { // Request an Actor Identity JWT. // - // Atelet supplies the exact worker Pod UID and actor assignment it observed; - // the server independently revalidates both before signing. + // To call this RPC, you must be authenticated as the Kubernetes Pod that is + // currently running the requested actor. MintJWT(context.Context, *MintJWTRequest) (*MintJWTResponse, error) // Request an Actor Identity Certificate for an actor. //