diff --git a/.github/k8s/agentmesh-control-plane-template.yaml b/.github/k8s/agentmesh-control-plane-template.yaml index e3bb0b69..9c6cf541 100644 --- a/.github/k8s/agentmesh-control-plane-template.yaml +++ b/.github/k8s/agentmesh-control-plane-template.yaml @@ -161,6 +161,26 @@ spec: kind: Service name: agentmesh-control-plane-${ENV_NAME} --- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: agentmesh-router-ingress-healthcheck-${ENV_NAME} + namespace: ${NAMESPACE} +spec: + default: + checkIntervalSec: 15 + timeoutSec: 5 + healthyThreshold: 1 + unhealthyThreshold: 2 + config: + type: HTTP + httpHealthCheck: + requestPath: /ingress/healthz + targetRef: + group: "" + kind: Service + name: agentmesh-router-ingress-${ENV_NAME} +--- apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: @@ -274,3 +294,13 @@ spec: backendRefs: - name: agentmesh-console-${ENV_NAME} port: 8081 + - matches: + - path: + type: Exact + value: /ingress + - path: + type: PathPrefix + value: /ingress/ + backendRefs: + - name: agentmesh-router-ingress-${ENV_NAME} + port: 8080 diff --git a/.github/k8s/agentmesh-router-template.yaml b/.github/k8s/agentmesh-router-template.yaml index 42ba7124..793e8926 100644 --- a/.github/k8s/agentmesh-router-template.yaml +++ b/.github/k8s/agentmesh-router-template.yaml @@ -23,6 +23,20 @@ spec: selector: app: agentmesh-router-${ENV_NAME} --- +apiVersion: v1 +kind: Service +metadata: + name: agentmesh-router-ingress-${ENV_NAME} + namespace: ${NAMESPACE} +spec: + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http-ingress + selector: + app: agentmesh-router-${ENV_NAME} +--- apiVersion: apps/v1 kind: StatefulSet metadata: @@ -66,6 +80,10 @@ spec: - containerPort: 9090 protocol: TCP name: metrics + # External HTTP ingress reached via the Gateway HTTPRoute (/ingress/*). + - containerPort: 8080 + protocol: TCP + name: http-ingress args: - "--control-plane=http://agentmesh-control-plane-${ENV_NAME}.${NAMESPACE}.svc.cluster.local:8080" - "--insecure-control-plane" @@ -75,6 +93,10 @@ spec: - "--jwt-path=/var/run/secrets/tokens/agentmesh-token" - "--keys-path=/data/router.key" - "--metrics-addr=0.0.0.0:9090" + - "--ingress-addr=0.0.0.0:8080" + - "--ingress-url=https://${ENV_NAME}.sam-mesh.dev/ingress" + - "--public-control-plane=https://${ENV_NAME}.sam-mesh.dev" + - "--allow-insecure-ingress-http" # Profiles on loopback only: `kubectl port-forward 6060` then # `go tool pprof http://127.0.0.1:6060/debug/pprof/heap`. - "--pprof-addr=127.0.0.1:6060" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 82223f04..e8b31776 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -440,10 +440,10 @@ jobs: -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -d '{ "roles": [ - {"name": "agentmesh-canary", "allowed_services": ["*"], "allowed_publish": ["*"], "allowed_targets": ["*"]}, + {"name": "agentmesh-canary", "allowed_services": ["*"], "allowed_publish": ["*"], "allowed_targets": ["*"], "allowed_ingress": [{"services": ["*"]}]}, {"name": "mesh:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, {"name": "mesh:role:node"}, - {"name": "public-mesh", "allowed_services": ["*"], "allowed_publish": ["*"], "allowed_targets": ["*"]} + {"name": "public-mesh", "allowed_services": ["*"], "allowed_publish": ["*"], "allowed_targets": ["*"], "allowed_ingress": [{"services": ["*"]}]} ], "bindings": [ {"role": "agentmesh-canary", "members": ["user:system:serviceaccount:agentmesh-canary-'"${ENV_NAME}"':agentmesh-node-sa"]}, diff --git a/api/agentmesh.pb.go b/api/agentmesh.pb.go index 90897485..ecd9221a 100644 --- a/api/agentmesh.pb.go +++ b/api/agentmesh.pb.go @@ -148,6 +148,59 @@ func (ServiceType) EnumDescriptor() ([]byte, []int) { return file_api_agentmesh_proto_rawDescGZIP(), []int{1} } +type IngressAuthMode int32 + +const ( + // Defaults to INGRESS_AUTH_MODE_EDGE. + IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED IngressAuthMode = 0 + // Router and provider node verify a delegated Mesh Biscuit + mesh policy. + IngressAuthMode_INGRESS_AUTH_MODE_EDGE IngressAuthMode = 1 + // Backend authenticates its own requests; requires an explicit PASSTHROUGH + // grant on the serving node's PolicyRole.allowed_ingress. + IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH IngressAuthMode = 2 +) + +// Enum value maps for IngressAuthMode. +var ( + IngressAuthMode_name = map[int32]string{ + 0: "INGRESS_AUTH_MODE_UNSPECIFIED", + 1: "INGRESS_AUTH_MODE_EDGE", + 2: "INGRESS_AUTH_MODE_PASSTHROUGH", + } + IngressAuthMode_value = map[string]int32{ + "INGRESS_AUTH_MODE_UNSPECIFIED": 0, + "INGRESS_AUTH_MODE_EDGE": 1, + "INGRESS_AUTH_MODE_PASSTHROUGH": 2, + } +) + +func (x IngressAuthMode) Enum() *IngressAuthMode { + p := new(IngressAuthMode) + *p = x + return p +} + +func (x IngressAuthMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (IngressAuthMode) Descriptor() protoreflect.EnumDescriptor { + return file_api_agentmesh_proto_enumTypes[2].Descriptor() +} + +func (IngressAuthMode) Type() protoreflect.EnumType { + return &file_api_agentmesh_proto_enumTypes[2] +} + +func (x IngressAuthMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use IngressAuthMode.Descriptor instead. +func (IngressAuthMode) EnumDescriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{2} +} + type EgressMode int32 const ( @@ -178,11 +231,11 @@ func (x EgressMode) String() string { } func (EgressMode) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[2].Descriptor() + return file_api_agentmesh_proto_enumTypes[3].Descriptor() } func (EgressMode) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[2] + return &file_api_agentmesh_proto_enumTypes[3] } func (x EgressMode) Number() protoreflect.EnumNumber { @@ -191,7 +244,7 @@ func (x EgressMode) Number() protoreflect.EnumNumber { // Deprecated: Use EgressMode.Descriptor instead. func (EgressMode) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{2} + return file_api_agentmesh_proto_rawDescGZIP(), []int{3} } type ResponseInspection int32 @@ -224,11 +277,11 @@ func (x ResponseInspection) String() string { } func (ResponseInspection) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[3].Descriptor() + return file_api_agentmesh_proto_enumTypes[4].Descriptor() } func (ResponseInspection) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[3] + return &file_api_agentmesh_proto_enumTypes[4] } func (x ResponseInspection) Number() protoreflect.EnumNumber { @@ -237,7 +290,7 @@ func (x ResponseInspection) Number() protoreflect.EnumNumber { // Deprecated: Use ResponseInspection.Descriptor instead. func (ResponseInspection) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{3} + return file_api_agentmesh_proto_rawDescGZIP(), []int{4} } type RouterGoAway_Reason int32 @@ -273,11 +326,11 @@ func (x RouterGoAway_Reason) String() string { } func (RouterGoAway_Reason) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[4].Descriptor() + return file_api_agentmesh_proto_enumTypes[5].Descriptor() } func (RouterGoAway_Reason) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[4] + return &file_api_agentmesh_proto_enumTypes[5] } func (x RouterGoAway_Reason) Number() protoreflect.EnumNumber { @@ -322,11 +375,11 @@ func (x MeshEvent_Type) String() string { } func (MeshEvent_Type) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[5].Descriptor() + return file_api_agentmesh_proto_enumTypes[6].Descriptor() } func (MeshEvent_Type) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[5] + return &file_api_agentmesh_proto_enumTypes[6] } func (x MeshEvent_Type) Number() protoreflect.EnumNumber { @@ -371,11 +424,11 @@ func (x ExtProcProcessingMode_HeaderMode) String() string { } func (ExtProcProcessingMode_HeaderMode) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[6].Descriptor() + return file_api_agentmesh_proto_enumTypes[7].Descriptor() } func (ExtProcProcessingMode_HeaderMode) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[6] + return &file_api_agentmesh_proto_enumTypes[7] } func (x ExtProcProcessingMode_HeaderMode) Number() protoreflect.EnumNumber { @@ -384,7 +437,7 @@ func (x ExtProcProcessingMode_HeaderMode) Number() protoreflect.EnumNumber { // Deprecated: Use ExtProcProcessingMode_HeaderMode.Descriptor instead. func (ExtProcProcessingMode_HeaderMode) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{31, 0} + return file_api_agentmesh_proto_rawDescGZIP(), []int{33, 0} } type ExtProcProcessingMode_BodyMode int32 @@ -426,11 +479,11 @@ func (x ExtProcProcessingMode_BodyMode) String() string { } func (ExtProcProcessingMode_BodyMode) Descriptor() protoreflect.EnumDescriptor { - return file_api_agentmesh_proto_enumTypes[7].Descriptor() + return file_api_agentmesh_proto_enumTypes[8].Descriptor() } func (ExtProcProcessingMode_BodyMode) Type() protoreflect.EnumType { - return &file_api_agentmesh_proto_enumTypes[7] + return &file_api_agentmesh_proto_enumTypes[8] } func (x ExtProcProcessingMode_BodyMode) Number() protoreflect.EnumNumber { @@ -439,7 +492,7 @@ func (x ExtProcProcessingMode_BodyMode) Number() protoreflect.EnumNumber { // Deprecated: Use ExtProcProcessingMode_BodyMode.Descriptor instead. func (ExtProcProcessingMode_BodyMode) EnumDescriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{31, 1} + return file_api_agentmesh_proto_rawDescGZIP(), []int{33, 1} } type AuthFrame struct { @@ -1068,18 +1121,82 @@ func (x *BootstrapEnrollResponse) GetExpireTime() *timestamppb.Timestamp { return nil } +// ServiceIngress configures external router HTTP ingress for an inbound service +// (a2a, mcp, or inference). Absent means the service is reachable only inside +// the mesh. +type ServiceIngress struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Defaults to INGRESS_AUTH_MODE_EDGE when UNSPECIFIED. + AuthMode IngressAuthMode `protobuf:"varint,1,opt,name=auth_mode,json=authMode,proto3,enum=agentmesh.v1.IngressAuthMode" json:"auth_mode,omitempty"` + // Optional caller narrowing in PolicyBinding.members syntax + // (e.g. "email:alice@example.com", "email:*@example.com", "group:eng"). + // Intersected (AND) with mesh Datalog policy: when empty, any caller + // authorized by mesh policy for this service may invoke it; when non-empty, + // the verified caller must also match at least one entry here. Only allowed + // in INGRESS_AUTH_MODE_EDGE. + Allow []string `protobuf:"bytes,2,rep,name=allow,proto3" json:"allow,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceIngress) Reset() { + *x = ServiceIngress{} + mi := &file_api_agentmesh_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceIngress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceIngress) ProtoMessage() {} + +func (x *ServiceIngress) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[8] + 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 ServiceIngress.ProtoReflect.Descriptor instead. +func (*ServiceIngress) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{8} +} + +func (x *ServiceIngress) GetAuthMode() IngressAuthMode { + if x != nil { + return x.AuthMode + } + return IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED +} + +func (x *ServiceIngress) GetAllow() []string { + if x != nil { + return x.Allow + } + return nil +} + type ServiceInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type ServiceType `protobuf:"varint,1,opt,name=type,proto3,enum=agentmesh.v1.ServiceType" json:"type,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Type ServiceType `protobuf:"varint,1,opt,name=type,proto3,enum=agentmesh.v1.ServiceType" json:"type,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // Optional external ingress exposure for this service. + Ingress *ServiceIngress `protobuf:"bytes,4,opt,name=ingress,proto3" json:"ingress,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServiceInfo) Reset() { *x = ServiceInfo{} - mi := &file_api_agentmesh_proto_msgTypes[8] + mi := &file_api_agentmesh_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1091,7 +1208,7 @@ func (x *ServiceInfo) String() string { func (*ServiceInfo) ProtoMessage() {} func (x *ServiceInfo) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[8] + mi := &file_api_agentmesh_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1104,7 +1221,7 @@ func (x *ServiceInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceInfo.ProtoReflect.Descriptor instead. func (*ServiceInfo) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{8} + return file_api_agentmesh_proto_rawDescGZIP(), []int{9} } func (x *ServiceInfo) GetType() ServiceType { @@ -1128,6 +1245,13 @@ func (x *ServiceInfo) GetDescription() string { return "" } +func (x *ServiceInfo) GetIngress() *ServiceIngress { + if x != nil { + return x.Ingress + } + return nil +} + type CommandBackend struct { state protoimpl.MessageState `protogen:"open.v1"` Command []string `protobuf:"bytes,1,rep,name=command,proto3" json:"command,omitempty"` @@ -1138,7 +1262,7 @@ type CommandBackend struct { func (x *CommandBackend) Reset() { *x = CommandBackend{} - mi := &file_api_agentmesh_proto_msgTypes[9] + mi := &file_api_agentmesh_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1150,7 +1274,7 @@ func (x *CommandBackend) String() string { func (*CommandBackend) ProtoMessage() {} func (x *CommandBackend) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[9] + mi := &file_api_agentmesh_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1163,7 +1287,7 @@ func (x *CommandBackend) ProtoReflect() protoreflect.Message { // Deprecated: Use CommandBackend.ProtoReflect.Descriptor instead. func (*CommandBackend) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{9} + return file_api_agentmesh_proto_rawDescGZIP(), []int{10} } func (x *CommandBackend) GetCommand() []string { @@ -1194,7 +1318,7 @@ type RegisterServiceRequest struct { func (x *RegisterServiceRequest) Reset() { *x = RegisterServiceRequest{} - mi := &file_api_agentmesh_proto_msgTypes[10] + mi := &file_api_agentmesh_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1206,7 +1330,7 @@ func (x *RegisterServiceRequest) String() string { func (*RegisterServiceRequest) ProtoMessage() {} func (x *RegisterServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[10] + mi := &file_api_agentmesh_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1219,7 +1343,7 @@ func (x *RegisterServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterServiceRequest.ProtoReflect.Descriptor instead. func (*RegisterServiceRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{10} + return file_api_agentmesh_proto_rawDescGZIP(), []int{11} } func (x *RegisterServiceRequest) GetService() *ServiceInfo { @@ -1282,7 +1406,7 @@ type DiscoveredProvider struct { func (x *DiscoveredProvider) Reset() { *x = DiscoveredProvider{} - mi := &file_api_agentmesh_proto_msgTypes[11] + mi := &file_api_agentmesh_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1418,7 @@ func (x *DiscoveredProvider) String() string { func (*DiscoveredProvider) ProtoMessage() {} func (x *DiscoveredProvider) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[11] + mi := &file_api_agentmesh_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1431,7 @@ func (x *DiscoveredProvider) ProtoReflect() protoreflect.Message { // Deprecated: Use DiscoveredProvider.ProtoReflect.Descriptor instead. func (*DiscoveredProvider) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{11} + return file_api_agentmesh_proto_rawDescGZIP(), []int{12} } func (x *DiscoveredProvider) GetPeerId() string { @@ -1370,7 +1494,7 @@ type ControlPlaneInfoResponse struct { func (x *ControlPlaneInfoResponse) Reset() { *x = ControlPlaneInfoResponse{} - mi := &file_api_agentmesh_proto_msgTypes[12] + mi := &file_api_agentmesh_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1382,7 +1506,7 @@ func (x *ControlPlaneInfoResponse) String() string { func (*ControlPlaneInfoResponse) ProtoMessage() {} func (x *ControlPlaneInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[12] + mi := &file_api_agentmesh_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1395,7 +1519,7 @@ func (x *ControlPlaneInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ControlPlaneInfoResponse.ProtoReflect.Descriptor instead. func (*ControlPlaneInfoResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{12} + return file_api_agentmesh_proto_rawDescGZIP(), []int{13} } func (x *ControlPlaneInfoResponse) GetOidcIssuer() string { @@ -1469,7 +1593,7 @@ type DirectoryParameters struct { func (x *DirectoryParameters) Reset() { *x = DirectoryParameters{} - mi := &file_api_agentmesh_proto_msgTypes[13] + mi := &file_api_agentmesh_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1481,7 +1605,7 @@ func (x *DirectoryParameters) String() string { func (*DirectoryParameters) ProtoMessage() {} func (x *DirectoryParameters) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[13] + mi := &file_api_agentmesh_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1494,7 +1618,7 @@ func (x *DirectoryParameters) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryParameters.ProtoReflect.Descriptor instead. func (*DirectoryParameters) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{13} + return file_api_agentmesh_proto_rawDescGZIP(), []int{14} } func (x *DirectoryParameters) GetReplication() int32 { @@ -1540,7 +1664,7 @@ type DirectoryRequest struct { func (x *DirectoryRequest) Reset() { *x = DirectoryRequest{} - mi := &file_api_agentmesh_proto_msgTypes[14] + mi := &file_api_agentmesh_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1552,7 +1676,7 @@ func (x *DirectoryRequest) String() string { func (*DirectoryRequest) ProtoMessage() {} func (x *DirectoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[14] + mi := &file_api_agentmesh_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1565,7 +1689,7 @@ func (x *DirectoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryRequest.ProtoReflect.Descriptor instead. func (*DirectoryRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{14} + return file_api_agentmesh_proto_rawDescGZIP(), []int{15} } func (x *DirectoryRequest) GetRequest() isDirectoryRequest_Request { @@ -1650,18 +1774,22 @@ func (*DirectoryRequest_Withdraw) isDirectoryRequest_Request() {} // by the router that holds the member, and refused from anyone else: a // member can only announce itself. type DirectoryProvide struct { - state protoimpl.MessageState `protogen:"open.v1"` - Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` - Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` - Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` - Service string `protobuf:"bytes,4,opt,name=service,proto3" json:"service,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` + Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` + Service string `protobuf:"bytes,4,opt,name=service,proto3" json:"service,omitempty"` + // Optional external ingress metadata for a named service record. When set, + // the router verifies that the provider's credential grants exposing this + // service with the requested auth_mode before storing or replicating it. + Ingress *ServiceIngress `protobuf:"bytes,5,opt,name=ingress,proto3" json:"ingress,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DirectoryProvide) Reset() { *x = DirectoryProvide{} - mi := &file_api_agentmesh_proto_msgTypes[15] + mi := &file_api_agentmesh_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1673,7 +1801,7 @@ func (x *DirectoryProvide) String() string { func (*DirectoryProvide) ProtoMessage() {} func (x *DirectoryProvide) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[15] + mi := &file_api_agentmesh_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1686,7 +1814,7 @@ func (x *DirectoryProvide) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryProvide.ProtoReflect.Descriptor instead. func (*DirectoryProvide) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{15} + return file_api_agentmesh_proto_rawDescGZIP(), []int{16} } func (x *DirectoryProvide) GetKey() []byte { @@ -1717,6 +1845,13 @@ func (x *DirectoryProvide) GetService() string { return "" } +func (x *DirectoryProvide) GetIngress() *ServiceIngress { + if x != nil { + return x.Ingress + } + return nil +} + // DirectoryWithdraw removes the sender's record under key, from the router // asked and from the routers closest to the key: what a member sends when // it stops a service or leaves the mesh, so that callers do not find it @@ -1731,7 +1866,7 @@ type DirectoryWithdraw struct { func (x *DirectoryWithdraw) Reset() { *x = DirectoryWithdraw{} - mi := &file_api_agentmesh_proto_msgTypes[16] + mi := &file_api_agentmesh_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1743,7 +1878,7 @@ func (x *DirectoryWithdraw) String() string { func (*DirectoryWithdraw) ProtoMessage() {} func (x *DirectoryWithdraw) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[16] + mi := &file_api_agentmesh_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1756,7 +1891,7 @@ func (x *DirectoryWithdraw) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryWithdraw.ProtoReflect.Descriptor instead. func (*DirectoryWithdraw) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{16} + return file_api_agentmesh_proto_rawDescGZIP(), []int{17} } func (x *DirectoryWithdraw) GetKey() []byte { @@ -1784,7 +1919,7 @@ type DirectoryFindProviders struct { func (x *DirectoryFindProviders) Reset() { *x = DirectoryFindProviders{} - mi := &file_api_agentmesh_proto_msgTypes[17] + mi := &file_api_agentmesh_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1796,7 +1931,7 @@ func (x *DirectoryFindProviders) String() string { func (*DirectoryFindProviders) ProtoMessage() {} func (x *DirectoryFindProviders) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[17] + mi := &file_api_agentmesh_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1809,7 +1944,7 @@ func (x *DirectoryFindProviders) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryFindProviders.ProtoReflect.Descriptor instead. func (*DirectoryFindProviders) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{17} + return file_api_agentmesh_proto_rawDescGZIP(), []int{18} } func (x *DirectoryFindProviders) GetKey() []byte { @@ -1836,7 +1971,7 @@ type DirectoryFindPeer struct { func (x *DirectoryFindPeer) Reset() { *x = DirectoryFindPeer{} - mi := &file_api_agentmesh_proto_msgTypes[18] + mi := &file_api_agentmesh_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1848,7 +1983,7 @@ func (x *DirectoryFindPeer) String() string { func (*DirectoryFindPeer) ProtoMessage() {} func (x *DirectoryFindPeer) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[18] + mi := &file_api_agentmesh_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1861,7 +1996,7 @@ func (x *DirectoryFindPeer) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryFindPeer.ProtoReflect.Descriptor instead. func (*DirectoryFindPeer) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{18} + return file_api_agentmesh_proto_rawDescGZIP(), []int{19} } func (x *DirectoryFindPeer) GetPeerId() string { @@ -1885,7 +2020,7 @@ type DirectoryResponse struct { func (x *DirectoryResponse) Reset() { *x = DirectoryResponse{} - mi := &file_api_agentmesh_proto_msgTypes[19] + mi := &file_api_agentmesh_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1897,7 +2032,7 @@ func (x *DirectoryResponse) String() string { func (*DirectoryResponse) ProtoMessage() {} func (x *DirectoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[19] + mi := &file_api_agentmesh_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1910,7 +2045,7 @@ func (x *DirectoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectoryResponse.ProtoReflect.Descriptor instead. func (*DirectoryResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{19} + return file_api_agentmesh_proto_rawDescGZIP(), []int{20} } func (x *DirectoryResponse) GetProviders() []*PeerRecord { @@ -1937,16 +2072,18 @@ func (x *DirectoryResponse) GetError() string { // PeerRecord is a peer and the addresses it is reached at, relayed ones // included, each ending in its peer ID. type PeerRecord struct { - state protoimpl.MessageState `protogen:"open.v1"` - PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` - Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + Addrs []string `protobuf:"bytes,2,rep,name=addrs,proto3" json:"addrs,omitempty"` + // Present on service provider records that opted into external ingress. + Ingress *ServiceIngress `protobuf:"bytes,3,opt,name=ingress,proto3" json:"ingress,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PeerRecord) Reset() { *x = PeerRecord{} - mi := &file_api_agentmesh_proto_msgTypes[20] + mi := &file_api_agentmesh_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1958,7 +2095,7 @@ func (x *PeerRecord) String() string { func (*PeerRecord) ProtoMessage() {} func (x *PeerRecord) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[20] + mi := &file_api_agentmesh_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1971,7 +2108,7 @@ func (x *PeerRecord) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerRecord.ProtoReflect.Descriptor instead. func (*PeerRecord) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{20} + return file_api_agentmesh_proto_rawDescGZIP(), []int{21} } func (x *PeerRecord) GetPeerId() string { @@ -1988,6 +2125,13 @@ func (x *PeerRecord) GetAddrs() []string { return nil } +func (x *PeerRecord) GetIngress() *ServiceIngress { + if x != nil { + return x.Ingress + } + return nil +} + // RouterInfo is one router as the control plane lists it on /info. type RouterInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2007,13 +2151,17 @@ type RouterInfo struct { // routers with the most room; connection_limit of zero means unknown. Connections int32 `protobuf:"varint,4,opt,name=connections,proto3" json:"connections,omitempty"` ConnectionLimit int32 `protobuf:"varint,5,opt,name=connection_limit,json=connectionLimit,proto3" json:"connection_limit,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Base HTTPS/HTTP URL where this router serves external ingress, without a + // trailing slash (e.g. "https://bananas.sam-mesh.dev/ingress"). Empty when + // external ingress is disabled on this router. + IngressUrl string `protobuf:"bytes,6,opt,name=ingress_url,json=ingressUrl,proto3" json:"ingress_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RouterInfo) Reset() { *x = RouterInfo{} - mi := &file_api_agentmesh_proto_msgTypes[21] + mi := &file_api_agentmesh_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2025,7 +2173,7 @@ func (x *RouterInfo) String() string { func (*RouterInfo) ProtoMessage() {} func (x *RouterInfo) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[21] + mi := &file_api_agentmesh_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2038,7 +2186,7 @@ func (x *RouterInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterInfo.ProtoReflect.Descriptor instead. func (*RouterInfo) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{21} + return file_api_agentmesh_proto_rawDescGZIP(), []int{22} } func (x *RouterInfo) GetPeerId() string { @@ -2076,6 +2224,13 @@ func (x *RouterInfo) GetConnectionLimit() int32 { return 0 } +func (x *RouterInfo) GetIngressUrl() string { + if x != nil { + return x.IngressUrl + } + return "" +} + type RouterLeaseRequest struct { state protoimpl.MessageState `protogen:"open.v1"` PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` @@ -2100,13 +2255,15 @@ type RouterLeaseRequest struct { // inbound connections and the number it accepts. Connections int32 `protobuf:"varint,9,opt,name=connections,proto3" json:"connections,omitempty"` ConnectionLimit int32 `protobuf:"varint,10,opt,name=connection_limit,json=connectionLimit,proto3" json:"connection_limit,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional base URL where this router serves external HTTP ingress. + IngressUrl string `protobuf:"bytes,11,opt,name=ingress_url,json=ingressUrl,proto3" json:"ingress_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RouterLeaseRequest) Reset() { *x = RouterLeaseRequest{} - mi := &file_api_agentmesh_proto_msgTypes[22] + mi := &file_api_agentmesh_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2118,7 +2275,7 @@ func (x *RouterLeaseRequest) String() string { func (*RouterLeaseRequest) ProtoMessage() {} func (x *RouterLeaseRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[22] + mi := &file_api_agentmesh_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2131,7 +2288,7 @@ func (x *RouterLeaseRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLeaseRequest.ProtoReflect.Descriptor instead. func (*RouterLeaseRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{22} + return file_api_agentmesh_proto_rawDescGZIP(), []int{23} } func (x *RouterLeaseRequest) GetPeerId() string { @@ -2204,6 +2361,13 @@ func (x *RouterLeaseRequest) GetConnectionLimit() int32 { return 0 } +func (x *RouterLeaseRequest) GetIngressUrl() string { + if x != nil { + return x.IngressUrl + } + return "" +} + type RouterLeaseResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` @@ -2215,7 +2379,7 @@ type RouterLeaseResponse struct { func (x *RouterLeaseResponse) Reset() { *x = RouterLeaseResponse{} - mi := &file_api_agentmesh_proto_msgTypes[23] + mi := &file_api_agentmesh_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2227,7 +2391,7 @@ func (x *RouterLeaseResponse) String() string { func (*RouterLeaseResponse) ProtoMessage() {} func (x *RouterLeaseResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[23] + mi := &file_api_agentmesh_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2240,7 +2404,7 @@ func (x *RouterLeaseResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLeaseResponse.ProtoReflect.Descriptor instead. func (*RouterLeaseResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{23} + return file_api_agentmesh_proto_rawDescGZIP(), []int{24} } func (x *RouterLeaseResponse) GetSuccess() bool { @@ -2284,13 +2448,17 @@ type PolicyRole struct { // An egress destination needs no entry: served_by grants it to the // nodes it selects. AllowedPublish []string `protobuf:"bytes,7,rep,name=allowed_publish,json=allowedPublish,proto3" json:"allowed_publish,omitempty"` + // Inbound services a member with this role may expose on router HTTP + // ingress, together with the permitted IngressAuthModes. Empty denies all + // external ingress exposure for this role. + AllowedIngress []*IngressGrant `protobuf:"bytes,8,rep,name=allowed_ingress,json=allowedIngress,proto3" json:"allowed_ingress,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PolicyRole) Reset() { *x = PolicyRole{} - mi := &file_api_agentmesh_proto_msgTypes[24] + mi := &file_api_agentmesh_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2302,7 +2470,7 @@ func (x *PolicyRole) String() string { func (*PolicyRole) ProtoMessage() {} func (x *PolicyRole) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[24] + mi := &file_api_agentmesh_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2315,7 +2483,7 @@ func (x *PolicyRole) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyRole.ProtoReflect.Descriptor instead. func (*PolicyRole) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{24} + return file_api_agentmesh_proto_rawDescGZIP(), []int{25} } func (x *PolicyRole) GetName() string { @@ -2367,6 +2535,70 @@ func (x *PolicyRole) GetAllowedPublish() []string { return nil } +func (x *PolicyRole) GetAllowedIngress() []*IngressGrant { + if x != nil { + return x.AllowedIngress + } + return nil +} + +// IngressGrant permits a role to expose matching inbound services on router +// HTTP ingress under the listed authentication modes. +type IngressGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Inbound service patterns ("a2a://*", "mcp://calc", "*"). Egress + // destinations ("egress://...") cannot be exposed as ingress. + Services []string `protobuf:"bytes,1,rep,name=services,proto3" json:"services,omitempty"` + // Permitted auth modes. Empty grants ONLY INGRESS_AUTH_MODE_EDGE. + AllowedAuthModes []IngressAuthMode `protobuf:"varint,2,rep,packed,name=allowed_auth_modes,json=allowedAuthModes,proto3,enum=agentmesh.v1.IngressAuthMode" json:"allowed_auth_modes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngressGrant) Reset() { + *x = IngressGrant{} + mi := &file_api_agentmesh_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngressGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngressGrant) ProtoMessage() {} + +func (x *IngressGrant) ProtoReflect() protoreflect.Message { + mi := &file_api_agentmesh_proto_msgTypes[26] + 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 IngressGrant.ProtoReflect.Descriptor instead. +func (*IngressGrant) Descriptor() ([]byte, []int) { + return file_api_agentmesh_proto_rawDescGZIP(), []int{26} +} + +func (x *IngressGrant) GetServices() []string { + if x != nil { + return x.Services + } + return nil +} + +func (x *IngressGrant) GetAllowedAuthModes() []IngressAuthMode { + if x != nil { + return x.AllowedAuthModes + } + return nil +} + // HTTPGrant narrows one allowed_services entry to HTTP methods and paths. // The control plane compiles it into granted_method and granted_path_* facts // in the holder's credential and withholds the plain service grant for that @@ -2389,7 +2621,7 @@ type HTTPGrant struct { func (x *HTTPGrant) Reset() { *x = HTTPGrant{} - mi := &file_api_agentmesh_proto_msgTypes[25] + mi := &file_api_agentmesh_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2401,7 +2633,7 @@ func (x *HTTPGrant) String() string { func (*HTTPGrant) ProtoMessage() {} func (x *HTTPGrant) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[25] + mi := &file_api_agentmesh_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2414,7 +2646,7 @@ func (x *HTTPGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPGrant.ProtoReflect.Descriptor instead. func (*HTTPGrant) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{25} + return file_api_agentmesh_proto_rawDescGZIP(), []int{27} } func (x *HTTPGrant) GetService() string { @@ -2482,7 +2714,7 @@ type EgressDestination struct { func (x *EgressDestination) Reset() { *x = EgressDestination{} - mi := &file_api_agentmesh_proto_msgTypes[26] + mi := &file_api_agentmesh_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2494,7 +2726,7 @@ func (x *EgressDestination) String() string { func (*EgressDestination) ProtoMessage() {} func (x *EgressDestination) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[26] + mi := &file_api_agentmesh_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2507,7 +2739,7 @@ func (x *EgressDestination) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressDestination.ProtoReflect.Descriptor instead. func (*EgressDestination) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{26} + return file_api_agentmesh_proto_rawDescGZIP(), []int{28} } func (x *EgressDestination) GetName() string { @@ -2592,7 +2824,7 @@ type Inspection struct { func (x *Inspection) Reset() { *x = Inspection{} - mi := &file_api_agentmesh_proto_msgTypes[27] + mi := &file_api_agentmesh_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2604,7 +2836,7 @@ func (x *Inspection) String() string { func (*Inspection) ProtoMessage() {} func (x *Inspection) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[27] + mi := &file_api_agentmesh_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2617,7 +2849,7 @@ func (x *Inspection) ProtoReflect() protoreflect.Message { // Deprecated: Use Inspection.ProtoReflect.Descriptor instead. func (*Inspection) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{27} + return file_api_agentmesh_proto_rawDescGZIP(), []int{29} } func (x *Inspection) GetInspectors() []*Inspector { @@ -2640,7 +2872,7 @@ type Inspector struct { func (x *Inspector) Reset() { *x = Inspector{} - mi := &file_api_agentmesh_proto_msgTypes[28] + mi := &file_api_agentmesh_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2652,7 +2884,7 @@ func (x *Inspector) String() string { func (*Inspector) ProtoMessage() {} func (x *Inspector) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[28] + mi := &file_api_agentmesh_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2665,7 +2897,7 @@ func (x *Inspector) ProtoReflect() protoreflect.Message { // Deprecated: Use Inspector.ProtoReflect.Descriptor instead. func (*Inspector) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{28} + return file_api_agentmesh_proto_rawDescGZIP(), []int{30} } func (x *Inspector) GetKind() isInspector_Kind { @@ -2730,7 +2962,7 @@ type ModelArmor struct { func (x *ModelArmor) Reset() { *x = ModelArmor{} - mi := &file_api_agentmesh_proto_msgTypes[29] + mi := &file_api_agentmesh_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2742,7 +2974,7 @@ func (x *ModelArmor) String() string { func (*ModelArmor) ProtoMessage() {} func (x *ModelArmor) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[29] + mi := &file_api_agentmesh_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2755,7 +2987,7 @@ func (x *ModelArmor) ProtoReflect() protoreflect.Message { // Deprecated: Use ModelArmor.ProtoReflect.Descriptor instead. func (*ModelArmor) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{29} + return file_api_agentmesh_proto_rawDescGZIP(), []int{31} } func (x *ModelArmor) GetTemplate() string { @@ -2813,7 +3045,7 @@ type ExtProc struct { func (x *ExtProc) Reset() { *x = ExtProc{} - mi := &file_api_agentmesh_proto_msgTypes[30] + mi := &file_api_agentmesh_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2825,7 +3057,7 @@ func (x *ExtProc) String() string { func (*ExtProc) ProtoMessage() {} func (x *ExtProc) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[30] + mi := &file_api_agentmesh_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2838,7 +3070,7 @@ func (x *ExtProc) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtProc.ProtoReflect.Descriptor instead. func (*ExtProc) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{30} + return file_api_agentmesh_proto_rawDescGZIP(), []int{32} } func (x *ExtProc) GetTarget() string { @@ -2911,7 +3143,7 @@ type ExtProcProcessingMode struct { func (x *ExtProcProcessingMode) Reset() { *x = ExtProcProcessingMode{} - mi := &file_api_agentmesh_proto_msgTypes[31] + mi := &file_api_agentmesh_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2923,7 +3155,7 @@ func (x *ExtProcProcessingMode) String() string { func (*ExtProcProcessingMode) ProtoMessage() {} func (x *ExtProcProcessingMode) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[31] + mi := &file_api_agentmesh_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2936,7 +3168,7 @@ func (x *ExtProcProcessingMode) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtProcProcessingMode.ProtoReflect.Descriptor instead. func (*ExtProcProcessingMode) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{31} + return file_api_agentmesh_proto_rawDescGZIP(), []int{33} } func (x *ExtProcProcessingMode) GetRequestHeaderMode() ExtProcProcessingMode_HeaderMode { @@ -2996,7 +3228,7 @@ type CredentialBroker struct { func (x *CredentialBroker) Reset() { *x = CredentialBroker{} - mi := &file_api_agentmesh_proto_msgTypes[32] + mi := &file_api_agentmesh_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3008,7 +3240,7 @@ func (x *CredentialBroker) String() string { func (*CredentialBroker) ProtoMessage() {} func (x *CredentialBroker) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[32] + mi := &file_api_agentmesh_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3021,7 +3253,7 @@ func (x *CredentialBroker) ProtoReflect() protoreflect.Message { // Deprecated: Use CredentialBroker.ProtoReflect.Descriptor instead. func (*CredentialBroker) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{32} + return file_api_agentmesh_proto_rawDescGZIP(), []int{34} } func (x *CredentialBroker) GetKind() isCredentialBroker_Kind { @@ -3118,7 +3350,7 @@ type OIDCFederation struct { func (x *OIDCFederation) Reset() { *x = OIDCFederation{} - mi := &file_api_agentmesh_proto_msgTypes[33] + mi := &file_api_agentmesh_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3130,7 +3362,7 @@ func (x *OIDCFederation) String() string { func (*OIDCFederation) ProtoMessage() {} func (x *OIDCFederation) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[33] + mi := &file_api_agentmesh_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3143,7 +3375,7 @@ func (x *OIDCFederation) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCFederation.ProtoReflect.Descriptor instead. func (*OIDCFederation) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{33} + return file_api_agentmesh_proto_rawDescGZIP(), []int{35} } func (x *OIDCFederation) GetTokenEndpoint() string { @@ -3185,7 +3417,7 @@ type AWSAssumeRole struct { func (x *AWSAssumeRole) Reset() { *x = AWSAssumeRole{} - mi := &file_api_agentmesh_proto_msgTypes[34] + mi := &file_api_agentmesh_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3197,7 +3429,7 @@ func (x *AWSAssumeRole) String() string { func (*AWSAssumeRole) ProtoMessage() {} func (x *AWSAssumeRole) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[34] + mi := &file_api_agentmesh_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3210,7 +3442,7 @@ func (x *AWSAssumeRole) ProtoReflect() protoreflect.Message { // Deprecated: Use AWSAssumeRole.ProtoReflect.Descriptor instead. func (*AWSAssumeRole) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{34} + return file_api_agentmesh_proto_rawDescGZIP(), []int{36} } func (x *AWSAssumeRole) GetRoleArn() string { @@ -3236,7 +3468,7 @@ type PlatformIdentity struct { func (x *PlatformIdentity) Reset() { *x = PlatformIdentity{} - mi := &file_api_agentmesh_proto_msgTypes[35] + mi := &file_api_agentmesh_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3248,7 +3480,7 @@ func (x *PlatformIdentity) String() string { func (*PlatformIdentity) ProtoMessage() {} func (x *PlatformIdentity) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[35] + mi := &file_api_agentmesh_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3261,7 +3493,7 @@ func (x *PlatformIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use PlatformIdentity.ProtoReflect.Descriptor instead. func (*PlatformIdentity) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{35} + return file_api_agentmesh_proto_rawDescGZIP(), []int{37} } func (x *PlatformIdentity) GetScopes() []string { @@ -3281,7 +3513,7 @@ type PolicyBinding struct { func (x *PolicyBinding) Reset() { *x = PolicyBinding{} - mi := &file_api_agentmesh_proto_msgTypes[36] + mi := &file_api_agentmesh_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3293,7 +3525,7 @@ func (x *PolicyBinding) String() string { func (*PolicyBinding) ProtoMessage() {} func (x *PolicyBinding) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[36] + mi := &file_api_agentmesh_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3306,7 +3538,7 @@ func (x *PolicyBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyBinding.ProtoReflect.Descriptor instead. func (*PolicyBinding) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{36} + return file_api_agentmesh_proto_rawDescGZIP(), []int{38} } func (x *PolicyBinding) GetRole() string { @@ -3338,7 +3570,7 @@ type PolicyConfig struct { func (x *PolicyConfig) Reset() { *x = PolicyConfig{} - mi := &file_api_agentmesh_proto_msgTypes[37] + mi := &file_api_agentmesh_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3350,7 +3582,7 @@ func (x *PolicyConfig) String() string { func (*PolicyConfig) ProtoMessage() {} func (x *PolicyConfig) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[37] + mi := &file_api_agentmesh_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3363,7 +3595,7 @@ func (x *PolicyConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfig.ProtoReflect.Descriptor instead. func (*PolicyConfig) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{37} + return file_api_agentmesh_proto_rawDescGZIP(), []int{39} } func (x *PolicyConfig) GetRoles() []*PolicyRole { @@ -3395,7 +3627,7 @@ type PolicyConfigGetRequest struct { func (x *PolicyConfigGetRequest) Reset() { *x = PolicyConfigGetRequest{} - mi := &file_api_agentmesh_proto_msgTypes[38] + mi := &file_api_agentmesh_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3407,7 +3639,7 @@ func (x *PolicyConfigGetRequest) String() string { func (*PolicyConfigGetRequest) ProtoMessage() {} func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[38] + mi := &file_api_agentmesh_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3420,7 +3652,7 @@ func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetRequest.ProtoReflect.Descriptor instead. func (*PolicyConfigGetRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{38} + return file_api_agentmesh_proto_rawDescGZIP(), []int{40} } // PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -3436,7 +3668,7 @@ type PolicyConfigGetResponse struct { func (x *PolicyConfigGetResponse) Reset() { *x = PolicyConfigGetResponse{} - mi := &file_api_agentmesh_proto_msgTypes[39] + mi := &file_api_agentmesh_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3448,7 +3680,7 @@ func (x *PolicyConfigGetResponse) String() string { func (*PolicyConfigGetResponse) ProtoMessage() {} func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[39] + mi := &file_api_agentmesh_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3461,7 +3693,7 @@ func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigGetResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{39} + return file_api_agentmesh_proto_rawDescGZIP(), []int{41} } func (x *PolicyConfigGetResponse) GetDatalogRules() []string { @@ -3481,7 +3713,7 @@ type PolicyConfigUpdateResponse struct { func (x *PolicyConfigUpdateResponse) Reset() { *x = PolicyConfigUpdateResponse{} - mi := &file_api_agentmesh_proto_msgTypes[40] + mi := &file_api_agentmesh_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3493,7 +3725,7 @@ func (x *PolicyConfigUpdateResponse) String() string { func (*PolicyConfigUpdateResponse) ProtoMessage() {} func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[40] + mi := &file_api_agentmesh_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3506,7 +3738,7 @@ func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigUpdateResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigUpdateResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{40} + return file_api_agentmesh_proto_rawDescGZIP(), []int{42} } func (x *PolicyConfigUpdateResponse) GetSuccess() bool { @@ -3531,7 +3763,7 @@ type EgressAssignmentsRequest struct { func (x *EgressAssignmentsRequest) Reset() { *x = EgressAssignmentsRequest{} - mi := &file_api_agentmesh_proto_msgTypes[41] + mi := &file_api_agentmesh_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3543,7 +3775,7 @@ func (x *EgressAssignmentsRequest) String() string { func (*EgressAssignmentsRequest) ProtoMessage() {} func (x *EgressAssignmentsRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[41] + mi := &file_api_agentmesh_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3556,7 +3788,7 @@ func (x *EgressAssignmentsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressAssignmentsRequest.ProtoReflect.Descriptor instead. func (*EgressAssignmentsRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{41} + return file_api_agentmesh_proto_rawDescGZIP(), []int{43} } // EgressAssignmentsResponse answers GET /egress for a mesh member holding a @@ -3572,7 +3804,7 @@ type EgressAssignmentsResponse struct { func (x *EgressAssignmentsResponse) Reset() { *x = EgressAssignmentsResponse{} - mi := &file_api_agentmesh_proto_msgTypes[42] + mi := &file_api_agentmesh_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3584,7 +3816,7 @@ func (x *EgressAssignmentsResponse) String() string { func (*EgressAssignmentsResponse) ProtoMessage() {} func (x *EgressAssignmentsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[42] + mi := &file_api_agentmesh_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3597,7 +3829,7 @@ func (x *EgressAssignmentsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EgressAssignmentsResponse.ProtoReflect.Descriptor instead. func (*EgressAssignmentsResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{42} + return file_api_agentmesh_proto_rawDescGZIP(), []int{44} } func (x *EgressAssignmentsResponse) GetEgress() []*EgressDestination { @@ -3624,7 +3856,7 @@ type KeysResponse struct { func (x *KeysResponse) Reset() { *x = KeysResponse{} - mi := &file_api_agentmesh_proto_msgTypes[43] + mi := &file_api_agentmesh_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3636,7 +3868,7 @@ func (x *KeysResponse) String() string { func (*KeysResponse) ProtoMessage() {} func (x *KeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[43] + mi := &file_api_agentmesh_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3649,7 +3881,7 @@ func (x *KeysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. func (*KeysResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{43} + return file_api_agentmesh_proto_rawDescGZIP(), []int{45} } func (x *KeysResponse) GetPublicKeys() [][]byte { @@ -3702,7 +3934,7 @@ type TokenRefreshRequest struct { func (x *TokenRefreshRequest) Reset() { *x = TokenRefreshRequest{} - mi := &file_api_agentmesh_proto_msgTypes[44] + mi := &file_api_agentmesh_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3714,7 +3946,7 @@ func (x *TokenRefreshRequest) String() string { func (*TokenRefreshRequest) ProtoMessage() {} func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[44] + mi := &file_api_agentmesh_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3727,7 +3959,7 @@ func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshRequest.ProtoReflect.Descriptor instead. func (*TokenRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{44} + return file_api_agentmesh_proto_rawDescGZIP(), []int{46} } func (x *TokenRefreshRequest) GetChallengeSignature() []byte { @@ -3769,7 +4001,7 @@ type TokenRefreshResponse struct { func (x *TokenRefreshResponse) Reset() { *x = TokenRefreshResponse{} - mi := &file_api_agentmesh_proto_msgTypes[45] + mi := &file_api_agentmesh_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3781,7 +4013,7 @@ func (x *TokenRefreshResponse) String() string { func (*TokenRefreshResponse) ProtoMessage() {} func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[45] + mi := &file_api_agentmesh_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3794,7 +4026,7 @@ func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshResponse.ProtoReflect.Descriptor instead. func (*TokenRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{45} + return file_api_agentmesh_proto_rawDescGZIP(), []int{47} } func (x *TokenRefreshResponse) GetBiscuitToken() []byte { @@ -3831,7 +4063,7 @@ type NodeCatalogReport struct { func (x *NodeCatalogReport) Reset() { *x = NodeCatalogReport{} - mi := &file_api_agentmesh_proto_msgTypes[46] + mi := &file_api_agentmesh_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3843,7 +4075,7 @@ func (x *NodeCatalogReport) String() string { func (*NodeCatalogReport) ProtoMessage() {} func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[46] + mi := &file_api_agentmesh_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3856,7 +4088,7 @@ func (x *NodeCatalogReport) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeCatalogReport.ProtoReflect.Descriptor instead. func (*NodeCatalogReport) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{46} + return file_api_agentmesh_proto_rawDescGZIP(), []int{48} } func (x *NodeCatalogReport) GetServices() []*ServiceInfo { @@ -3875,7 +4107,7 @@ type TokenRevokeRequest struct { func (x *TokenRevokeRequest) Reset() { *x = TokenRevokeRequest{} - mi := &file_api_agentmesh_proto_msgTypes[47] + mi := &file_api_agentmesh_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3887,7 +4119,7 @@ func (x *TokenRevokeRequest) String() string { func (*TokenRevokeRequest) ProtoMessage() {} func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[47] + mi := &file_api_agentmesh_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3900,7 +4132,7 @@ func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeRequest.ProtoReflect.Descriptor instead. func (*TokenRevokeRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{47} + return file_api_agentmesh_proto_rawDescGZIP(), []int{49} } func (x *TokenRevokeRequest) GetPeerId() string { @@ -3920,7 +4152,7 @@ type TokenRevokeResponse struct { func (x *TokenRevokeResponse) Reset() { *x = TokenRevokeResponse{} - mi := &file_api_agentmesh_proto_msgTypes[48] + mi := &file_api_agentmesh_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3932,7 +4164,7 @@ func (x *TokenRevokeResponse) String() string { func (*TokenRevokeResponse) ProtoMessage() {} func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[48] + mi := &file_api_agentmesh_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3945,7 +4177,7 @@ func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeResponse.ProtoReflect.Descriptor instead. func (*TokenRevokeResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{48} + return file_api_agentmesh_proto_rawDescGZIP(), []int{50} } func (x *TokenRevokeResponse) GetSuccess() bool { @@ -3996,7 +4228,7 @@ type BootstrapTokenCreateRequest struct { func (x *BootstrapTokenCreateRequest) Reset() { *x = BootstrapTokenCreateRequest{} - mi := &file_api_agentmesh_proto_msgTypes[49] + mi := &file_api_agentmesh_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4008,7 +4240,7 @@ func (x *BootstrapTokenCreateRequest) String() string { func (*BootstrapTokenCreateRequest) ProtoMessage() {} func (x *BootstrapTokenCreateRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[49] + mi := &file_api_agentmesh_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4021,7 +4253,7 @@ func (x *BootstrapTokenCreateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenCreateRequest.ProtoReflect.Descriptor instead. func (*BootstrapTokenCreateRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{49} + return file_api_agentmesh_proto_rawDescGZIP(), []int{51} } func (x *BootstrapTokenCreateRequest) GetRole() string { @@ -4089,7 +4321,7 @@ type BootstrapTokenCreateResponse struct { func (x *BootstrapTokenCreateResponse) Reset() { *x = BootstrapTokenCreateResponse{} - mi := &file_api_agentmesh_proto_msgTypes[50] + mi := &file_api_agentmesh_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4101,7 +4333,7 @@ func (x *BootstrapTokenCreateResponse) String() string { func (*BootstrapTokenCreateResponse) ProtoMessage() {} func (x *BootstrapTokenCreateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[50] + mi := &file_api_agentmesh_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4114,7 +4346,7 @@ func (x *BootstrapTokenCreateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenCreateResponse.ProtoReflect.Descriptor instead. func (*BootstrapTokenCreateResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{50} + return file_api_agentmesh_proto_rawDescGZIP(), []int{52} } func (x *BootstrapTokenCreateResponse) GetId() string { @@ -4174,7 +4406,7 @@ type BootstrapToken struct { func (x *BootstrapToken) Reset() { *x = BootstrapToken{} - mi := &file_api_agentmesh_proto_msgTypes[51] + mi := &file_api_agentmesh_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4186,7 +4418,7 @@ func (x *BootstrapToken) String() string { func (*BootstrapToken) ProtoMessage() {} func (x *BootstrapToken) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[51] + mi := &file_api_agentmesh_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4199,7 +4431,7 @@ func (x *BootstrapToken) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapToken.ProtoReflect.Descriptor instead. func (*BootstrapToken) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{51} + return file_api_agentmesh_proto_rawDescGZIP(), []int{53} } func (x *BootstrapToken) GetId() string { @@ -4288,7 +4520,7 @@ type BootstrapTokenListResponse struct { func (x *BootstrapTokenListResponse) Reset() { *x = BootstrapTokenListResponse{} - mi := &file_api_agentmesh_proto_msgTypes[52] + mi := &file_api_agentmesh_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4300,7 +4532,7 @@ func (x *BootstrapTokenListResponse) String() string { func (*BootstrapTokenListResponse) ProtoMessage() {} func (x *BootstrapTokenListResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[52] + mi := &file_api_agentmesh_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4313,7 +4545,7 @@ func (x *BootstrapTokenListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BootstrapTokenListResponse.ProtoReflect.Descriptor instead. func (*BootstrapTokenListResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{52} + return file_api_agentmesh_proto_rawDescGZIP(), []int{54} } func (x *BootstrapTokenListResponse) GetTokens() []*BootstrapToken { @@ -4344,7 +4576,7 @@ type EnrollmentRequest struct { func (x *EnrollmentRequest) Reset() { *x = EnrollmentRequest{} - mi := &file_api_agentmesh_proto_msgTypes[53] + mi := &file_api_agentmesh_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4356,7 +4588,7 @@ func (x *EnrollmentRequest) String() string { func (*EnrollmentRequest) ProtoMessage() {} func (x *EnrollmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[53] + mi := &file_api_agentmesh_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4369,7 +4601,7 @@ func (x *EnrollmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrollmentRequest.ProtoReflect.Descriptor instead. func (*EnrollmentRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{53} + return file_api_agentmesh_proto_rawDescGZIP(), []int{55} } func (x *EnrollmentRequest) GetId() string { @@ -4437,7 +4669,7 @@ type EnrollmentRequestListResponse struct { func (x *EnrollmentRequestListResponse) Reset() { *x = EnrollmentRequestListResponse{} - mi := &file_api_agentmesh_proto_msgTypes[54] + mi := &file_api_agentmesh_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4449,7 +4681,7 @@ func (x *EnrollmentRequestListResponse) String() string { func (*EnrollmentRequestListResponse) ProtoMessage() {} func (x *EnrollmentRequestListResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[54] + mi := &file_api_agentmesh_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4462,7 +4694,7 @@ func (x *EnrollmentRequestListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrollmentRequestListResponse.ProtoReflect.Descriptor instead. func (*EnrollmentRequestListResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{54} + return file_api_agentmesh_proto_rawDescGZIP(), []int{56} } func (x *EnrollmentRequestListResponse) GetRequests() []*EnrollmentRequest { @@ -4488,7 +4720,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_api_agentmesh_proto_msgTypes[55] + mi := &file_api_agentmesh_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4500,7 +4732,7 @@ func (x *User) String() string { func (*User) ProtoMessage() {} func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[55] + mi := &file_api_agentmesh_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4513,7 +4745,7 @@ func (x *User) ProtoReflect() protoreflect.Message { // Deprecated: Use User.ProtoReflect.Descriptor instead. func (*User) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{55} + return file_api_agentmesh_proto_rawDescGZIP(), []int{57} } func (x *User) GetId() string { @@ -4576,7 +4808,7 @@ type EnrolledNode struct { func (x *EnrolledNode) Reset() { *x = EnrolledNode{} - mi := &file_api_agentmesh_proto_msgTypes[56] + mi := &file_api_agentmesh_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4588,7 +4820,7 @@ func (x *EnrolledNode) String() string { func (*EnrolledNode) ProtoMessage() {} func (x *EnrolledNode) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[56] + mi := &file_api_agentmesh_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4601,7 +4833,7 @@ func (x *EnrolledNode) ProtoReflect() protoreflect.Message { // Deprecated: Use EnrolledNode.ProtoReflect.Descriptor instead. func (*EnrolledNode) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{56} + return file_api_agentmesh_proto_rawDescGZIP(), []int{58} } func (x *EnrolledNode) GetPeerId() string { @@ -4691,13 +4923,14 @@ type RouterLease struct { ExpireTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` ConnectedPeers []string `protobuf:"bytes,5,rep,name=connected_peers,json=connectedPeers,proto3" json:"connected_peers,omitempty"` DhtSize int32 `protobuf:"varint,6,opt,name=dht_size,json=dhtSize,proto3" json:"dht_size,omitempty"` + IngressUrl string `protobuf:"bytes,7,opt,name=ingress_url,json=ingressUrl,proto3" json:"ingress_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RouterLease) Reset() { *x = RouterLease{} - mi := &file_api_agentmesh_proto_msgTypes[57] + mi := &file_api_agentmesh_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4709,7 +4942,7 @@ func (x *RouterLease) String() string { func (*RouterLease) ProtoMessage() {} func (x *RouterLease) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[57] + mi := &file_api_agentmesh_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4722,7 +4955,7 @@ func (x *RouterLease) ProtoReflect() protoreflect.Message { // Deprecated: Use RouterLease.ProtoReflect.Descriptor instead. func (*RouterLease) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{57} + return file_api_agentmesh_proto_rawDescGZIP(), []int{59} } func (x *RouterLease) GetPeerId() string { @@ -4767,6 +5000,13 @@ func (x *RouterLease) GetDhtSize() int32 { return 0 } +func (x *RouterLease) GetIngressUrl() string { + if x != nil { + return x.IngressUrl + } + return "" +} + // NodeServices is the services one node last reported (see // NodeCatalogReport). type NodeServices struct { @@ -4779,7 +5019,7 @@ type NodeServices struct { func (x *NodeServices) Reset() { *x = NodeServices{} - mi := &file_api_agentmesh_proto_msgTypes[58] + mi := &file_api_agentmesh_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4791,7 +5031,7 @@ func (x *NodeServices) String() string { func (*NodeServices) ProtoMessage() {} func (x *NodeServices) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[58] + mi := &file_api_agentmesh_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4804,7 +5044,7 @@ func (x *NodeServices) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeServices.ProtoReflect.Descriptor instead. func (*NodeServices) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{58} + return file_api_agentmesh_proto_rawDescGZIP(), []int{60} } func (x *NodeServices) GetServices() []*ServiceInfo { @@ -4839,7 +5079,7 @@ type AdminStatusResponse struct { func (x *AdminStatusResponse) Reset() { *x = AdminStatusResponse{} - mi := &file_api_agentmesh_proto_msgTypes[59] + mi := &file_api_agentmesh_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4851,7 +5091,7 @@ func (x *AdminStatusResponse) String() string { func (*AdminStatusResponse) ProtoMessage() {} func (x *AdminStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[59] + mi := &file_api_agentmesh_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4864,7 +5104,7 @@ func (x *AdminStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AdminStatusResponse.ProtoReflect.Descriptor instead. func (*AdminStatusResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{59} + return file_api_agentmesh_proto_rawDescGZIP(), []int{61} } func (x *AdminStatusResponse) GetUsers() []*User { @@ -4932,7 +5172,7 @@ type UserStatusResponse struct { func (x *UserStatusResponse) Reset() { *x = UserStatusResponse{} - mi := &file_api_agentmesh_proto_msgTypes[60] + mi := &file_api_agentmesh_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4944,7 +5184,7 @@ func (x *UserStatusResponse) String() string { func (*UserStatusResponse) ProtoMessage() {} func (x *UserStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[60] + mi := &file_api_agentmesh_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4957,7 +5197,7 @@ func (x *UserStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UserStatusResponse.ProtoReflect.Descriptor instead. func (*UserStatusResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{60} + return file_api_agentmesh_proto_rawDescGZIP(), []int{62} } func (x *UserStatusResponse) GetUser() *User { @@ -5010,7 +5250,7 @@ type NodeListResponse struct { func (x *NodeListResponse) Reset() { *x = NodeListResponse{} - mi := &file_api_agentmesh_proto_msgTypes[61] + mi := &file_api_agentmesh_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5022,7 +5262,7 @@ func (x *NodeListResponse) String() string { func (*NodeListResponse) ProtoMessage() {} func (x *NodeListResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[61] + mi := &file_api_agentmesh_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5035,7 +5275,7 @@ func (x *NodeListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NodeListResponse.ProtoReflect.Descriptor instead. func (*NodeListResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{61} + return file_api_agentmesh_proto_rawDescGZIP(), []int{63} } func (x *NodeListResponse) GetNodes() []*EnrolledNode { @@ -5066,7 +5306,7 @@ type IdentityEvidenceResponse struct { func (x *IdentityEvidenceResponse) Reset() { *x = IdentityEvidenceResponse{} - mi := &file_api_agentmesh_proto_msgTypes[62] + mi := &file_api_agentmesh_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5078,7 +5318,7 @@ func (x *IdentityEvidenceResponse) String() string { func (*IdentityEvidenceResponse) ProtoMessage() {} func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[62] + mi := &file_api_agentmesh_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5091,7 +5331,7 @@ func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{62} + return file_api_agentmesh_proto_rawDescGZIP(), []int{64} } func (x *IdentityEvidenceResponse) GetPeerId() string { @@ -5152,7 +5392,7 @@ type PeerEvidenceResponse struct { func (x *PeerEvidenceResponse) Reset() { *x = PeerEvidenceResponse{} - mi := &file_api_agentmesh_proto_msgTypes[63] + mi := &file_api_agentmesh_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5164,7 +5404,7 @@ func (x *PeerEvidenceResponse) String() string { func (*PeerEvidenceResponse) ProtoMessage() {} func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[63] + mi := &file_api_agentmesh_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5177,7 +5417,7 @@ func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{63} + return file_api_agentmesh_proto_rawDescGZIP(), []int{65} } func (x *PeerEvidenceResponse) GetPeerId() string { @@ -5262,7 +5502,7 @@ type MemberCredential struct { func (x *MemberCredential) Reset() { *x = MemberCredential{} - mi := &file_api_agentmesh_proto_msgTypes[64] + mi := &file_api_agentmesh_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5274,7 +5514,7 @@ func (x *MemberCredential) String() string { func (*MemberCredential) ProtoMessage() {} func (x *MemberCredential) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[64] + mi := &file_api_agentmesh_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5287,7 +5527,7 @@ func (x *MemberCredential) ProtoReflect() protoreflect.Message { // Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. func (*MemberCredential) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{64} + return file_api_agentmesh_proto_rawDescGZIP(), []int{66} } func (x *MemberCredential) GetControlPlaneUrl() string { @@ -5352,7 +5592,7 @@ type TrustedSigningKey struct { func (x *TrustedSigningKey) Reset() { *x = TrustedSigningKey{} - mi := &file_api_agentmesh_proto_msgTypes[65] + mi := &file_api_agentmesh_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5364,7 +5604,7 @@ func (x *TrustedSigningKey) String() string { func (*TrustedSigningKey) ProtoMessage() {} func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[65] + mi := &file_api_agentmesh_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5377,7 +5617,7 @@ func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { // Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. func (*TrustedSigningKey) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{65} + return file_api_agentmesh_proto_rawDescGZIP(), []int{67} } func (x *TrustedSigningKey) GetPublicKey() []byte { @@ -5406,7 +5646,7 @@ type OIDCSession struct { func (x *OIDCSession) Reset() { *x = OIDCSession{} - mi := &file_api_agentmesh_proto_msgTypes[66] + mi := &file_api_agentmesh_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5418,7 +5658,7 @@ func (x *OIDCSession) String() string { func (*OIDCSession) ProtoMessage() {} func (x *OIDCSession) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[66] + mi := &file_api_agentmesh_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5431,7 +5671,7 @@ func (x *OIDCSession) ProtoReflect() protoreflect.Message { // Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. func (*OIDCSession) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{66} + return file_api_agentmesh_proto_rawDescGZIP(), []int{68} } func (x *OIDCSession) GetIssuer() string { @@ -5483,7 +5723,7 @@ type TaskAuthorizationRule struct { func (x *TaskAuthorizationRule) Reset() { *x = TaskAuthorizationRule{} - mi := &file_api_agentmesh_proto_msgTypes[67] + mi := &file_api_agentmesh_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5495,7 +5735,7 @@ func (x *TaskAuthorizationRule) String() string { func (*TaskAuthorizationRule) ProtoMessage() {} func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[67] + mi := &file_api_agentmesh_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5508,7 +5748,7 @@ func (x *TaskAuthorizationRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskAuthorizationRule.ProtoReflect.Descriptor instead. func (*TaskAuthorizationRule) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{67} + return file_api_agentmesh_proto_rawDescGZIP(), []int{69} } func (x *TaskAuthorizationRule) GetName() string { @@ -5560,7 +5800,7 @@ type TaskRule struct { func (x *TaskRule) Reset() { *x = TaskRule{} - mi := &file_api_agentmesh_proto_msgTypes[68] + mi := &file_api_agentmesh_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5572,7 +5812,7 @@ func (x *TaskRule) String() string { func (*TaskRule) ProtoMessage() {} func (x *TaskRule) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[68] + mi := &file_api_agentmesh_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5585,7 +5825,7 @@ func (x *TaskRule) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskRule.ProtoReflect.Descriptor instead. func (*TaskRule) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{68} + return file_api_agentmesh_proto_rawDescGZIP(), []int{70} } func (x *TaskRule) GetDescription() string { @@ -5634,7 +5874,7 @@ type TaskOperation struct { func (x *TaskOperation) Reset() { *x = TaskOperation{} - mi := &file_api_agentmesh_proto_msgTypes[69] + mi := &file_api_agentmesh_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5646,7 +5886,7 @@ func (x *TaskOperation) String() string { func (*TaskOperation) ProtoMessage() {} func (x *TaskOperation) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[69] + mi := &file_api_agentmesh_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5659,7 +5899,7 @@ func (x *TaskOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskOperation.ProtoReflect.Descriptor instead. func (*TaskOperation) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{69} + return file_api_agentmesh_proto_rawDescGZIP(), []int{71} } func (x *TaskOperation) GetAllowedTools() []string { @@ -5719,7 +5959,7 @@ type TokenExchangeRequest struct { func (x *TokenExchangeRequest) Reset() { *x = TokenExchangeRequest{} - mi := &file_api_agentmesh_proto_msgTypes[70] + mi := &file_api_agentmesh_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5731,7 +5971,7 @@ func (x *TokenExchangeRequest) String() string { func (*TokenExchangeRequest) ProtoMessage() {} func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[70] + mi := &file_api_agentmesh_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5744,7 +5984,7 @@ func (x *TokenExchangeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeRequest.ProtoReflect.Descriptor instead. func (*TokenExchangeRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{70} + return file_api_agentmesh_proto_rawDescGZIP(), []int{72} } func (x *TokenExchangeRequest) GetSubjectToken() string { @@ -5794,7 +6034,7 @@ type TokenExchangeResponse struct { func (x *TokenExchangeResponse) Reset() { *x = TokenExchangeResponse{} - mi := &file_api_agentmesh_proto_msgTypes[71] + mi := &file_api_agentmesh_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5806,7 +6046,7 @@ func (x *TokenExchangeResponse) String() string { func (*TokenExchangeResponse) ProtoMessage() {} func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[71] + mi := &file_api_agentmesh_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5819,7 +6059,7 @@ func (x *TokenExchangeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenExchangeResponse.ProtoReflect.Descriptor instead. func (*TokenExchangeResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{71} + return file_api_agentmesh_proto_rawDescGZIP(), []int{73} } func (x *TokenExchangeResponse) GetBiscuitToken() []byte { @@ -5875,7 +6115,7 @@ type STSTokenRequest struct { func (x *STSTokenRequest) Reset() { *x = STSTokenRequest{} - mi := &file_api_agentmesh_proto_msgTypes[72] + mi := &file_api_agentmesh_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5887,7 +6127,7 @@ func (x *STSTokenRequest) String() string { func (*STSTokenRequest) ProtoMessage() {} func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[72] + mi := &file_api_agentmesh_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5900,7 +6140,7 @@ func (x *STSTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenRequest.ProtoReflect.Descriptor instead. func (*STSTokenRequest) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{72} + return file_api_agentmesh_proto_rawDescGZIP(), []int{74} } func (x *STSTokenRequest) GetBiscuit() []byte { @@ -5951,7 +6191,7 @@ type STSTokenResponse struct { func (x *STSTokenResponse) Reset() { *x = STSTokenResponse{} - mi := &file_api_agentmesh_proto_msgTypes[73] + mi := &file_api_agentmesh_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5963,7 +6203,7 @@ func (x *STSTokenResponse) String() string { func (*STSTokenResponse) ProtoMessage() {} func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[73] + mi := &file_api_agentmesh_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5976,7 +6216,7 @@ func (x *STSTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use STSTokenResponse.ProtoReflect.Descriptor instead. func (*STSTokenResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{73} + return file_api_agentmesh_proto_rawDescGZIP(), []int{75} } func (x *STSTokenResponse) GetJwt() string { @@ -6027,7 +6267,7 @@ type RevocationsResponse struct { func (x *RevocationsResponse) Reset() { *x = RevocationsResponse{} - mi := &file_api_agentmesh_proto_msgTypes[74] + mi := &file_api_agentmesh_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6039,7 +6279,7 @@ func (x *RevocationsResponse) String() string { func (*RevocationsResponse) ProtoMessage() {} func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_agentmesh_proto_msgTypes[74] + mi := &file_api_agentmesh_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6052,7 +6292,7 @@ func (x *RevocationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevocationsResponse.ProtoReflect.Descriptor instead. func (*RevocationsResponse) Descriptor() ([]byte, []int) { - return file_api_agentmesh_proto_rawDescGZIP(), []int{74} + return file_api_agentmesh_proto_rawDescGZIP(), []int{76} } func (x *RevocationsResponse) GetRevocationIds() []string { @@ -6141,11 +6381,15 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x18control_plane_public_key\x18\x05 \x01(\fR\x15controlPlanePublicKey\x12)\n" + "\x10router_addresses\x18\x06 \x03(\tR\x0frouterAddresses\x12;\n" + "\vexpire_time\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expireTime\"r\n" + + "expireTime\"b\n" + + "\x0eServiceIngress\x12:\n" + + "\tauth_mode\x18\x01 \x01(\x0e2\x1d.agentmesh.v1.IngressAuthModeR\bauthMode\x12\x14\n" + + "\x05allow\x18\x02 \x03(\tR\x05allow\"\xaa\x01\n" + "\vServiceInfo\x12-\n" + "\x04type\x18\x01 \x01(\x0e2\x19.agentmesh.v1.ServiceTypeR\x04type\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12 \n" + - "\vdescription\x18\x03 \x01(\tR\vdescription\"\x9b\x01\n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\x126\n" + + "\aingress\x18\x04 \x01(\v2\x1c.agentmesh.v1.ServiceIngressR\aingress\"\x9b\x01\n" + "\x0eCommandBackend\x12\x18\n" + "\acommand\x18\x01 \x03(\tR\acommand\x127\n" + "\x03env\x18\x02 \x03(\v2%.agentmesh.v1.CommandBackend.EnvEntryR\x03env\x1a6\n" + @@ -6182,12 +6426,13 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x0efind_providers\x18\x02 \x01(\v2$.agentmesh.v1.DirectoryFindProvidersH\x00R\rfindProviders\x12>\n" + "\tfind_peer\x18\x03 \x01(\v2\x1f.agentmesh.v1.DirectoryFindPeerH\x00R\bfindPeer\x12=\n" + "\bwithdraw\x18\x04 \x01(\v2\x1f.agentmesh.v1.DirectoryWithdrawH\x00R\bwithdrawB\t\n" + - "\arequest\"p\n" + + "\arequest\"\xa8\x01\n" + "\x10DirectoryProvide\x12\x10\n" + "\x03key\x18\x01 \x01(\fR\x03key\x12\x14\n" + "\x05addrs\x18\x02 \x03(\tR\x05addrs\x12\x1a\n" + "\bprovider\x18\x03 \x01(\tR\bprovider\x12\x18\n" + - "\aservice\x18\x04 \x01(\tR\aservice\"A\n" + + "\aservice\x18\x04 \x01(\tR\aservice\x126\n" + + "\aingress\x18\x05 \x01(\v2\x1c.agentmesh.v1.ServiceIngressR\aingress\"A\n" + "\x11DirectoryWithdraw\x12\x10\n" + "\x03key\x18\x01 \x01(\fR\x03key\x12\x1a\n" + "\bprovider\x18\x02 \x01(\tR\bprovider\"@\n" + @@ -6199,21 +6444,24 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x11DirectoryResponse\x126\n" + "\tproviders\x18\x01 \x03(\v2\x18.agentmesh.v1.PeerRecordR\tproviders\x12,\n" + "\x04peer\x18\x02 \x01(\v2\x18.agentmesh.v1.PeerRecordR\x04peer\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\";\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"s\n" + "\n" + "PeerRecord\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x14\n" + - "\x05addrs\x18\x02 \x03(\tR\x05addrs\"\x89\x02\n" + + "\x05addrs\x18\x02 \x03(\tR\x05addrs\x126\n" + + "\aingress\x18\x03 \x01(\v2\x1c.agentmesh.v1.ServiceIngressR\aingress\"\xaa\x02\n" + "\n" + "RouterInfo\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x1c\n" + "\taddresses\x18\x02 \x03(\tR\taddresses\x12<\n" + "\x06labels\x18\x03 \x03(\v2$.agentmesh.v1.RouterInfo.LabelsEntryR\x06labels\x12 \n" + "\vconnections\x18\x04 \x01(\x05R\vconnections\x12)\n" + - "\x10connection_limit\x18\x05 \x01(\x05R\x0fconnectionLimit\x1a9\n" + + "\x10connection_limit\x18\x05 \x01(\x05R\x0fconnectionLimit\x12\x1f\n" + + "\vingress_url\x18\x06 \x01(\tR\n" + + "ingressUrl\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x80\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa1\x03\n" + "\x12RouterLeaseRequest\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x1c\n" + "\taddresses\x18\x02 \x03(\tR\taddresses\x12\x18\n" + @@ -6225,12 +6473,14 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x03ttl\x18\b \x01(\v2\x19.google.protobuf.DurationR\x03ttl\x12 \n" + "\vconnections\x18\t \x01(\x05R\vconnections\x12)\n" + "\x10connection_limit\x18\n" + - " \x01(\x05R\x0fconnectionLimit\"\x82\x01\n" + + " \x01(\x05R\x0fconnectionLimit\x12\x1f\n" + + "\vingress_url\x18\v \x01(\tR\n" + + "ingressUrl\"\x82\x01\n" + "\x13RouterLeaseResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12;\n" + "\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expireTime\"\x98\x02\n" + + "expireTime\"\xdd\x02\n" + "\n" + "PolicyRole\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12'\n" + @@ -6239,7 +6489,11 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x0ecustom_datalog\x18\x04 \x03(\tR\rcustomDatalog\x12%\n" + "\x0eallowed_labels\x18\x05 \x03(\tR\rallowedLabels\x12+\n" + "\x04http\x18\x06 \x03(\v2\x17.agentmesh.v1.HTTPGrantR\x04http\x12'\n" + - "\x0fallowed_publish\x18\a \x03(\tR\x0eallowedPublish\"U\n" + + "\x0fallowed_publish\x18\a \x03(\tR\x0eallowedPublish\x12C\n" + + "\x0fallowed_ingress\x18\b \x03(\v2\x1a.agentmesh.v1.IngressGrantR\x0eallowedIngress\"w\n" + + "\fIngressGrant\x12\x1a\n" + + "\bservices\x18\x01 \x03(\tR\bservices\x12K\n" + + "\x12allowed_auth_modes\x18\x02 \x03(\x0e2\x1d.agentmesh.v1.IngressAuthModeR\x10allowedAuthModes\"U\n" + "\tHTTPGrant\x12\x18\n" + "\aservice\x18\x01 \x01(\tR\aservice\x12\x18\n" + "\amethods\x18\x02 \x03(\tR\amethods\x12\x14\n" + @@ -6436,7 +6690,7 @@ const file_api_agentmesh_proto_rawDesc = "" + "\tephemeral\x18\v \x01(\bR\tephemeral\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8d\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xae\x02\n" + "\vRouterLease\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x1c\n" + "\taddresses\x18\x02 \x03(\tR\taddresses\x12F\n" + @@ -6444,7 +6698,9 @@ const file_api_agentmesh_proto_rawDesc = "" + "\vexpire_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "expireTime\x12'\n" + "\x0fconnected_peers\x18\x05 \x03(\tR\x0econnectedPeers\x12\x19\n" + - "\bdht_size\x18\x06 \x01(\x05R\adhtSize\"\x82\x01\n" + + "\bdht_size\x18\x06 \x01(\x05R\adhtSize\x12\x1f\n" + + "\vingress_url\x18\a \x01(\tR\n" + + "ingressUrl\"\x82\x01\n" + "\fNodeServices\x125\n" + "\bservices\x18\x01 \x03(\v2\x19.agentmesh.v1.ServiceInfoR\bservices\x12;\n" + "\vreport_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" + @@ -6563,7 +6819,11 @@ const file_api_agentmesh_proto_rawDesc = "" + "\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n" + "\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n" + "\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n" + - "\x13SERVICE_TYPE_EGRESS\x10\x04*7\n" + + "\x13SERVICE_TYPE_EGRESS\x10\x04*s\n" + + "\x0fIngressAuthMode\x12!\n" + + "\x1dINGRESS_AUTH_MODE_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16INGRESS_AUTH_MODE_EDGE\x10\x01\x12!\n" + + "\x1dINGRESS_AUTH_MODE_PASSTHROUGH\x10\x02*7\n" + "\n" + "EgressMode\x12\x14\n" + "\x10EGRESS_MODE_HTTP\x10\x00\x12\x13\n" + @@ -6584,209 +6844,218 @@ func file_api_agentmesh_proto_rawDescGZIP() []byte { return file_api_agentmesh_proto_rawDescData } -var file_api_agentmesh_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_api_agentmesh_proto_msgTypes = make([]protoimpl.MessageInfo, 83) +var file_api_agentmesh_proto_enumTypes = make([]protoimpl.EnumInfo, 9) +var file_api_agentmesh_proto_msgTypes = make([]protoimpl.MessageInfo, 85) var file_api_agentmesh_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: agentmesh.v1.EnrollmentStatus (ServiceType)(0), // 1: agentmesh.v1.ServiceType - (EgressMode)(0), // 2: agentmesh.v1.EgressMode - (ResponseInspection)(0), // 3: agentmesh.v1.ResponseInspection - (RouterGoAway_Reason)(0), // 4: agentmesh.v1.RouterGoAway.Reason - (MeshEvent_Type)(0), // 5: agentmesh.v1.MeshEvent.Type - (ExtProcProcessingMode_HeaderMode)(0), // 6: agentmesh.v1.ExtProcProcessingMode.HeaderMode - (ExtProcProcessingMode_BodyMode)(0), // 7: agentmesh.v1.ExtProcProcessingMode.BodyMode - (*AuthFrame)(nil), // 8: agentmesh.v1.AuthFrame - (*AuthResponse)(nil), // 9: agentmesh.v1.AuthResponse - (*RouterGoAway)(nil), // 10: agentmesh.v1.RouterGoAway - (*MeshEvent)(nil), // 11: agentmesh.v1.MeshEvent - (*EnrollRequest)(nil), // 12: agentmesh.v1.EnrollRequest - (*EnrollResponse)(nil), // 13: agentmesh.v1.EnrollResponse - (*BootstrapEnrollRequest)(nil), // 14: agentmesh.v1.BootstrapEnrollRequest - (*BootstrapEnrollResponse)(nil), // 15: agentmesh.v1.BootstrapEnrollResponse - (*ServiceInfo)(nil), // 16: agentmesh.v1.ServiceInfo - (*CommandBackend)(nil), // 17: agentmesh.v1.CommandBackend - (*RegisterServiceRequest)(nil), // 18: agentmesh.v1.RegisterServiceRequest - (*DiscoveredProvider)(nil), // 19: agentmesh.v1.DiscoveredProvider - (*ControlPlaneInfoResponse)(nil), // 20: agentmesh.v1.ControlPlaneInfoResponse - (*DirectoryParameters)(nil), // 21: agentmesh.v1.DirectoryParameters - (*DirectoryRequest)(nil), // 22: agentmesh.v1.DirectoryRequest - (*DirectoryProvide)(nil), // 23: agentmesh.v1.DirectoryProvide - (*DirectoryWithdraw)(nil), // 24: agentmesh.v1.DirectoryWithdraw - (*DirectoryFindProviders)(nil), // 25: agentmesh.v1.DirectoryFindProviders - (*DirectoryFindPeer)(nil), // 26: agentmesh.v1.DirectoryFindPeer - (*DirectoryResponse)(nil), // 27: agentmesh.v1.DirectoryResponse - (*PeerRecord)(nil), // 28: agentmesh.v1.PeerRecord - (*RouterInfo)(nil), // 29: agentmesh.v1.RouterInfo - (*RouterLeaseRequest)(nil), // 30: agentmesh.v1.RouterLeaseRequest - (*RouterLeaseResponse)(nil), // 31: agentmesh.v1.RouterLeaseResponse - (*PolicyRole)(nil), // 32: agentmesh.v1.PolicyRole - (*HTTPGrant)(nil), // 33: agentmesh.v1.HTTPGrant - (*EgressDestination)(nil), // 34: agentmesh.v1.EgressDestination - (*Inspection)(nil), // 35: agentmesh.v1.Inspection - (*Inspector)(nil), // 36: agentmesh.v1.Inspector - (*ModelArmor)(nil), // 37: agentmesh.v1.ModelArmor - (*ExtProc)(nil), // 38: agentmesh.v1.ExtProc - (*ExtProcProcessingMode)(nil), // 39: agentmesh.v1.ExtProcProcessingMode - (*CredentialBroker)(nil), // 40: agentmesh.v1.CredentialBroker - (*OIDCFederation)(nil), // 41: agentmesh.v1.OIDCFederation - (*AWSAssumeRole)(nil), // 42: agentmesh.v1.AWSAssumeRole - (*PlatformIdentity)(nil), // 43: agentmesh.v1.PlatformIdentity - (*PolicyBinding)(nil), // 44: agentmesh.v1.PolicyBinding - (*PolicyConfig)(nil), // 45: agentmesh.v1.PolicyConfig - (*PolicyConfigGetRequest)(nil), // 46: agentmesh.v1.PolicyConfigGetRequest - (*PolicyConfigGetResponse)(nil), // 47: agentmesh.v1.PolicyConfigGetResponse - (*PolicyConfigUpdateResponse)(nil), // 48: agentmesh.v1.PolicyConfigUpdateResponse - (*EgressAssignmentsRequest)(nil), // 49: agentmesh.v1.EgressAssignmentsRequest - (*EgressAssignmentsResponse)(nil), // 50: agentmesh.v1.EgressAssignmentsResponse - (*KeysResponse)(nil), // 51: agentmesh.v1.KeysResponse - (*TokenRefreshRequest)(nil), // 52: agentmesh.v1.TokenRefreshRequest - (*TokenRefreshResponse)(nil), // 53: agentmesh.v1.TokenRefreshResponse - (*NodeCatalogReport)(nil), // 54: agentmesh.v1.NodeCatalogReport - (*TokenRevokeRequest)(nil), // 55: agentmesh.v1.TokenRevokeRequest - (*TokenRevokeResponse)(nil), // 56: agentmesh.v1.TokenRevokeResponse - (*BootstrapTokenCreateRequest)(nil), // 57: agentmesh.v1.BootstrapTokenCreateRequest - (*BootstrapTokenCreateResponse)(nil), // 58: agentmesh.v1.BootstrapTokenCreateResponse - (*BootstrapToken)(nil), // 59: agentmesh.v1.BootstrapToken - (*BootstrapTokenListResponse)(nil), // 60: agentmesh.v1.BootstrapTokenListResponse - (*EnrollmentRequest)(nil), // 61: agentmesh.v1.EnrollmentRequest - (*EnrollmentRequestListResponse)(nil), // 62: agentmesh.v1.EnrollmentRequestListResponse - (*User)(nil), // 63: agentmesh.v1.User - (*EnrolledNode)(nil), // 64: agentmesh.v1.EnrolledNode - (*RouterLease)(nil), // 65: agentmesh.v1.RouterLease - (*NodeServices)(nil), // 66: agentmesh.v1.NodeServices - (*AdminStatusResponse)(nil), // 67: agentmesh.v1.AdminStatusResponse - (*UserStatusResponse)(nil), // 68: agentmesh.v1.UserStatusResponse - (*NodeListResponse)(nil), // 69: agentmesh.v1.NodeListResponse - (*IdentityEvidenceResponse)(nil), // 70: agentmesh.v1.IdentityEvidenceResponse - (*PeerEvidenceResponse)(nil), // 71: agentmesh.v1.PeerEvidenceResponse - (*MemberCredential)(nil), // 72: agentmesh.v1.MemberCredential - (*TrustedSigningKey)(nil), // 73: agentmesh.v1.TrustedSigningKey - (*OIDCSession)(nil), // 74: agentmesh.v1.OIDCSession - (*TaskAuthorizationRule)(nil), // 75: agentmesh.v1.TaskAuthorizationRule - (*TaskRule)(nil), // 76: agentmesh.v1.TaskRule - (*TaskOperation)(nil), // 77: agentmesh.v1.TaskOperation - (*TokenExchangeRequest)(nil), // 78: agentmesh.v1.TokenExchangeRequest - (*TokenExchangeResponse)(nil), // 79: agentmesh.v1.TokenExchangeResponse - (*STSTokenRequest)(nil), // 80: agentmesh.v1.STSTokenRequest - (*STSTokenResponse)(nil), // 81: agentmesh.v1.STSTokenResponse - (*RevocationsResponse)(nil), // 82: agentmesh.v1.RevocationsResponse - nil, // 83: agentmesh.v1.EnrollRequest.LabelsEntry - nil, // 84: agentmesh.v1.BootstrapEnrollRequest.LabelsEntry - nil, // 85: agentmesh.v1.CommandBackend.EnvEntry - nil, // 86: agentmesh.v1.RouterInfo.LabelsEntry - nil, // 87: agentmesh.v1.EnrollmentRequest.LabelsEntry - nil, // 88: agentmesh.v1.EnrolledNode.LabelsEntry - nil, // 89: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry - nil, // 90: agentmesh.v1.PeerEvidenceResponse.LabelsEntry - (*durationpb.Duration)(nil), // 91: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 92: google.protobuf.Timestamp + (IngressAuthMode)(0), // 2: agentmesh.v1.IngressAuthMode + (EgressMode)(0), // 3: agentmesh.v1.EgressMode + (ResponseInspection)(0), // 4: agentmesh.v1.ResponseInspection + (RouterGoAway_Reason)(0), // 5: agentmesh.v1.RouterGoAway.Reason + (MeshEvent_Type)(0), // 6: agentmesh.v1.MeshEvent.Type + (ExtProcProcessingMode_HeaderMode)(0), // 7: agentmesh.v1.ExtProcProcessingMode.HeaderMode + (ExtProcProcessingMode_BodyMode)(0), // 8: agentmesh.v1.ExtProcProcessingMode.BodyMode + (*AuthFrame)(nil), // 9: agentmesh.v1.AuthFrame + (*AuthResponse)(nil), // 10: agentmesh.v1.AuthResponse + (*RouterGoAway)(nil), // 11: agentmesh.v1.RouterGoAway + (*MeshEvent)(nil), // 12: agentmesh.v1.MeshEvent + (*EnrollRequest)(nil), // 13: agentmesh.v1.EnrollRequest + (*EnrollResponse)(nil), // 14: agentmesh.v1.EnrollResponse + (*BootstrapEnrollRequest)(nil), // 15: agentmesh.v1.BootstrapEnrollRequest + (*BootstrapEnrollResponse)(nil), // 16: agentmesh.v1.BootstrapEnrollResponse + (*ServiceIngress)(nil), // 17: agentmesh.v1.ServiceIngress + (*ServiceInfo)(nil), // 18: agentmesh.v1.ServiceInfo + (*CommandBackend)(nil), // 19: agentmesh.v1.CommandBackend + (*RegisterServiceRequest)(nil), // 20: agentmesh.v1.RegisterServiceRequest + (*DiscoveredProvider)(nil), // 21: agentmesh.v1.DiscoveredProvider + (*ControlPlaneInfoResponse)(nil), // 22: agentmesh.v1.ControlPlaneInfoResponse + (*DirectoryParameters)(nil), // 23: agentmesh.v1.DirectoryParameters + (*DirectoryRequest)(nil), // 24: agentmesh.v1.DirectoryRequest + (*DirectoryProvide)(nil), // 25: agentmesh.v1.DirectoryProvide + (*DirectoryWithdraw)(nil), // 26: agentmesh.v1.DirectoryWithdraw + (*DirectoryFindProviders)(nil), // 27: agentmesh.v1.DirectoryFindProviders + (*DirectoryFindPeer)(nil), // 28: agentmesh.v1.DirectoryFindPeer + (*DirectoryResponse)(nil), // 29: agentmesh.v1.DirectoryResponse + (*PeerRecord)(nil), // 30: agentmesh.v1.PeerRecord + (*RouterInfo)(nil), // 31: agentmesh.v1.RouterInfo + (*RouterLeaseRequest)(nil), // 32: agentmesh.v1.RouterLeaseRequest + (*RouterLeaseResponse)(nil), // 33: agentmesh.v1.RouterLeaseResponse + (*PolicyRole)(nil), // 34: agentmesh.v1.PolicyRole + (*IngressGrant)(nil), // 35: agentmesh.v1.IngressGrant + (*HTTPGrant)(nil), // 36: agentmesh.v1.HTTPGrant + (*EgressDestination)(nil), // 37: agentmesh.v1.EgressDestination + (*Inspection)(nil), // 38: agentmesh.v1.Inspection + (*Inspector)(nil), // 39: agentmesh.v1.Inspector + (*ModelArmor)(nil), // 40: agentmesh.v1.ModelArmor + (*ExtProc)(nil), // 41: agentmesh.v1.ExtProc + (*ExtProcProcessingMode)(nil), // 42: agentmesh.v1.ExtProcProcessingMode + (*CredentialBroker)(nil), // 43: agentmesh.v1.CredentialBroker + (*OIDCFederation)(nil), // 44: agentmesh.v1.OIDCFederation + (*AWSAssumeRole)(nil), // 45: agentmesh.v1.AWSAssumeRole + (*PlatformIdentity)(nil), // 46: agentmesh.v1.PlatformIdentity + (*PolicyBinding)(nil), // 47: agentmesh.v1.PolicyBinding + (*PolicyConfig)(nil), // 48: agentmesh.v1.PolicyConfig + (*PolicyConfigGetRequest)(nil), // 49: agentmesh.v1.PolicyConfigGetRequest + (*PolicyConfigGetResponse)(nil), // 50: agentmesh.v1.PolicyConfigGetResponse + (*PolicyConfigUpdateResponse)(nil), // 51: agentmesh.v1.PolicyConfigUpdateResponse + (*EgressAssignmentsRequest)(nil), // 52: agentmesh.v1.EgressAssignmentsRequest + (*EgressAssignmentsResponse)(nil), // 53: agentmesh.v1.EgressAssignmentsResponse + (*KeysResponse)(nil), // 54: agentmesh.v1.KeysResponse + (*TokenRefreshRequest)(nil), // 55: agentmesh.v1.TokenRefreshRequest + (*TokenRefreshResponse)(nil), // 56: agentmesh.v1.TokenRefreshResponse + (*NodeCatalogReport)(nil), // 57: agentmesh.v1.NodeCatalogReport + (*TokenRevokeRequest)(nil), // 58: agentmesh.v1.TokenRevokeRequest + (*TokenRevokeResponse)(nil), // 59: agentmesh.v1.TokenRevokeResponse + (*BootstrapTokenCreateRequest)(nil), // 60: agentmesh.v1.BootstrapTokenCreateRequest + (*BootstrapTokenCreateResponse)(nil), // 61: agentmesh.v1.BootstrapTokenCreateResponse + (*BootstrapToken)(nil), // 62: agentmesh.v1.BootstrapToken + (*BootstrapTokenListResponse)(nil), // 63: agentmesh.v1.BootstrapTokenListResponse + (*EnrollmentRequest)(nil), // 64: agentmesh.v1.EnrollmentRequest + (*EnrollmentRequestListResponse)(nil), // 65: agentmesh.v1.EnrollmentRequestListResponse + (*User)(nil), // 66: agentmesh.v1.User + (*EnrolledNode)(nil), // 67: agentmesh.v1.EnrolledNode + (*RouterLease)(nil), // 68: agentmesh.v1.RouterLease + (*NodeServices)(nil), // 69: agentmesh.v1.NodeServices + (*AdminStatusResponse)(nil), // 70: agentmesh.v1.AdminStatusResponse + (*UserStatusResponse)(nil), // 71: agentmesh.v1.UserStatusResponse + (*NodeListResponse)(nil), // 72: agentmesh.v1.NodeListResponse + (*IdentityEvidenceResponse)(nil), // 73: agentmesh.v1.IdentityEvidenceResponse + (*PeerEvidenceResponse)(nil), // 74: agentmesh.v1.PeerEvidenceResponse + (*MemberCredential)(nil), // 75: agentmesh.v1.MemberCredential + (*TrustedSigningKey)(nil), // 76: agentmesh.v1.TrustedSigningKey + (*OIDCSession)(nil), // 77: agentmesh.v1.OIDCSession + (*TaskAuthorizationRule)(nil), // 78: agentmesh.v1.TaskAuthorizationRule + (*TaskRule)(nil), // 79: agentmesh.v1.TaskRule + (*TaskOperation)(nil), // 80: agentmesh.v1.TaskOperation + (*TokenExchangeRequest)(nil), // 81: agentmesh.v1.TokenExchangeRequest + (*TokenExchangeResponse)(nil), // 82: agentmesh.v1.TokenExchangeResponse + (*STSTokenRequest)(nil), // 83: agentmesh.v1.STSTokenRequest + (*STSTokenResponse)(nil), // 84: agentmesh.v1.STSTokenResponse + (*RevocationsResponse)(nil), // 85: agentmesh.v1.RevocationsResponse + nil, // 86: agentmesh.v1.EnrollRequest.LabelsEntry + nil, // 87: agentmesh.v1.BootstrapEnrollRequest.LabelsEntry + nil, // 88: agentmesh.v1.CommandBackend.EnvEntry + nil, // 89: agentmesh.v1.RouterInfo.LabelsEntry + nil, // 90: agentmesh.v1.EnrollmentRequest.LabelsEntry + nil, // 91: agentmesh.v1.EnrolledNode.LabelsEntry + nil, // 92: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry + nil, // 93: agentmesh.v1.PeerEvidenceResponse.LabelsEntry + (*durationpb.Duration)(nil), // 94: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 95: google.protobuf.Timestamp } var file_api_agentmesh_proto_depIdxs = []int32{ - 4, // 0: agentmesh.v1.RouterGoAway.reason:type_name -> agentmesh.v1.RouterGoAway.Reason - 91, // 1: agentmesh.v1.RouterGoAway.retry_after:type_name -> google.protobuf.Duration - 5, // 2: agentmesh.v1.MeshEvent.type:type_name -> agentmesh.v1.MeshEvent.Type - 92, // 3: agentmesh.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp - 83, // 4: agentmesh.v1.EnrollRequest.labels:type_name -> agentmesh.v1.EnrollRequest.LabelsEntry - 92, // 5: agentmesh.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 84, // 6: agentmesh.v1.BootstrapEnrollRequest.labels:type_name -> agentmesh.v1.BootstrapEnrollRequest.LabelsEntry + 5, // 0: agentmesh.v1.RouterGoAway.reason:type_name -> agentmesh.v1.RouterGoAway.Reason + 94, // 1: agentmesh.v1.RouterGoAway.retry_after:type_name -> google.protobuf.Duration + 6, // 2: agentmesh.v1.MeshEvent.type:type_name -> agentmesh.v1.MeshEvent.Type + 95, // 3: agentmesh.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp + 86, // 4: agentmesh.v1.EnrollRequest.labels:type_name -> agentmesh.v1.EnrollRequest.LabelsEntry + 95, // 5: agentmesh.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 87, // 6: agentmesh.v1.BootstrapEnrollRequest.labels:type_name -> agentmesh.v1.BootstrapEnrollRequest.LabelsEntry 0, // 7: agentmesh.v1.BootstrapEnrollResponse.status:type_name -> agentmesh.v1.EnrollmentStatus - 92, // 8: agentmesh.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 1, // 9: agentmesh.v1.ServiceInfo.type:type_name -> agentmesh.v1.ServiceType - 85, // 10: agentmesh.v1.CommandBackend.env:type_name -> agentmesh.v1.CommandBackend.EnvEntry - 16, // 11: agentmesh.v1.RegisterServiceRequest.service:type_name -> agentmesh.v1.ServiceInfo - 17, // 12: agentmesh.v1.RegisterServiceRequest.command:type_name -> agentmesh.v1.CommandBackend - 29, // 13: agentmesh.v1.ControlPlaneInfoResponse.routers:type_name -> agentmesh.v1.RouterInfo - 21, // 14: agentmesh.v1.ControlPlaneInfoResponse.directory:type_name -> agentmesh.v1.DirectoryParameters - 91, // 15: agentmesh.v1.DirectoryParameters.reprovide_interval:type_name -> google.protobuf.Duration - 91, // 16: agentmesh.v1.DirectoryParameters.record_ttl:type_name -> google.protobuf.Duration - 23, // 17: agentmesh.v1.DirectoryRequest.provide:type_name -> agentmesh.v1.DirectoryProvide - 25, // 18: agentmesh.v1.DirectoryRequest.find_providers:type_name -> agentmesh.v1.DirectoryFindProviders - 26, // 19: agentmesh.v1.DirectoryRequest.find_peer:type_name -> agentmesh.v1.DirectoryFindPeer - 24, // 20: agentmesh.v1.DirectoryRequest.withdraw:type_name -> agentmesh.v1.DirectoryWithdraw - 28, // 21: agentmesh.v1.DirectoryResponse.providers:type_name -> agentmesh.v1.PeerRecord - 28, // 22: agentmesh.v1.DirectoryResponse.peer:type_name -> agentmesh.v1.PeerRecord - 86, // 23: agentmesh.v1.RouterInfo.labels:type_name -> agentmesh.v1.RouterInfo.LabelsEntry - 91, // 24: agentmesh.v1.RouterLeaseRequest.ttl:type_name -> google.protobuf.Duration - 92, // 25: agentmesh.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp - 33, // 26: agentmesh.v1.PolicyRole.http:type_name -> agentmesh.v1.HTTPGrant - 40, // 27: agentmesh.v1.EgressDestination.broker:type_name -> agentmesh.v1.CredentialBroker - 35, // 28: agentmesh.v1.EgressDestination.inspection:type_name -> agentmesh.v1.Inspection - 2, // 29: agentmesh.v1.EgressDestination.mode:type_name -> agentmesh.v1.EgressMode - 36, // 30: agentmesh.v1.Inspection.inspectors:type_name -> agentmesh.v1.Inspector - 37, // 31: agentmesh.v1.Inspector.model_armor:type_name -> agentmesh.v1.ModelArmor - 38, // 32: agentmesh.v1.Inspector.ext_proc:type_name -> agentmesh.v1.ExtProc - 3, // 33: agentmesh.v1.ModelArmor.response:type_name -> agentmesh.v1.ResponseInspection - 91, // 34: agentmesh.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration - 39, // 35: agentmesh.v1.ExtProc.processing_mode:type_name -> agentmesh.v1.ExtProcProcessingMode - 91, // 36: agentmesh.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration - 6, // 37: agentmesh.v1.ExtProcProcessingMode.request_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 6, // 38: agentmesh.v1.ExtProcProcessingMode.response_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 7, // 39: agentmesh.v1.ExtProcProcessingMode.request_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode - 7, // 40: agentmesh.v1.ExtProcProcessingMode.response_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode - 6, // 41: agentmesh.v1.ExtProcProcessingMode.request_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 6, // 42: agentmesh.v1.ExtProcProcessingMode.response_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode - 41, // 43: agentmesh.v1.CredentialBroker.oidc_federation:type_name -> agentmesh.v1.OIDCFederation - 42, // 44: agentmesh.v1.CredentialBroker.aws_assume_role:type_name -> agentmesh.v1.AWSAssumeRole - 43, // 45: agentmesh.v1.CredentialBroker.platform_identity:type_name -> agentmesh.v1.PlatformIdentity - 32, // 46: agentmesh.v1.PolicyConfig.roles:type_name -> agentmesh.v1.PolicyRole - 44, // 47: agentmesh.v1.PolicyConfig.bindings:type_name -> agentmesh.v1.PolicyBinding - 34, // 48: agentmesh.v1.PolicyConfig.egress:type_name -> agentmesh.v1.EgressDestination - 34, // 49: agentmesh.v1.EgressAssignmentsResponse.egress:type_name -> agentmesh.v1.EgressDestination - 92, // 50: agentmesh.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp - 92, // 51: agentmesh.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp - 16, // 52: agentmesh.v1.NodeCatalogReport.services:type_name -> agentmesh.v1.ServiceInfo - 92, // 53: agentmesh.v1.BootstrapTokenCreateResponse.expire_time:type_name -> google.protobuf.Timestamp - 92, // 54: agentmesh.v1.BootstrapToken.create_time:type_name -> google.protobuf.Timestamp - 92, // 55: agentmesh.v1.BootstrapToken.expire_time:type_name -> google.protobuf.Timestamp - 92, // 56: agentmesh.v1.BootstrapToken.revoke_time:type_name -> google.protobuf.Timestamp - 59, // 57: agentmesh.v1.BootstrapTokenListResponse.tokens:type_name -> agentmesh.v1.BootstrapToken - 0, // 58: agentmesh.v1.EnrollmentRequest.status:type_name -> agentmesh.v1.EnrollmentStatus - 87, // 59: agentmesh.v1.EnrollmentRequest.labels:type_name -> agentmesh.v1.EnrollmentRequest.LabelsEntry - 92, // 60: agentmesh.v1.EnrollmentRequest.create_time:type_name -> google.protobuf.Timestamp - 92, // 61: agentmesh.v1.EnrollmentRequest.resolve_time:type_name -> google.protobuf.Timestamp - 61, // 62: agentmesh.v1.EnrollmentRequestListResponse.requests:type_name -> agentmesh.v1.EnrollmentRequest - 92, // 63: agentmesh.v1.User.create_time:type_name -> google.protobuf.Timestamp - 88, // 64: agentmesh.v1.EnrolledNode.labels:type_name -> agentmesh.v1.EnrolledNode.LabelsEntry - 92, // 65: agentmesh.v1.EnrolledNode.enroll_time:type_name -> google.protobuf.Timestamp - 92, // 66: agentmesh.v1.EnrolledNode.expire_time:type_name -> google.protobuf.Timestamp - 92, // 67: agentmesh.v1.RouterLease.last_renewal_time:type_name -> google.protobuf.Timestamp - 92, // 68: agentmesh.v1.RouterLease.expire_time:type_name -> google.protobuf.Timestamp - 16, // 69: agentmesh.v1.NodeServices.services:type_name -> agentmesh.v1.ServiceInfo - 92, // 70: agentmesh.v1.NodeServices.report_time:type_name -> google.protobuf.Timestamp - 63, // 71: agentmesh.v1.AdminStatusResponse.users:type_name -> agentmesh.v1.User - 65, // 72: agentmesh.v1.AdminStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease - 64, // 73: agentmesh.v1.AdminStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode - 61, // 74: agentmesh.v1.AdminStatusResponse.enrollment_requests:type_name -> agentmesh.v1.EnrollmentRequest - 59, // 75: agentmesh.v1.AdminStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken - 45, // 76: agentmesh.v1.AdminStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig - 89, // 77: agentmesh.v1.AdminStatusResponse.node_catalog:type_name -> agentmesh.v1.AdminStatusResponse.NodeCatalogEntry - 63, // 78: agentmesh.v1.UserStatusResponse.user:type_name -> agentmesh.v1.User - 64, // 79: agentmesh.v1.UserStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode - 59, // 80: agentmesh.v1.UserStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken - 65, // 81: agentmesh.v1.UserStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease - 45, // 82: agentmesh.v1.UserStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig - 64, // 83: agentmesh.v1.NodeListResponse.nodes:type_name -> agentmesh.v1.EnrolledNode - 92, // 84: agentmesh.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp - 92, // 85: agentmesh.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 90, // 86: agentmesh.v1.PeerEvidenceResponse.labels:type_name -> agentmesh.v1.PeerEvidenceResponse.LabelsEntry - 92, // 87: agentmesh.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp - 92, // 88: agentmesh.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 92, // 89: agentmesh.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp - 73, // 90: agentmesh.v1.MemberCredential.trusted_keys:type_name -> agentmesh.v1.TrustedSigningKey - 74, // 91: agentmesh.v1.MemberCredential.oidc_session:type_name -> agentmesh.v1.OIDCSession - 92, // 92: agentmesh.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp - 76, // 93: agentmesh.v1.TaskAuthorizationRule.rules:type_name -> agentmesh.v1.TaskRule - 92, // 94: agentmesh.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp - 77, // 95: agentmesh.v1.TaskRule.operation:type_name -> agentmesh.v1.TaskOperation - 75, // 96: agentmesh.v1.TokenExchangeRequest.task_rule:type_name -> agentmesh.v1.TaskAuthorizationRule - 92, // 97: agentmesh.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp - 92, // 98: agentmesh.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp - 66, // 99: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry.value:type_name -> agentmesh.v1.NodeServices - 100, // [100:100] is the sub-list for method output_type - 100, // [100:100] is the sub-list for method input_type - 100, // [100:100] is the sub-list for extension type_name - 100, // [100:100] is the sub-list for extension extendee - 0, // [0:100] is the sub-list for field type_name + 95, // 8: agentmesh.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 2, // 9: agentmesh.v1.ServiceIngress.auth_mode:type_name -> agentmesh.v1.IngressAuthMode + 1, // 10: agentmesh.v1.ServiceInfo.type:type_name -> agentmesh.v1.ServiceType + 17, // 11: agentmesh.v1.ServiceInfo.ingress:type_name -> agentmesh.v1.ServiceIngress + 88, // 12: agentmesh.v1.CommandBackend.env:type_name -> agentmesh.v1.CommandBackend.EnvEntry + 18, // 13: agentmesh.v1.RegisterServiceRequest.service:type_name -> agentmesh.v1.ServiceInfo + 19, // 14: agentmesh.v1.RegisterServiceRequest.command:type_name -> agentmesh.v1.CommandBackend + 31, // 15: agentmesh.v1.ControlPlaneInfoResponse.routers:type_name -> agentmesh.v1.RouterInfo + 23, // 16: agentmesh.v1.ControlPlaneInfoResponse.directory:type_name -> agentmesh.v1.DirectoryParameters + 94, // 17: agentmesh.v1.DirectoryParameters.reprovide_interval:type_name -> google.protobuf.Duration + 94, // 18: agentmesh.v1.DirectoryParameters.record_ttl:type_name -> google.protobuf.Duration + 25, // 19: agentmesh.v1.DirectoryRequest.provide:type_name -> agentmesh.v1.DirectoryProvide + 27, // 20: agentmesh.v1.DirectoryRequest.find_providers:type_name -> agentmesh.v1.DirectoryFindProviders + 28, // 21: agentmesh.v1.DirectoryRequest.find_peer:type_name -> agentmesh.v1.DirectoryFindPeer + 26, // 22: agentmesh.v1.DirectoryRequest.withdraw:type_name -> agentmesh.v1.DirectoryWithdraw + 17, // 23: agentmesh.v1.DirectoryProvide.ingress:type_name -> agentmesh.v1.ServiceIngress + 30, // 24: agentmesh.v1.DirectoryResponse.providers:type_name -> agentmesh.v1.PeerRecord + 30, // 25: agentmesh.v1.DirectoryResponse.peer:type_name -> agentmesh.v1.PeerRecord + 17, // 26: agentmesh.v1.PeerRecord.ingress:type_name -> agentmesh.v1.ServiceIngress + 89, // 27: agentmesh.v1.RouterInfo.labels:type_name -> agentmesh.v1.RouterInfo.LabelsEntry + 94, // 28: agentmesh.v1.RouterLeaseRequest.ttl:type_name -> google.protobuf.Duration + 95, // 29: agentmesh.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp + 36, // 30: agentmesh.v1.PolicyRole.http:type_name -> agentmesh.v1.HTTPGrant + 35, // 31: agentmesh.v1.PolicyRole.allowed_ingress:type_name -> agentmesh.v1.IngressGrant + 2, // 32: agentmesh.v1.IngressGrant.allowed_auth_modes:type_name -> agentmesh.v1.IngressAuthMode + 43, // 33: agentmesh.v1.EgressDestination.broker:type_name -> agentmesh.v1.CredentialBroker + 38, // 34: agentmesh.v1.EgressDestination.inspection:type_name -> agentmesh.v1.Inspection + 3, // 35: agentmesh.v1.EgressDestination.mode:type_name -> agentmesh.v1.EgressMode + 39, // 36: agentmesh.v1.Inspection.inspectors:type_name -> agentmesh.v1.Inspector + 40, // 37: agentmesh.v1.Inspector.model_armor:type_name -> agentmesh.v1.ModelArmor + 41, // 38: agentmesh.v1.Inspector.ext_proc:type_name -> agentmesh.v1.ExtProc + 4, // 39: agentmesh.v1.ModelArmor.response:type_name -> agentmesh.v1.ResponseInspection + 94, // 40: agentmesh.v1.ModelArmor.timeout:type_name -> google.protobuf.Duration + 42, // 41: agentmesh.v1.ExtProc.processing_mode:type_name -> agentmesh.v1.ExtProcProcessingMode + 94, // 42: agentmesh.v1.ExtProc.message_timeout:type_name -> google.protobuf.Duration + 7, // 43: agentmesh.v1.ExtProcProcessingMode.request_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 7, // 44: agentmesh.v1.ExtProcProcessingMode.response_header_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 8, // 45: agentmesh.v1.ExtProcProcessingMode.request_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode + 8, // 46: agentmesh.v1.ExtProcProcessingMode.response_body_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.BodyMode + 7, // 47: agentmesh.v1.ExtProcProcessingMode.request_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 7, // 48: agentmesh.v1.ExtProcProcessingMode.response_trailer_mode:type_name -> agentmesh.v1.ExtProcProcessingMode.HeaderMode + 44, // 49: agentmesh.v1.CredentialBroker.oidc_federation:type_name -> agentmesh.v1.OIDCFederation + 45, // 50: agentmesh.v1.CredentialBroker.aws_assume_role:type_name -> agentmesh.v1.AWSAssumeRole + 46, // 51: agentmesh.v1.CredentialBroker.platform_identity:type_name -> agentmesh.v1.PlatformIdentity + 34, // 52: agentmesh.v1.PolicyConfig.roles:type_name -> agentmesh.v1.PolicyRole + 47, // 53: agentmesh.v1.PolicyConfig.bindings:type_name -> agentmesh.v1.PolicyBinding + 37, // 54: agentmesh.v1.PolicyConfig.egress:type_name -> agentmesh.v1.EgressDestination + 37, // 55: agentmesh.v1.EgressAssignmentsResponse.egress:type_name -> agentmesh.v1.EgressDestination + 95, // 56: agentmesh.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp + 95, // 57: agentmesh.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 18, // 58: agentmesh.v1.NodeCatalogReport.services:type_name -> agentmesh.v1.ServiceInfo + 95, // 59: agentmesh.v1.BootstrapTokenCreateResponse.expire_time:type_name -> google.protobuf.Timestamp + 95, // 60: agentmesh.v1.BootstrapToken.create_time:type_name -> google.protobuf.Timestamp + 95, // 61: agentmesh.v1.BootstrapToken.expire_time:type_name -> google.protobuf.Timestamp + 95, // 62: agentmesh.v1.BootstrapToken.revoke_time:type_name -> google.protobuf.Timestamp + 62, // 63: agentmesh.v1.BootstrapTokenListResponse.tokens:type_name -> agentmesh.v1.BootstrapToken + 0, // 64: agentmesh.v1.EnrollmentRequest.status:type_name -> agentmesh.v1.EnrollmentStatus + 90, // 65: agentmesh.v1.EnrollmentRequest.labels:type_name -> agentmesh.v1.EnrollmentRequest.LabelsEntry + 95, // 66: agentmesh.v1.EnrollmentRequest.create_time:type_name -> google.protobuf.Timestamp + 95, // 67: agentmesh.v1.EnrollmentRequest.resolve_time:type_name -> google.protobuf.Timestamp + 64, // 68: agentmesh.v1.EnrollmentRequestListResponse.requests:type_name -> agentmesh.v1.EnrollmentRequest + 95, // 69: agentmesh.v1.User.create_time:type_name -> google.protobuf.Timestamp + 91, // 70: agentmesh.v1.EnrolledNode.labels:type_name -> agentmesh.v1.EnrolledNode.LabelsEntry + 95, // 71: agentmesh.v1.EnrolledNode.enroll_time:type_name -> google.protobuf.Timestamp + 95, // 72: agentmesh.v1.EnrolledNode.expire_time:type_name -> google.protobuf.Timestamp + 95, // 73: agentmesh.v1.RouterLease.last_renewal_time:type_name -> google.protobuf.Timestamp + 95, // 74: agentmesh.v1.RouterLease.expire_time:type_name -> google.protobuf.Timestamp + 18, // 75: agentmesh.v1.NodeServices.services:type_name -> agentmesh.v1.ServiceInfo + 95, // 76: agentmesh.v1.NodeServices.report_time:type_name -> google.protobuf.Timestamp + 66, // 77: agentmesh.v1.AdminStatusResponse.users:type_name -> agentmesh.v1.User + 68, // 78: agentmesh.v1.AdminStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease + 67, // 79: agentmesh.v1.AdminStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode + 64, // 80: agentmesh.v1.AdminStatusResponse.enrollment_requests:type_name -> agentmesh.v1.EnrollmentRequest + 62, // 81: agentmesh.v1.AdminStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken + 48, // 82: agentmesh.v1.AdminStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig + 92, // 83: agentmesh.v1.AdminStatusResponse.node_catalog:type_name -> agentmesh.v1.AdminStatusResponse.NodeCatalogEntry + 66, // 84: agentmesh.v1.UserStatusResponse.user:type_name -> agentmesh.v1.User + 67, // 85: agentmesh.v1.UserStatusResponse.enrolled_nodes:type_name -> agentmesh.v1.EnrolledNode + 62, // 86: agentmesh.v1.UserStatusResponse.bootstrap_tokens:type_name -> agentmesh.v1.BootstrapToken + 68, // 87: agentmesh.v1.UserStatusResponse.active_routers:type_name -> agentmesh.v1.RouterLease + 48, // 88: agentmesh.v1.UserStatusResponse.policy:type_name -> agentmesh.v1.PolicyConfig + 67, // 89: agentmesh.v1.NodeListResponse.nodes:type_name -> agentmesh.v1.EnrolledNode + 95, // 90: agentmesh.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp + 95, // 91: agentmesh.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 93, // 92: agentmesh.v1.PeerEvidenceResponse.labels:type_name -> agentmesh.v1.PeerEvidenceResponse.LabelsEntry + 95, // 93: agentmesh.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp + 95, // 94: agentmesh.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 95, // 95: agentmesh.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp + 76, // 96: agentmesh.v1.MemberCredential.trusted_keys:type_name -> agentmesh.v1.TrustedSigningKey + 77, // 97: agentmesh.v1.MemberCredential.oidc_session:type_name -> agentmesh.v1.OIDCSession + 95, // 98: agentmesh.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp + 79, // 99: agentmesh.v1.TaskAuthorizationRule.rules:type_name -> agentmesh.v1.TaskRule + 95, // 100: agentmesh.v1.TaskAuthorizationRule.expire_time:type_name -> google.protobuf.Timestamp + 80, // 101: agentmesh.v1.TaskRule.operation:type_name -> agentmesh.v1.TaskOperation + 78, // 102: agentmesh.v1.TokenExchangeRequest.task_rule:type_name -> agentmesh.v1.TaskAuthorizationRule + 95, // 103: agentmesh.v1.TokenExchangeResponse.expire_time:type_name -> google.protobuf.Timestamp + 95, // 104: agentmesh.v1.STSTokenResponse.expire_time:type_name -> google.protobuf.Timestamp + 69, // 105: agentmesh.v1.AdminStatusResponse.NodeCatalogEntry.value:type_name -> agentmesh.v1.NodeServices + 106, // [106:106] is the sub-list for method output_type + 106, // [106:106] is the sub-list for method input_type + 106, // [106:106] is the sub-list for extension type_name + 106, // [106:106] is the sub-list for extension extendee + 0, // [0:106] is the sub-list for field type_name } func init() { file_api_agentmesh_proto_init() } @@ -6794,21 +7063,21 @@ func file_api_agentmesh_proto_init() { if File_api_agentmesh_proto != nil { return } - file_api_agentmesh_proto_msgTypes[10].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[11].OneofWrappers = []any{ (*RegisterServiceRequest_TargetUrl)(nil), (*RegisterServiceRequest_Command)(nil), } - file_api_agentmesh_proto_msgTypes[14].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[15].OneofWrappers = []any{ (*DirectoryRequest_Provide)(nil), (*DirectoryRequest_FindProviders)(nil), (*DirectoryRequest_FindPeer)(nil), (*DirectoryRequest_Withdraw)(nil), } - file_api_agentmesh_proto_msgTypes[28].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[30].OneofWrappers = []any{ (*Inspector_ModelArmor)(nil), (*Inspector_ExtProc)(nil), } - file_api_agentmesh_proto_msgTypes[32].OneofWrappers = []any{ + file_api_agentmesh_proto_msgTypes[34].OneofWrappers = []any{ (*CredentialBroker_StaticSecret)(nil), (*CredentialBroker_OidcFederation)(nil), (*CredentialBroker_AwsAssumeRole)(nil), @@ -6819,8 +7088,8 @@ func file_api_agentmesh_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_agentmesh_proto_rawDesc), len(file_api_agentmesh_proto_rawDesc)), - NumEnums: 8, - NumMessages: 83, + NumEnums: 9, + NumMessages: 85, NumExtensions: 0, NumServices: 0, }, diff --git a/api/agentmesh.proto b/api/agentmesh.proto index 67d97032..b265be2a 100644 --- a/api/agentmesh.proto +++ b/api/agentmesh.proto @@ -150,10 +150,37 @@ enum ServiceType { SERVICE_TYPE_EGRESS = 4; } +enum IngressAuthMode { + // Defaults to INGRESS_AUTH_MODE_EDGE. + INGRESS_AUTH_MODE_UNSPECIFIED = 0; + // Router and provider node verify a delegated Mesh Biscuit + mesh policy. + INGRESS_AUTH_MODE_EDGE = 1; + // Backend authenticates its own requests; requires an explicit PASSTHROUGH + // grant on the serving node's PolicyRole.allowed_ingress. + INGRESS_AUTH_MODE_PASSTHROUGH = 2; +} + +// ServiceIngress configures external router HTTP ingress for an inbound service +// (a2a, mcp, or inference). Absent means the service is reachable only inside +// the mesh. +message ServiceIngress { + // Defaults to INGRESS_AUTH_MODE_EDGE when UNSPECIFIED. + IngressAuthMode auth_mode = 1; + // Optional caller narrowing in PolicyBinding.members syntax + // (e.g. "email:alice@example.com", "email:*@example.com", "group:eng"). + // Intersected (AND) with mesh Datalog policy: when empty, any caller + // authorized by mesh policy for this service may invoke it; when non-empty, + // the verified caller must also match at least one entry here. Only allowed + // in INGRESS_AUTH_MODE_EDGE. + repeated string allow = 2; +} + message ServiceInfo { ServiceType type = 1; string name = 2; string description = 3; + // Optional external ingress exposure for this service. + ServiceIngress ingress = 4; } message CommandBackend { @@ -251,6 +278,10 @@ message DirectoryProvide { repeated string addrs = 2; string provider = 3; string service = 4; + // Optional external ingress metadata for a named service record. When set, + // the router verifies that the provider's credential grants exposing this + // service with the requested auth_mode before storing or replicating it. + ServiceIngress ingress = 5; } // DirectoryWithdraw removes the sender's record under key, from the router @@ -287,6 +318,8 @@ message DirectoryResponse { message PeerRecord { string peer_id = 1; repeated string addrs = 2; + // Present on service provider records that opted into external ingress. + ServiceIngress ingress = 3; } // RouterInfo is one router as the control plane lists it on /info. @@ -307,6 +340,10 @@ message RouterInfo { // routers with the most room; connection_limit of zero means unknown. int32 connections = 4; int32 connection_limit = 5; + // Base HTTPS/HTTP URL where this router serves external ingress, without a + // trailing slash (e.g. "https://bananas.sam-mesh.dev/ingress"). Empty when + // external ingress is disabled on this router. + string ingress_url = 6; } message RouterLeaseRequest { @@ -332,6 +369,8 @@ message RouterLeaseRequest { // inbound connections and the number it accepts. int32 connections = 9; int32 connection_limit = 10; + // Optional base URL where this router serves external HTTP ingress. + string ingress_url = 11; } message RouterLeaseResponse { @@ -359,6 +398,20 @@ message PolicyRole { // An egress destination needs no entry: served_by grants it to the // nodes it selects. repeated string allowed_publish = 7; + // Inbound services a member with this role may expose on router HTTP + // ingress, together with the permitted IngressAuthModes. Empty denies all + // external ingress exposure for this role. + repeated IngressGrant allowed_ingress = 8; +} + +// IngressGrant permits a role to expose matching inbound services on router +// HTTP ingress under the listed authentication modes. +message IngressGrant { + // Inbound service patterns ("a2a://*", "mcp://calc", "*"). Egress + // destinations ("egress://...") cannot be exposed as ingress. + repeated string services = 1; + // Permitted auth modes. Empty grants ONLY INGRESS_AUTH_MODE_EDGE. + repeated IngressAuthMode allowed_auth_modes = 2; } // HTTPGrant narrows one allowed_services entry to HTTP methods and paths. @@ -767,6 +820,7 @@ message RouterLease { google.protobuf.Timestamp expire_time = 4; repeated string connected_peers = 5; int32 dht_size = 6; + string ingress_url = 7; } // NodeServices is the services one node last reported (see diff --git a/api/datalog.go b/api/datalog.go index d2728df6..5cdb6d4d 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -187,6 +187,24 @@ const ( // Added by the verifier, never carried in a token. FactPublish = "publish" + // The granted_ingress_* facts are the granted_publish_* counterparts for + // external router HTTP ingress (PolicyRole.allowed_ingress), parameterized + // by the effective auth mode ("edge" or "passthrough") as their final term. + // A router verifies them via IngressPolicies over an + // ingress($type, $name, $mode) fact before accepting or serving an + // external-ingress directory record. + FactGrantedIngressAllTypes = "granted_ingress_all_types" + FactGrantedIngressAll = "granted_ingress_all" + FactGrantedIngressSuffix = "granted_ingress_suffix" + FactGrantedIngressPrefix = "granted_ingress_prefix" + FactGrantedIngressExact = "granted_ingress_exact" + FactGrantedIngressSet = "granted_ingress_set" + + // FactIngress is the external ingress exposure a verifier asks about: + // ingress("a2a", "assistant", "edge"). Added by the verifier, never carried + // in a token. + FactIngress = "ingress" + // FactGrantedTargetAllTypes allows target access to all network targets (unrestricted). // Contains: biscuit.Bool(true) (marker fact) FactGrantedTargetAllTypes = "granted_target_all_types" @@ -448,6 +466,12 @@ var ( // caller before it sends a request to a provider it found by name. PublishPolicies []biscuit.Policy + // IngressPolicies decide whether a member may expose an inbound service on + // router HTTP ingress under a given auth mode: over its own credential and + // an ingress($type, $name, $mode) fact, one allows when a granted_ingress_* + // fact covers both the service and the mode. + IngressPolicies []biscuit.Policy + // BaselineSources is the Datalog text every variable above is parsed from. // The SDKs embed this text (see hack/gen-sdk-datalog) so that a provider // written in another language evaluates the same authorizer as agentmesh-node. @@ -461,6 +485,8 @@ type DatalogSources struct { Policies []string `json:"policies"` // PublishPolicies decide whether a member may serve a service (PublishPolicies). PublishPolicies []string `json:"publish_policies"` + // IngressPolicies decide whether a member may expose a service on router HTTP ingress (IngressPolicies). + IngressPolicies []string `json:"ingress_policies"` // Rules derive allow_network_target from target grants (BaselineRules). Rules []string `json:"rules"` // HTTPRules derive service grants from narrowed grants (BaselineHTTPRules). @@ -531,6 +557,24 @@ func init() { PublishPolicies = append(PublishPolicies, p) } + // Ingress policies: decide whether a member may expose an inbound service on + // router HTTP ingress under $mode ("edge" or "passthrough"). + BaselineSources.IngressPolicies = []string{ + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($type, $name, $mode)`, FactIngress, FactGrantedIngressExact), + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($type, $set, $mode), $set.contains($name)`, FactIngress, FactGrantedIngressSet), + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($type, $prefix, $mode), $name.starts_with($prefix)`, FactIngress, FactGrantedIngressPrefix), + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($type, $suffix, $mode), $name.ends_with($suffix)`, FactIngress, FactGrantedIngressSuffix), + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($type, $mode)`, FactIngress, FactGrantedIngressAll), + fmt.Sprintf(`allow if %s($type, $name, $mode), %s($mode)`, FactIngress, FactGrantedIngressAllTypes), + } + for i, pStr := range BaselineSources.IngressPolicies { + p, err := parser.FromStringPolicy(pStr) + if err != nil { + panic(fmt.Sprintf("failed to parse ingress policy %d: %v", i, err)) + } + IngressPolicies = append(IngressPolicies, p) + } + // 2. Target Evaluation Rules // These rules satisfy the check if allow_network_target($fact, $val) injected by the control plane. BaselineSources.Rules = []string{ @@ -838,6 +882,87 @@ func PublishFact(svcType, svcName string) biscuit.Fact { return biscuit.Fact{Predicate: biscuit.Predicate{Name: FactPublish, IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName)}}} } +// ingressFactNames maps each granted_service_* fact to its granted_ingress_* +// counterpart. +var ingressFactNames = map[string]string{ + FactGrantedServiceAllTypes: FactGrantedIngressAllTypes, + FactGrantedServiceAll: FactGrantedIngressAll, + FactGrantedServiceSuffix: FactGrantedIngressSuffix, + FactGrantedServicePrefix: FactGrantedIngressPrefix, + FactGrantedServiceExact: FactGrantedIngressExact, + FactGrantedServiceSet: FactGrantedIngressSet, +} + +// BuildIngressDatalogFacts translates a role's allowed_ingress grants into +// granted_ingress_* facts, grouping service patterns by effective auth mode +// ("edge" when allowed_auth_modes is empty, otherwise each listed mode). +func BuildIngressDatalogFacts(grants []*IngressGrant) []biscuit.Fact { + if len(grants) == 0 { + return nil + } + byMode := make(map[string][]string) + var modes []string + addMode := func(m string, services []string) { + if len(services) == 0 { + return + } + if _, ok := byMode[m]; !ok { + modes = append(modes, m) + } + byMode[m] = append(byMode[m], services...) + } + for _, g := range grants { + if g == nil || len(g.Services) == 0 { + continue + } + if len(g.AllowedAuthModes) == 0 { + addMode(IngressAuthModeStringEdge, g.Services) + continue + } + for _, m := range g.AllowedAuthModes { + modeStr, err := IngressAuthModeToString(m) + if err != nil { + continue + } + addMode(modeStr, g.Services) + } + } + sort.Strings(modes) + var out []biscuit.Fact + for _, modeStr := range modes { + modeTerm := biscuit.String(modeStr) + svcFacts := BuildServiceDatalogFacts(byMode[modeStr]) + for _, f := range svcFacts { + name := ingressFactNames[f.Name] + if f.Name == FactGrantedServiceAllTypes { + out = append(out, biscuit.Fact{Predicate: biscuit.Predicate{ + Name: name, + IDs: []biscuit.Term{modeTerm}, + }}) + continue + } + ids := make([]biscuit.Term, 0, len(f.IDs)+1) + ids = append(ids, f.IDs...) + ids = append(ids, modeTerm) + out = append(out, biscuit.Fact{Predicate: biscuit.Predicate{ + Name: name, + IDs: ids, + }}) + } + } + return out +} + +// IngressFact is the fact a verifier adds to ask whether a credential grants +// exposing a service on router HTTP ingress under mode ("edge" or +// "passthrough"): ingress("", "", ""). +func IngressFact(svcType, svcName, mode string) biscuit.Fact { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactIngress, + IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName), biscuit.String(mode)}, + }} +} + // BuildServiceDatalogFacts translates a list of service patterns into a minimal set of Datalog facts. // Exact-match entries are grouped by service type into a single granted_service_set fact each, so // token/world fact counts stay flat regardless of how many exact services a role grants. Wildcard, diff --git a/api/ingress.go b/api/ingress.go new file mode 100644 index 00000000..84bb4451 --- /dev/null +++ b/api/ingress.go @@ -0,0 +1,283 @@ +// 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 api + +import ( + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/libp2p/go-libp2p/core/peer" +) + +const ( + // IngressAuthModeStringEdge is the canonical string form of INGRESS_AUTH_MODE_EDGE. + IngressAuthModeStringEdge = "edge" + + // IngressAuthModeStringPassthrough is the canonical string form of INGRESS_AUTH_MODE_PASSTHROUGH. + IngressAuthModeStringPassthrough = "passthrough" + + // MaxIngressAllowEntries bounds the number of caller principal patterns a + // single ServiceIngress.allow list may carry in directory announcements. + MaxIngressAllowEntries = 64 +) + +// IsInboundServiceType reports whether t is an inbound service type that a +// node can expose on external router HTTP ingress (a2a, mcp, or inference). +func IsInboundServiceType(t ServiceType) bool { + switch t { + case ServiceType_SERVICE_TYPE_A2A, ServiceType_SERVICE_TYPE_MCP, ServiceType_SERVICE_TYPE_INFERENCE: + return true + default: + return false + } +} + +// EffectiveIngressAuthMode resolves UNSPECIFIED to the safe zero-trust default +// (INGRESS_AUTH_MODE_EDGE). +func EffectiveIngressAuthMode(ing *ServiceIngress) IngressAuthMode { + if ing == nil || ing.AuthMode == IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED { + return IngressAuthMode_INGRESS_AUTH_MODE_EDGE + } + return ing.AuthMode +} + +// IngressAuthModeToString converts a resolved IngressAuthMode enum to its +// canonical Datalog and YAML string ("edge" or "passthrough"). +func IngressAuthModeToString(m IngressAuthMode) (string, error) { + switch m { + case IngressAuthMode_INGRESS_AUTH_MODE_EDGE: + return IngressAuthModeStringEdge, nil + case IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH: + return IngressAuthModeStringPassthrough, nil + default: + return "", fmt.Errorf("invalid or unspecified ingress auth mode: %v", m) + } +} + +// ParseIngressAuthMode parses a YAML/config string into an IngressAuthMode. +// An empty string defaults to INGRESS_AUTH_MODE_EDGE. +func ParseIngressAuthMode(s string) (IngressAuthMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", IngressAuthModeStringEdge: + return IngressAuthMode_INGRESS_AUTH_MODE_EDGE, nil + case IngressAuthModeStringPassthrough: + return IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, nil + default: + return IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED, fmt.Errorf("invalid ingress auth_mode %q (want %q or %q)", s, IngressAuthModeStringEdge, IngressAuthModeStringPassthrough) + } +} + +// ServiceIngressFromConfig converts a YAML IngressConfig into a validated +// protobuf ServiceIngress message. This is the boundary where operator text +// enters the mesh, so a "node:" allow entry is rewritten to the +// canonical peer ID string here: the record a router stores and replicates +// never carries the form the operator happened to type. +func ServiceIngressFromConfig(svcType ServiceType, cfg *IngressConfig) (*ServiceIngress, error) { + if cfg == nil { + return nil, nil + } + mode, err := ParseIngressAuthMode(cfg.AuthMode) + if err != nil { + return nil, err + } + ing := &ServiceIngress{ + AuthMode: mode, + Allow: slices.Clone(cfg.Allow), + } + if err := ValidateServiceIngress(svcType, ing); err != nil { + return nil, err + } + for i, member := range ing.Allow { + if prefix, val, ok := strings.Cut(member, ":"); ok && prefix == FactNode { + // Validated above, so Decode cannot fail here. + if id, err := peer.Decode(val); err == nil { + ing.Allow[i] = FactNode + ":" + id.String() + } + } + } + return ing, nil +} + +// ValidateServiceIngress validates a ServiceIngress declaration on a service +// of type svcType. A nil ServiceIngress is always valid (internal-only). +func ValidateServiceIngress(svcType ServiceType, ing *ServiceIngress) error { + if ing == nil { + return nil + } + if !IsInboundServiceType(svcType) { + return fmt.Errorf("ingress is only supported for inbound service types (a2a, mcp, inference), got %v", svcType) + } + return ValidatePeerRecordIngress(ing) +} + +// ValidatePeerRecordIngress validates the mode and allow-list contents of a +// ServiceIngress message independent of service type (for example when a +// router validates a PeerRecord received during directory lookup). +func ValidatePeerRecordIngress(ing *ServiceIngress) error { + if ing == nil { + return nil + } + mode := EffectiveIngressAuthMode(ing) + if _, err := IngressAuthModeToString(mode); err != nil { + return err + } + if mode == IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH && len(ing.Allow) > 0 { + return fmt.Errorf("ingress.allow cannot be set when auth_mode is %q: caller identity is not verified at the edge", IngressAuthModeStringPassthrough) + } + if len(ing.Allow) > MaxIngressAllowEntries { + return fmt.Errorf("ingress.allow has %d entries, max is %d", len(ing.Allow), MaxIngressAllowEntries) + } + for _, member := range ing.Allow { + if err := ValidateBindingMember(member, "ingress.allow"); err != nil { + return err + } + if prefix, val, ok := strings.Cut(member, ":"); ok && prefix == FactNode { + if _, err := peer.Decode(val); err != nil { + return fmt.Errorf("ingress.allow entry %q has invalid peer ID: %w", member, err) + } + } + } + return nil +} + +// SanitizeExternalIngressHeaders removes ambient cookies, spoofable X-Mesh-* +// and X-Peer-Id attribution headers, and (when stripAuthorization is true) the +// external caller's Authorization header before forwarding an external ingress +// request across the router-to-node hop or into a local backend service. +func SanitizeExternalIngressHeaders(h http.Header, stripAuthorization bool) { + for name := range h { + lower := strings.ToLower(name) + if lower == "cookie" || + (stripAuthorization && lower == "authorization") || + strings.HasPrefix(lower, "x-mesh-") || + strings.EqualFold(name, HeaderPeerID) { + delete(h, name) + } + } +} + +// ValidateIngressGrant checks one PolicyRole.allowed_ingress entry. +func ValidateIngressGrant(g *IngressGrant, roleName string) error { + if g == nil || len(g.Services) == 0 { + return fmt.Errorf("allowed_ingress grant in role %q must list at least one service", roleName) + } + for _, svc := range g.Services { + if err := ValidateServiceFormat(svc); err != nil { + return fmt.Errorf("invalid service %q in allowed_ingress for role %q: %w", svc, roleName, err) + } + if svc == "*" { + continue + } + svcTypeStr, _ := ParseServiceTarget(svc) + svcType, err := ParseServiceType(svcTypeStr) + if err != nil || !IsInboundServiceType(svcType) { + return fmt.Errorf("service %q in allowed_ingress for role %q must target an inbound service type (a2a, mcp, inference)", svc, roleName) + } + } + for _, m := range g.AllowedAuthModes { + if _, err := IngressAuthModeToString(m); err != nil { + return fmt.Errorf("invalid allowed_auth_modes value %v in allowed_ingress for role %q: %w", m, roleName, err) + } + } + return nil +} + +// ValidateIngressGrants checks all PolicyRole.allowed_ingress entries on a role. +func ValidateIngressGrants(grants []*IngressGrant, roleName string) error { + for _, g := range grants { + if err := ValidateIngressGrant(g, roleName); err != nil { + return err + } + } + return nil +} + +// ValidateIngressURL validates and normalizes a router's public ingress base +// URL (e.g. "https://bananas.sam-mesh.dev/ingress"). An empty string disables +// ingress URL advertisement and returns "", nil. +func ValidateIngressURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + u, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("invalid ingress_url %q: %w", raw, err) + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("ingress_url %q must use http or https scheme", raw) + } + if u.Host == "" { + return "", fmt.Errorf("ingress_url %q must include a host", raw) + } + if u.User != nil { + return "", fmt.Errorf("ingress_url %q must not contain userinfo credentials", raw) + } + if u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("ingress_url %q must not contain query parameters or fragments", raw) + } + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = "" + return u.String(), nil +} + +// MatchIngressAllow reports whether a verified caller's attested identity +// claims (keyed by Datalog fact name: "user", "email", "group", "idp_role", +// "node") satisfy ServiceIngress.allow. +// +// When allow is empty, MatchIngressAllow returns true so mesh Datalog policy +// alone governs access. When allow is non-empty, the caller must have at least +// one attested claim matching at least one entry in allow. +func MatchIngressAllow(allow []string, claims map[string][]string) bool { + if len(allow) == 0 { + return true + } + hasAnyIdentity := false + for _, k := range BindingMemberPrefixes() { + if len(claims[k]) > 0 { + hasAnyIdentity = true + break + } + } + if !hasAnyIdentity { + return false + } + for _, entry := range allow { + if entry == SystemAuthenticated { + return true + } + prefix, pattern, ok := strings.Cut(entry, ":") + if !ok { + continue + } + if prefix == FactNode { + id, err := peer.Decode(pattern) + if err != nil { + continue + } + if slices.Contains(claims[FactNode], id.String()) { + return true + } + continue + } + if MatchBindingMemberValue(claims[prefix], pattern) { + return true + } + } + return false +} diff --git a/api/ingress_test.go b/api/ingress_test.go new file mode 100644 index 00000000..14472b38 --- /dev/null +++ b/api/ingress_test.go @@ -0,0 +1,341 @@ +// 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 api + +import ( + "crypto/ed25519" + "crypto/rand" + "slices" + "testing" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func testPeerID(t *testing.T) peer.ID { + t.Helper() + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatalf("GenerateEd25519Key: %v", err) + } + id, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + return id +} + +func TestValidateServiceIngress(t *testing.T) { + pid := testPeerID(t) + cases := []struct { + name string + svcType ServiceType + ing *ServiceIngress + wantErr bool + }{ + { + name: "nil ingress allowed on any type", + svcType: ServiceType_SERVICE_TYPE_EGRESS, + ing: nil, + }, + { + name: "default edge mode on a2a", + svcType: ServiceType_SERVICE_TYPE_A2A, + ing: &ServiceIngress{}, + }, + { + name: "edge mode with email and domain wildcard allow", + svcType: ServiceType_SERVICE_TYPE_MCP, + ing: &ServiceIngress{ + AuthMode: IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com", "email:*@google.com", "node:" + pid.String()}, + }, + }, + { + name: "passthrough without allow", + svcType: ServiceType_SERVICE_TYPE_A2A, + ing: &ServiceIngress{AuthMode: IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + }, + { + name: "passthrough with allow rejected", + svcType: ServiceType_SERVICE_TYPE_A2A, + ing: &ServiceIngress{ + AuthMode: IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + Allow: []string{"email:alice@example.com"}, + }, + wantErr: true, + }, + { + name: "egress rejected", + svcType: ServiceType_SERVICE_TYPE_EGRESS, + ing: &ServiceIngress{}, + wantErr: true, + }, + { + name: "invalid allow entry rejected", + svcType: ServiceType_SERVICE_TYPE_INFERENCE, + ing: &ServiceIngress{ + Allow: []string{"email:*"}, + }, + wantErr: true, + }, + { + name: "invalid node peer id in allow rejected", + svcType: ServiceType_SERVICE_TYPE_A2A, + ing: &ServiceIngress{ + Allow: []string{"node:not-a-peer-id"}, + }, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateServiceIngress(tc.svcType, tc.ing) + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateServiceIngress() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestServiceIngressFromConfig(t *testing.T) { + got, err := ServiceIngressFromConfig(ServiceType_SERVICE_TYPE_A2A, nil) + if err != nil || got != nil { + t.Fatalf("nil config = (%v, %v), want (nil, nil)", got, err) + } + + got, err = ServiceIngressFromConfig(ServiceType_SERVICE_TYPE_A2A, &IngressConfig{ + Allow: []string{"email:yaroslav@google.com"}, + }) + if err != nil { + t.Fatalf("ServiceIngressFromConfig default mode: %v", err) + } + if EffectiveIngressAuthMode(got) != IngressAuthMode_INGRESS_AUTH_MODE_EDGE { + t.Fatalf("EffectiveIngressAuthMode = %v, want EDGE", EffectiveIngressAuthMode(got)) + } + + if _, err := ServiceIngressFromConfig(ServiceType_SERVICE_TYPE_A2A, &IngressConfig{AuthMode: "bogus"}); err == nil { + t.Fatal("expected error for invalid auth_mode") + } + + // A node: entry is stored in canonical form whatever the operator typed: + // the CIDv1 spelling of a peer ID decodes to the same ID as its base58 + // spelling, and the record carries the latter. + id := testPeerID(t) + cidForm := peer.ToCid(id).String() + if cidForm == id.String() { + t.Fatalf("test needs a non-canonical spelling; ToCid gave %q", cidForm) + } + got, err = ServiceIngressFromConfig(ServiceType_SERVICE_TYPE_A2A, &IngressConfig{ + Allow: []string{"node:" + cidForm, "email:alice@example.com"}, + }) + if err != nil { + t.Fatalf("ServiceIngressFromConfig with CID peer ID: %v", err) + } + if want := []string{"node:" + id.String(), "email:alice@example.com"}; !slices.Equal(got.Allow, want) { + t.Fatalf("Allow = %v, want canonical %v", got.Allow, want) + } +} + +func TestValidateIngressGrants(t *testing.T) { + cases := []struct { + name string + grants []*IngressGrant + wantErr bool + }{ + { + name: "empty modes defaults to edge", + grants: []*IngressGrant{ + {Services: []string{"a2a://*", "mcp://calc"}}, + }, + }, + { + name: "wildcard and explicit modes", + grants: []*IngressGrant{ + { + Services: []string{"*"}, + AllowedAuthModes: []IngressAuthMode{IngressAuthMode_INGRESS_AUTH_MODE_EDGE, IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + }, + }, + }, + { + name: "empty services rejected", + grants: []*IngressGrant{ + {}, + }, + wantErr: true, + }, + { + name: "egress service rejected", + grants: []*IngressGrant{ + {Services: []string{"egress://api.github.com"}}, + }, + wantErr: true, + }, + { + name: "unspecified auth mode in list rejected", + grants: []*IngressGrant{ + { + Services: []string{"a2a://*"}, + AllowedAuthModes: []IngressAuthMode{IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED}, + }, + }, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateIngressGrants(tc.grants, "test-role") + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateIngressGrants() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestValidateIngressURL(t *testing.T) { + got, err := ValidateIngressURL(" https://bananas.sam-mesh.dev/ingress/ ") + if err != nil { + t.Fatalf("ValidateIngressURL: %v", err) + } + if got != "https://bananas.sam-mesh.dev/ingress" { + t.Fatalf("got %q, want %q", got, "https://bananas.sam-mesh.dev/ingress") + } + + if got, err := ValidateIngressURL(""); err != nil || got != "" { + t.Fatalf("empty URL = (%q, %v), want (\"\", nil)", got, err) + } + + for _, bad := range []string{ + "ftp://example.com/ingress", + "https:///ingress", + "https://user:pass@example.com/ingress", + "https://example.com/ingress?foo=bar", + "https://example.com/ingress#frag", + } { + if _, err := ValidateIngressURL(bad); err == nil { + t.Errorf("ValidateIngressURL(%q) succeeded, want error", bad) + } + } +} + +func TestMatchIngressAllow(t *testing.T) { + pid := testPeerID(t) + claims := map[string][]string{ + FactEmail: {"yaroslav@google.com"}, + FactGroup: {"a2a-team"}, + FactNode: {pid.String()}, + } + + if !MatchIngressAllow(nil, nil) { + t.Fatal("empty allow must return true") + } + if MatchIngressAllow([]string{SystemAuthenticated}, nil) { + t.Fatal("non-empty allow with empty claims must return false") + } + if !MatchIngressAllow([]string{SystemAuthenticated}, claims) { + t.Fatal("SystemAuthenticated must match non-empty identity claims") + } + if !MatchIngressAllow([]string{"email:*@google.com"}, claims) { + t.Fatal("wildcard domain should match yaroslav@google.com") + } + if !MatchIngressAllow([]string{"node:" + pid.String()}, claims) { + t.Fatal("exact node peer ID should match") + } + if MatchIngressAllow([]string{"email:other@example.com", "group:ops"}, claims) { + t.Fatal("non-matching allow entries must return false") + } +} + +func TestIngressDatalogPolicyEvaluation(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + roles := []*PolicyRole{ + { + Name: "laptop-agent", + AllowedIngress: []*IngressGrant{ + { + // Empty AllowedAuthModes -> grants EDGE only. + Services: []string{"a2a://*", "mcp://calc", "mcp://notes"}, + }, + { + Services: []string{"a2a://paired.*"}, + AllowedAuthModes: []IngressAuthMode{IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + }, + }, + }, + } + bindings := []*PolicyBinding{ + {Role: "laptop-agent", Members: []string{"email:dev@example.com"}}, + } + + compiled, warnings := BuildPolicyRules(roles, bindings) + if len(warnings) != 0 { + t.Fatalf("unexpected warnings: %v", warnings) + } + parsedRules, err := ParseDatalogRules(PolicyRuleTexts(compiled)) + if err != nil { + t.Fatalf("ParseDatalogRules: %v", err) + } + + builder := biscuit.NewBuilder(priv) + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactEmail, + IDs: []biscuit.Term{biscuit.String("dev@example.com")}, + }}); err != nil { + t.Fatalf("AddAuthorityFact: %v", err) + } + tok, err := builder.Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + check := func(svcType, svcName, mode string) bool { + auth, err := tok.Authorizer(pub) + if err != nil { + t.Fatalf("Authorizer: %v", err) + } + auth.AddFact(IngressFact(svcType, svcName, mode)) + for _, r := range parsedRules { + auth.AddRule(r) + } + for _, p := range IngressPolicies { + auth.AddPolicy(p) + } + return auth.Authorize() == nil + } + + if !check("a2a", "assistant", IngressAuthModeStringEdge) { + t.Error("expected a2a://assistant edge to be allowed") + } + if check("a2a", "assistant", IngressAuthModeStringPassthrough) { + t.Error("expected a2a://assistant passthrough to be denied without explicit grant") + } + if !check("a2a", "paired.coder", IngressAuthModeStringPassthrough) { + t.Error("expected a2a://paired.coder passthrough to be allowed") + } + if !check("mcp", "calc", IngressAuthModeStringEdge) { + t.Error("expected mcp://calc set grant edge to be allowed") + } + if check("inference", "vllm", IngressAuthModeStringEdge) { + t.Error("expected ungranted inference://vllm edge to be denied") + } +} diff --git a/api/mcp.go b/api/mcp.go new file mode 100644 index 00000000..504de608 --- /dev/null +++ b/api/mcp.go @@ -0,0 +1,96 @@ +// 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 api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// DefaultMaxMCPBodyInspectBytes is the default maximum HTTP request body size +// buffered when inspecting an MCP JSON-RPC 2.0 payload for tool-level policy +// evaluation (4 MiB). +const DefaultMaxMCPBodyInspectBytes int64 = 4 << 20 + +// InspectJSONRPCMCPBody inspects a JSON-RPC 2.0 request body for MCP methods +// and extracts the bare tool name when present, or sets allowStreamInit for +// session handshake, capability discovery, and protocol notification methods +// that precede a tool call. +func InspectJSONRPCMCPBody(body []byte) (mcpTool string, allowStreamInit bool) { + if len(bytes.TrimSpace(body)) == 0 { + return "", false + } + var rpc struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(body, &rpc); err != nil { + return "", false + } + switch rpc.Method { + case "initialize", "notifications/initialized", "server/discover", "ping", + "tools/list", "resources/list", "prompts/list", "notifications/cancelled": + return "", true + case "tools/call": + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(rpc.Params, ¶ms); err != nil { + return "", false + } + rawTool := strings.TrimSpace(params.Name) + if rawTool == "" { + return "", false + } + if _, stripped, err := SplitToolName(rawTool); err == nil { + return stripped, false + } + return rawTool, false + default: + return "", false + } +} + +// InspectMCPHTTPRequestBody buffers and restores r.Body up to maxBytes +// (defaulting to DefaultMaxMCPBodyInspectBytes when maxBytes <= 0) and extracts +// the MCP tool name and stream initialization flag. +func InspectMCPHTTPRequestBody(r *http.Request, maxBytes int64) (mcpTool string, allowStreamInit bool, err error) { + if r == nil { + return "", false, nil + } + if strings.EqualFold(r.Method, http.MethodGet) || strings.EqualFold(r.Method, http.MethodDelete) { + return "", true, nil + } + if r.Body == nil || !strings.EqualFold(r.Method, http.MethodPost) { + return "", false, nil + } + if maxBytes <= 0 { + maxBytes = DefaultMaxMCPBodyInspectBytes + } + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxBytes+1)) + if err != nil { + return "", false, fmt.Errorf("failed to read MCP request body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + if int64(len(bodyBytes)) > maxBytes { + return "", false, fmt.Errorf("MCP request body exceeds maximum inspection size (%d bytes)", maxBytes) + } + tool, allowInit := InspectJSONRPCMCPBody(bodyBytes) + return tool, allowInit, nil +} diff --git a/api/mcp_test.go b/api/mcp_test.go new file mode 100644 index 00000000..d9fad8e3 --- /dev/null +++ b/api/mcp_test.go @@ -0,0 +1,159 @@ +// 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 api + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInspectJSONRPCMCPBody(t *testing.T) { + cases := []struct { + name string + body string + wantTool string + wantAllow bool + }{ + { + name: "tools/call bare tool", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file"}}`, + wantTool: "read_file", + wantAllow: false, + }, + { + name: "tools/call namespaced tool", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp://github/get_pr"}}`, + wantTool: "get_pr", + wantAllow: false, + }, + { + name: "initialize", + body: `{"jsonrpc":"2.0","id":1,"method":"initialize"}`, + wantTool: "", + wantAllow: true, + }, + { + name: "notifications/initialized", + body: `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + wantTool: "", + wantAllow: true, + }, + { + name: "server/discover", + body: `{"jsonrpc":"2.0","id":1,"method":"server/discover"}`, + wantTool: "", + wantAllow: true, + }, + { + name: "tools/list", + body: `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`, + wantTool: "", + wantAllow: true, + }, + { + name: "resources/read is not auth-free", + body: `{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///secret"}}`, + wantTool: "", + wantAllow: false, + }, + { + name: "empty body", + body: " ", + wantTool: "", + wantAllow: false, + }, + { + name: "invalid json", + body: "{not-json", + wantTool: "", + wantAllow: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotTool, gotAllow := InspectJSONRPCMCPBody([]byte(tc.body)) + if gotTool != tc.wantTool || gotAllow != tc.wantAllow { + t.Fatalf("InspectJSONRPCMCPBody(%q) = (%q, %v), want (%q, %v)", tc.body, gotTool, gotAllow, tc.wantTool, tc.wantAllow) + } + }) + } +} + +func TestInspectMCPHTTPRequestBody(t *testing.T) { + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mcp://git/status"}}` + req := httptest.NewRequest(http.MethodPost, "/mcp/git", strings.NewReader(body)) + tool, allowInit, err := InspectMCPHTTPRequestBody(req, 1024) + if err != nil { + t.Fatalf("InspectMCPHTTPRequestBody: %v", err) + } + if tool != "status" || allowInit { + t.Fatalf("got (%q, %v), want (\"status\", false)", tool, allowInit) + } + restored, err := io.ReadAll(req.Body) + if err != nil || string(restored) != body { + t.Fatalf("restored body = %q (err=%v), want %q", string(restored), err, body) + } + + getReq := httptest.NewRequest(http.MethodGet, "/mcp/git", nil) + if tool, allowInit, err := InspectMCPHTTPRequestBody(getReq, 1024); err != nil || tool != "" || !allowInit { + t.Fatalf("GET SSE stream: got (%q, %v, %v), want (\"\", true, nil)", tool, allowInit, err) + } + + oversized := httptest.NewRequest(http.MethodPost, "/mcp/git", strings.NewReader(strings.Repeat("a", 65))) + if _, _, err := InspectMCPHTTPRequestBody(oversized, 64); err == nil { + t.Fatal("oversized MCP body succeeded, want error") + } +} + +func TestSanitizeExternalIngressHeaders(t *testing.T) { + edgeHdr := http.Header{ + "Authorization": {"Bearer caller-token"}, + "Cookie": {"sid=123"}, + "X-Mesh-Principal": {"spoofed@evil.example"}, + "X-Mesh-Custom": {"evil"}, + "X-Peer-Id": {"spoofed-peer"}, + "Content-Type": {"application/json"}, + "X-Hub-Signature": {"sha256=ok"}, + } + SanitizeExternalIngressHeaders(edgeHdr, true) + for _, mustDrop := range []string{"Authorization", "Cookie", "X-Mesh-Principal", "X-Mesh-Custom", "X-Peer-Id"} { + if got := edgeHdr.Get(mustDrop); got != "" { + t.Errorf("stripAuth=true kept %s=%q", mustDrop, got) + } + } + if edgeHdr.Get("Content-Type") != "application/json" || edgeHdr.Get("X-Hub-Signature") != "sha256=ok" { + t.Errorf("stripAuth=true dropped benign headers: %v", edgeHdr) + } + + passHdr := http.Header{ + "Authorization": {"Bearer pair-token"}, + "Cookie": {"sid=123"}, + "X-Mesh-Principal": {"spoofed@evil.example"}, + "X-Peer-Id": {"spoofed-peer"}, + } + SanitizeExternalIngressHeaders(passHdr, false) + if got := passHdr.Get("Authorization"); got != "Bearer pair-token" { + t.Errorf("stripAuth=false Authorization = %q, want Bearer pair-token", got) + } + for _, mustDrop := range []string{"Cookie", "X-Mesh-Principal", "X-Peer-Id"} { + if got := passHdr.Get(mustDrop); got != "" { + t.Errorf("stripAuth=false kept %s=%q", mustDrop, got) + } + } +} diff --git a/api/network.go b/api/network.go index ffc1d612..40177dd4 100644 --- a/api/network.go +++ b/api/network.go @@ -42,6 +42,10 @@ const ( // it will no longer hold it; the stream carries one RouterGoAway. GoAwayProtocolID protocol.ID = "/mesh/goaway/1.0.0" + // IngressProtocolID is the libp2p protocol identifier on which an enrolled + // router forwards external HTTP ingress requests to a serving node. + IngressProtocolID protocol.ID = "/mesh/ingress/1.0.0" + // DHTProtocolPrefix namespaces the mesh's Kademlia DHT away from the // public IPFS one; the protocol every member and router registers for // it is DHTProtocolID. @@ -303,6 +307,17 @@ const ( HeaderMeshTask = "X-Mesh-Task" HeaderMeshTaskID = "X-Mesh-Task-Id" HeaderMeshTargetService = "X-Mesh-Target-Service" + + // HeaderMeshIngress marks a request delivered over router external ingress + // ("edge" or "passthrough"). Inbound values from external callers are + // stripped at both the router and the serving node. + HeaderMeshIngress = "X-Mesh-Ingress" + + // HeaderMeshIngressCallerBiscuit carries the base64-encoded caller Biscuit + // from an enrolled router to the serving node over IngressProtocolID so the + // node can re-verify Datalog policy, local attenuation, TAR, and allow-lists + // end to end. Stripped before forwarding to the backend service. + HeaderMeshIngressCallerBiscuit = "X-Mesh-Ingress-Caller-Biscuit" ) // ============================================================================ diff --git a/api/policy.go b/api/policy.go index 2b82dc6d..6f998674 100644 --- a/api/policy.go +++ b/api/policy.go @@ -19,8 +19,17 @@ const ( SystemAuthenticated = "mesh:system:authenticated" ) +type IngressConfig struct { + // AuthMode is "edge" (default when empty) or "passthrough". + AuthMode string `yaml:"auth_mode,omitempty"` + // Allow optionally narrows which verified callers may invoke this service + // over edge ingress, using PolicyBinding.members syntax (e.g. + // "email:alice@example.com", "email:*@example.com", "group:eng"). + Allow []string `yaml:"allow,omitempty"` +} + type ServiceConfig struct { - Type string `yaml:"type"` // e.g., "mcp", "inference" + Type string `yaml:"type"` // e.g., "mcp", "inference", "a2a" Name string `yaml:"name"` Description string `yaml:"description"` TargetURL string `yaml:"target_url,omitempty"` @@ -32,6 +41,7 @@ type ServiceConfig struct { TargetAuthPath string `yaml:"target_auth_path,omitempty"` Command []string `yaml:"command,omitempty"` Env map[string]string `yaml:"env,omitempty"` + Ingress *IngressConfig `yaml:"ingress,omitempty"` } // NodeConfig defines the optional attenuation rules and static services for a specific Agent Mesh Node. diff --git a/api/policy_rules.go b/api/policy_rules.go index 44069948..40dffe94 100644 --- a/api/policy_rules.go +++ b/api/policy_rules.go @@ -228,6 +228,9 @@ func BuildPolicyRules(roles []*PolicyRole, bindings []*PolicyBinding) (rules []P for _, fact := range BuildPublishDatalogFacts(role.AllowedPublish) { add(fact.Predicate, fromRole) } + for _, fact := range BuildIngressDatalogFacts(role.AllowedIngress) { + add(fact.Predicate, fromRole) + } hasUnrestricted := false var nonWildcardTargets []string diff --git a/charts/agentmesh/README.md b/charts/agentmesh/README.md index 3fd0e346..a840ecac 100644 --- a/charts/agentmesh/README.md +++ b/charts/agentmesh/README.md @@ -86,12 +86,12 @@ with no default, because the right GatewayClass is provider-specific (`cloud-provider-kind` in kind, `gke-l7-global-external-managed` on GKE, `istio`, `envoy-gateway`, …). -The route exposes only the control plane's enrollment and runtime surface (`/register`, +The route exposes the control plane's enrollment and runtime surface (`/register`, `/info`, `/keys`, `/routers/lease`, `/policies`, `/egress`, `/revocations`, `/token/exchange`, `/sts/token`, `/enroll`, `/enroll/status`, `/refresh`, -`/nodes/catalog`, `/.well-known/*`, `/jwks`, `/oauth/authorize`, `/oauth/token`) and the console under `gateway.consolePath`; everything else, +`/nodes/catalog`, `/.well-known/*`, `/jwks`, `/oauth/authorize`, `/oauth/token`), routes external HTTP ingress (`/ingress` and `/ingress/`) directly to the `agentmesh-router` Service (`router.ingressPort`, default `8090`, controlled by `gateway.ingressRoute: true`), and serves the console under `gateway.consolePath`; everything else, including `/admin` and `/user`, is unrouted. `gateway.adminRoute: true` -additionally routes `/admin` — a dev convenience, leave it off in production. +additionally routes `/admin` — a dev convenience, leave it off in production. Set `router.ingressUrl` (e.g. `https://mesh.example.com/ingress`) and `router.publicControlPlaneUrl` (e.g. `https://mesh.example.com`) so routers advertise the public ingress base URL in their lease (`GET /info`) and in RFC 9728 / A2A OAuth metadata. For the console, the bare prefix (`/console`) is answered with a 302 to `/console/`, and a `URLRewrite` filter strips the prefix before the request diff --git a/charts/agentmesh/templates/bootstrap-job.yaml b/charts/agentmesh/templates/bootstrap-job.yaml index 4dd49596..d4c87e05 100644 --- a/charts/agentmesh/templates/bootstrap-job.yaml +++ b/charts/agentmesh/templates/bootstrap-job.yaml @@ -122,7 +122,7 @@ spec: "roles": [ {"name": "mesh-admin", "allowed_services": ["*"], "allowed_targets": ["*"]}, {"name": "mesh:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, - {"name": "mesh:role:node", "allowed_services": {{ toJson .Values.bootstrap.nodeServices }}, "allowed_publish": {{ toJson .Values.bootstrap.nodePublish }}, "allowed_targets": ["*"], "allowed_labels": {{ toJson .Values.bootstrap.nodeLabels }}} + {"name": "mesh:role:node", "allowed_services": {{ toJson .Values.bootstrap.nodeServices }}, "allowed_publish": {{ toJson .Values.bootstrap.nodePublish }}, "allowed_targets": ["*"], "allowed_labels": {{ toJson .Values.bootstrap.nodeLabels }}, "allowed_ingress": {{ toJson .Values.bootstrap.nodeIngress }}} ], "bindings": {{ toJson $bindings }} }' \ diff --git a/charts/agentmesh/templates/gateway.yaml b/charts/agentmesh/templates/gateway.yaml index 429f9a22..277c2cbf 100644 --- a/charts/agentmesh/templates/gateway.yaml +++ b/charts/agentmesh/templates/gateway.yaml @@ -129,4 +129,16 @@ spec: - name: {{ $fullName }}-console port: {{ .Values.console.service.port }} {{- end }} + {{- if and .Values.gateway.ingressRoute .Values.router.enabled .Values.router.ingressPort }} + - matches: + - path: + type: Exact + value: /ingress + - path: + type: PathPrefix + value: /ingress/ + backendRefs: + - name: {{ $fullName }}-router + port: {{ .Values.router.ingressPort }} + {{- end }} {{- end }} diff --git a/charts/agentmesh/templates/router-service.yaml b/charts/agentmesh/templates/router-service.yaml index 45b2bef3..e36f4ee3 100644 --- a/charts/agentmesh/templates/router-service.yaml +++ b/charts/agentmesh/templates/router-service.yaml @@ -16,6 +16,12 @@ spec: port: {{ .Values.router.service.port }} targetPort: 4501 protocol: UDP + {{- if .Values.router.ingressPort }} + - name: ingress-http + port: {{ .Values.router.ingressPort }} + targetPort: {{ .Values.router.ingressPort }} + protocol: TCP + {{- end }} selector: app: {{ include "agentmesh.fullname" . }}-router {{- end }} diff --git a/charts/agentmesh/templates/router-statefulset.yaml b/charts/agentmesh/templates/router-statefulset.yaml index 2cded719..93c2df2c 100644 --- a/charts/agentmesh/templates/router-statefulset.yaml +++ b/charts/agentmesh/templates/router-statefulset.yaml @@ -89,6 +89,11 @@ spec: - containerPort: {{ .Values.router.metricsPort }} name: metrics protocol: TCP + {{- if .Values.router.ingressPort }} + - containerPort: {{ .Values.router.ingressPort }} + name: ingress-http + protocol: TCP + {{- end }} # /healthz answers as soon as the process is up; /readyz once the # router has enrolled and its libp2p host is online. startupProbe: @@ -125,6 +130,17 @@ spec: - "--listen=/ip4/0.0.0.0/tcp/4501" - "--listen=/ip4/0.0.0.0/udp/4501/quic-v1" - "--metrics-addr=0.0.0.0:{{ .Values.router.metricsPort }}" + {{- if .Values.router.ingressPort }} + - "--ingress-addr=0.0.0.0:{{ .Values.router.ingressPort }}" + # In-cluster plaintext behind the Gateway; TLS terminates at the Gateway listener. + - "--allow-insecure-ingress-http" + {{- if .Values.router.ingressUrl }} + - "--ingress-url={{ .Values.router.ingressUrl }}" + {{- end }} + {{- if .Values.router.publicControlPlaneUrl }} + - "--public-control-plane={{ .Values.router.publicControlPlaneUrl }}" + {{- end }} + {{- end }} {{- if .Values.router.externalAddrs }} {{- range .Values.router.externalAddrs }} - "--external-addr={{ . }}" diff --git a/charts/agentmesh/tests/bootstrap-job_test.yaml b/charts/agentmesh/tests/bootstrap-job_test.yaml index 06474186..0676e993 100644 --- a/charts/agentmesh/tests/bootstrap-job_test.yaml +++ b/charts/agentmesh/tests/bootstrap-job_test.yaml @@ -33,14 +33,14 @@ tests: - exists: path: spec.template.spec.containers[0].resources.limits - - it: node role grants no labels by default (fail closed) + - it: node role grants no labels or ingress by default (fail closed) documentSelector: path: kind value: Job asserts: - matchRegex: path: spec.template.spec.containers[0].args[0] - pattern: '"name": "mesh:role:node".*"allowed_labels": \[\]' + pattern: '"name": "mesh:role:node".*"allowed_labels": \[\], "allowed_ingress": \[\]' - it: router bootstrap token is spent after one use per replica set: diff --git a/charts/agentmesh/tests/gateway_test.yaml b/charts/agentmesh/tests/gateway_test.yaml index 6a3b2216..fcf7a085 100644 --- a/charts/agentmesh/tests/gateway_test.yaml +++ b/charts/agentmesh/tests/gateway_test.yaml @@ -16,7 +16,7 @@ tests: - failedTemplate: errorPattern: gateway.className is required when gateway.enabled is true - - it: routes enrollment plus the console redirect and rewrite by default + - it: routes enrollment, console redirect/rewrite, and router /ingress by default set: gateway.enabled: true gateway.className: x @@ -24,10 +24,10 @@ tests: path: kind value: HTTPRoute asserts: - # enrollment surface + consolePath 302 + consolePath URLRewrite + # enrollment surface + consolePath 302 + consolePath URLRewrite + /ingress -> router - lengthEqual: path: spec.rules - count: 3 + count: 4 # Every path a node calls on its own must be on the allow-list, or the # node's periodic push silently 404s at the gateway. - contains: @@ -66,6 +66,18 @@ tests: path: type: Exact value: /sts/token + - contains: + path: spec.rules[3].matches + content: + path: + type: PathPrefix + value: /ingress/ + - equal: + path: spec.rules[3].backendRefs[0].name + value: agentmesh-router + - equal: + path: spec.rules[3].backendRefs[0].port + value: 8090 - it: empty consolePath leaves the console unrouted set: @@ -78,7 +90,20 @@ tests: asserts: - lengthEqual: path: spec.rules - count: 1 + count: 2 + + - it: ingressRoute false omits the /ingress router rule + set: + gateway.enabled: true + gateway.className: x + gateway.ingressRoute: false + documentSelector: + path: kind + value: HTTPRoute + asserts: + - lengthEqual: + path: spec.rules + count: 3 - it: adminRoute adds the /admin rule set: @@ -91,7 +116,7 @@ tests: asserts: - lengthEqual: path: spec.rules - count: 4 + count: 5 - contains: path: spec.rules[1].matches content: diff --git a/charts/agentmesh/tests/router-statefulset_test.yaml b/charts/agentmesh/tests/router-statefulset_test.yaml index debf6d04..fa8f1962 100644 --- a/charts/agentmesh/tests/router-statefulset_test.yaml +++ b/charts/agentmesh/tests/router-statefulset_test.yaml @@ -199,3 +199,31 @@ tests: - equal: path: spec.template.spec.containers[0].ports[2].containerPort value: 9999 + + - it: binds external HTTP ingress on ingressPort and forwards ingressUrl when configured + set: + router.ingressPort: 8095 + router.ingressUrl: https://mesh.example.com/ingress + router.publicControlPlaneUrl: https://mesh.example.com + documentSelector: + path: kind + value: StatefulSet + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --ingress-addr=0.0.0.0:8095 + - contains: + path: spec.template.spec.containers[0].args + content: --allow-insecure-ingress-http + - contains: + path: spec.template.spec.containers[0].args + content: --ingress-url=https://mesh.example.com/ingress + - contains: + path: spec.template.spec.containers[0].args + content: --public-control-plane=https://mesh.example.com + - equal: + path: spec.template.spec.containers[0].ports[3].name + value: ingress-http + - equal: + path: spec.template.spec.containers[0].ports[3].containerPort + value: 8095 diff --git a/charts/agentmesh/values.yaml b/charts/agentmesh/values.yaml index 3bd102e3..12ecd864 100644 --- a/charts/agentmesh/values.yaml +++ b/charts/agentmesh/values.yaml @@ -2,8 +2,8 @@ global: imageTag: local imagePullPolicy: IfNotPresent -# One Gateway fronting the mesh: the control plane's enrollment surface plus the console -# under gateway.consolePath. +# One Gateway fronting the mesh: the control plane's enrollment surface, external HTTP +# ingress routed directly to routers under /ingress, plus the console under gateway.consolePath. gateway: enabled: false # Required when enabled. No default: the GatewayClass is provider-specific, e.g. @@ -11,6 +11,12 @@ gateway: className: "" # Routes the control plane's /admin API. Dev only. adminRoute: false + # Routes /ingress and /ingress/ directly to the router Service so external HTTP + # callers reach opted-in node services without traversing the control plane. + # Because routers behind an L7 Gateway see the Gateway's internal proxy IP on + # TCP RemoteAddr, attach per-client source-IP rate limiting (e.g. Cloud Armor) + # at the Gateway for unauthenticated routes (AgentCard discovery and passthrough). + ingressRoute: true # External prefix the console is served under. Empty leaves the console unrouted. consolePath: /console listeners: @@ -153,6 +159,16 @@ router: # Plain HTTP port for /metrics, /healthz and /readyz. Unauthenticated, so it # is only ever a containerPort, never a hostPort or Service. metricsPort: 9090 + # Plain HTTP port for external HTTP ingress (/ingress/*), exposed on the router + # ClusterIP Service and routed by the Gateway when gateway.ingressRoute is true. + # Set to 0 to disable external HTTP ingress on routers. + ingressPort: 8090 + # Optional: canonical public base URL advertised by routers in their lease and + # surfaced on GET /info (e.g. https://mesh.example.com/ingress). + ingressUrl: "" + # Optional: public URL of the control plane advertised in RFC 9728 and A2A OAuth + # metadata when --control-plane uses the cluster-internal Service address. + publicControlPlaneUrl: "" # PVC for /data/router.key, so the libp2p peer ID survives rescheduling. storageSize: 1Gi # PVC StorageClass; cluster default when null. @@ -226,6 +242,10 @@ bootstrap: # labels in its agentmesh-node.yaml. Empty means nodes cannot enroll with any label # (fail closed). nodeLabels: [] + # External HTTP ingress ceiling mesh:role:node may expose, as IngressGrant + # objects (e.g. [{"services": ["a2a://*"]}]). Empty means nodes cannot expose + # any service on /ingress (fail closed). + nodeIngress: [] # Scraping of the control-plane and router /metrics endpoints. Both are # cluster-internal and unauthenticated; enable the resource for whichever diff --git a/cmd/agentmesh-one/main.go b/cmd/agentmesh-one/main.go index 2d4d9d02..cf2147b5 100644 --- a/cmd/agentmesh-one/main.go +++ b/cmd/agentmesh-one/main.go @@ -279,6 +279,7 @@ func printBanner(srv *standalone.Server, tun tunnel.Tunnel, secrets bannerSecret fmt.Printf("Tunnel: %s -> http://%s\n", tun.URL(), srv.Addr()) } fmt.Printf("Web Console: %s/console\n", base) + fmt.Printf("Ingress URL: %s\n", srv.IngressURL()) fmt.Printf("Router Peer: %s\n", srv.PeerID()) fmt.Printf("Admin Token: %s\n", adminShown) switch { diff --git a/cmd/agentmesh-router/main.go b/cmd/agentmesh-router/main.go index dc0f0962..5b702fe1 100644 --- a/cmd/agentmesh-router/main.go +++ b/cmd/agentmesh-router/main.go @@ -76,6 +76,12 @@ var ( shutdownLeaseTTL time.Duration authDeadline time.Duration labelFlags []string + ingressAddr string + ingressURL string + ingressTLSCertPath string + ingressTLSKeyPath string + allowInsecureIngressHTTP bool + publicControlPlaneURL string ) var logger = golog.Logger("agentmesh-router-cli") @@ -110,6 +116,12 @@ func main() { RelayMaxCircuits: relayMaxCircuits, ShutdownLeaseTTL: shutdownLeaseTTL, AuthDeadline: authDeadline, + IngressAddr: ingressAddr, + IngressURL: ingressURL, + IngressTLSCertPath: ingressTLSCertPath, + IngressTLSKeyPath: ingressTLSKeyPath, + AllowInsecureIngressHTTP: allowInsecureIngressHTTP, + PublicControlPlaneURL: publicControlPlaneURL, } labels, err := api.ParseLabelFlags(labelFlags) if err != nil { @@ -169,6 +181,12 @@ func main() { rootCmd.Flags().DurationVar(&shutdownLeaseTTL, "shutdown-lease-ttl", router.DefaultShutdownLeaseTTL, "Sent in this router's last lease when it stops: how long it expects to be away, which is how long the control plane keeps listing it. 0 sends nothing and the lease expires on the control plane's schedule") rootCmd.Flags().DurationVar(&authDeadline, "auth-deadline", router.DefaultAuthDeadline, "Close an inbound connection whose peer has not passed the mesh handshake within this time; members handshake at once, a DHT query from a member attached elsewhere is done long before. 0 keeps every connection") rootCmd.Flags().StringArrayVar(&labelFlags, "label", nil, "A key=value label declared at enrollment, repeatable; the control plane signs it only if the router's role allows it, and members select or prefer routers by it (e.g. region=eu, zone=us-central1-a)") + rootCmd.Flags().StringVar(&ingressAddr, "ingress-addr", "", "Serve external HTTP ingress under /ingress/ on this address (e.g. 0.0.0.0:8080); off by default") + rootCmd.Flags().StringVar(&ingressURL, "ingress-url", "", "Public base URL advertised for external HTTP ingress on this router (e.g. https://bananas.sam-mesh.dev/ingress)") + rootCmd.Flags().StringVar(&ingressTLSCertPath, "ingress-tls-cert-path", "", "Path to TLS certificate file for --ingress-addr") + rootCmd.Flags().StringVar(&ingressTLSKeyPath, "ingress-tls-key-path", "", "Path to TLS private key file for --ingress-addr") + rootCmd.Flags().BoolVar(&allowInsecureIngressHTTP, "allow-insecure-ingress-http", false, "Allow --ingress-addr on a non-loopback interface without TLS and allow http:// in --ingress-url. Risk: every caller's bearer Biscuit and request body then cross the network in plaintext between the client and this router; set it only when a TLS-terminating L7 load balancer in front of the router is the only thing that can reach --ingress-addr") + rootCmd.Flags().StringVar(&publicControlPlaneURL, "public-control-plane", "", "Optional public URL of the control plane advertised in RFC 9728 and A2A OAuth metadata when --control-plane uses a cluster-internal address") ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() diff --git a/hack/gen-sdk-datalog/main.go b/hack/gen-sdk-datalog/main.go index 972cf8c7..79b70f15 100644 --- a/hack/gen-sdk-datalog/main.go +++ b/hack/gen-sdk-datalog/main.go @@ -59,6 +59,7 @@ type artifact struct { FactRole string `json:"fact_role"` FactTargetFact string `json:"fact_target_fact"` FactPublish string `json:"fact_publish"` + FactIngress string `json:"fact_ingress"` CatalogTarget string `json:"catalog_target"` FactTARBlock string `json:"fact_tar_block"` TARBlockSourcePattern string `json:"tar_block_source_pattern"` @@ -717,6 +718,7 @@ func main() { FactRole: api.FactRole, FactTargetFact: api.FactTargetFact, FactPublish: api.FactPublish, + FactIngress: api.FactIngress, CatalogTarget: api.CatalogTarget, FactTARBlock: api.FactTARBlock, TARBlockSourcePattern: api.TARBlockSourcePattern, diff --git a/internal/controlplane/catalog.go b/internal/controlplane/catalog.go index ab79b0ac..825d9b70 100644 --- a/internal/controlplane/catalog.go +++ b/internal/controlplane/catalog.go @@ -139,6 +139,15 @@ func (s *Server) HandleNodeCatalog(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Too many services in report (max %d)", maxCatalogServices), http.StatusBadRequest) return } + for _, svc := range req.Services { + if svc == nil || svc.GetIngress() == nil { + continue + } + if err := api.ValidateServiceIngress(svc.GetType(), svc.GetIngress()); err != nil { + http.Error(w, "Invalid service ingress: "+err.Error(), http.StatusBadRequest) + return + } + } s.catalogMu.Lock() s.catalog[nodeRecord.PeerID] = nodeCatalogEntry{ diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index f1c34e2f..be0b8fdd 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -85,6 +85,7 @@ func (s *Server) routerInfos(ctx context.Context, routers []storage.RouterLease) Addresses: r.Addresses, Connections: int32(r.Connections), ConnectionLimit: int32(r.ConnectionLimit), + IngressUrl: r.IngressURL, } if record, err := s.store.GetNode(ctx, r.PeerID); err == nil && record != nil { info.Labels = record.Labels @@ -1635,6 +1636,11 @@ func (s *Server) HandleRouterLease(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid load: connections and connection_limit must not be negative", http.StatusBadRequest) return } + ingressURL, err := api.ValidateIngressURL(req.IngressUrl) + if err != nil { + http.Error(w, "Invalid ingress_url: "+err.Error(), http.StatusBadRequest) + return + } if len(req.Addresses) == 0 { logger.Warnw("Router leased without addresses: it is listed but members cannot reach it", "peer_id", canonical) } @@ -1669,6 +1675,7 @@ func (s *Server) HandleRouterLease(w http.ResponseWriter, r *http.Request) { DHTSize: int(req.DhtSize), Connections: int(req.Connections), ConnectionLimit: int(req.ConnectionLimit), + IngressURL: ingressURL, } if err := s.store.UpsertRouterLease(r.Context(), lease); err != nil { @@ -3852,6 +3859,9 @@ func validatePolicyConfig(req *api.PolicyConfig) error { if err := api.ValidateHTTPGrants(r.Http, r.AllowedServices); err != nil { return fmt.Errorf("in role %s: %w", r.Name, err) } + if err := api.ValidateIngressGrants(r.AllowedIngress, r.Name); err != nil { + return err + } for _, dl := range r.CustomDatalog { trimmed := strings.TrimRight(strings.TrimSpace(dl), ";") if trimmed == "" { @@ -3870,6 +3880,7 @@ func validatePolicyConfig(req *api.PolicyConfig) error { // which roles are mutually exclusive for a given identity. factBudget += len(api.BuildServiceDatalogFacts(r.AllowedServices)) factBudget += len(api.BuildPublishDatalogFacts(r.AllowedPublish)) + factBudget += len(api.BuildIngressDatalogFacts(r.AllowedIngress)) factBudget += len(api.BuildTargetDatalogFacts(r.AllowedTargets)) factBudget += len(r.CustomDatalog) for _, g := range r.Http { diff --git a/internal/controlplane/views.go b/internal/controlplane/views.go index 6c0420d2..b92253f3 100644 --- a/internal/controlplane/views.go +++ b/internal/controlplane/views.go @@ -133,6 +133,7 @@ func routerLeaseView(l *storage.RouterLease) *api.RouterLease { ExpireTime: timestampOrNil(l.ExpiresAt), ConnectedPeers: l.ConnectedPeers, DhtSize: int32(l.DHTSize), + IngressUrl: l.IngressURL, } } diff --git a/internal/envoy/server.go b/internal/envoy/server.go index 0c149b44..e7e394d1 100644 --- a/internal/envoy/server.go +++ b/internal/envoy/server.go @@ -18,11 +18,8 @@ package envoy import ( - "bytes" "context" - "encoding/json" "errors" - "fmt" "io" "net/http" "strings" @@ -482,49 +479,14 @@ func (s *GatewayServer) evaluateExtProcDecision(ctx context.Context, method, pat } // InspectJSONRPCMCPBody inspects a JSON-RPC 2.0 request body for MCP methods -// ("initialize", "ping", "tools/list", "tools/call") and extracts the bare tool -// name when present. +// and extracts the bare tool name when present, or sets allowStreamInit for +// session handshake, capability discovery, and protocol notification methods. func InspectJSONRPCMCPBody(body []byte) (mcpTool string, allowStreamInit bool) { - var rpc struct { - Method string `json:"method"` - Params struct { - Name string `json:"name"` - } `json:"params"` - } - if err := json.Unmarshal(body, &rpc); err != nil { - return "", false - } - switch rpc.Method { - case "initialize", "ping", "tools/list": - return "", true - case "tools/call": - rawTool := strings.TrimSpace(rpc.Params.Name) - if rawTool == "" { - return "", false - } - if _, stripped, err := api.SplitToolName(rawTool); err == nil { - return stripped, false - } - return rawTool, false - default: - return "", false - } + return api.InspectJSONRPCMCPBody(body) } // InspectMCPHTTPRequestBody buffers and restores r.Body up to MaxGRPCMessageBytes // and extracts the MCP tool name and stream initialization flag. func InspectMCPHTTPRequestBody(r *http.Request) (string, bool, error) { - if r == nil || r.Body == nil || !strings.EqualFold(r.Method, http.MethodPost) { - return "", false, nil - } - bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, MaxGRPCMessageBytes+1)) - if err != nil { - return "", false, err - } - if int64(len(bodyBytes)) > MaxGRPCMessageBytes { - return "", false, fmt.Errorf("MCP request body exceeds maximum inspection size (%d bytes)", MaxGRPCMessageBytes) - } - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - tool, allowInit := InspectJSONRPCMCPBody(bodyBytes) - return tool, allowInit, nil + return api.InspectMCPHTTPRequestBody(r, MaxGRPCMessageBytes) } diff --git a/internal/identity/biscuit.go b/internal/identity/biscuit.go index 62cf426a..fbc2a420 100644 --- a/internal/identity/biscuit.go +++ b/internal/identity/biscuit.go @@ -439,6 +439,7 @@ func mintBiscuitWithBindingAndRand(rng io.Reader, signingKey ed25519.PrivateKey, // What the holder may serve. A delegated subject serves nothing: it is a // caller the actor node speaks for, not a member that publishes. var allPublish []string + var allIngress []*api.IngressGrant for _, role := range roles { if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ Name: api.FactRole, @@ -468,6 +469,7 @@ func mintBiscuitWithBindingAndRand(rng io.Reader, signingKey ed25519.PrivateKey, allTargets = append(allTargets, pr.AllowedTargets...) if !delegated { allPublish = append(allPublish, pr.AllowedPublish...) + allIngress = append(allIngress, pr.AllowedIngress...) } for _, customEntry := range pr.CustomDatalog { @@ -515,6 +517,11 @@ func mintBiscuitWithBindingAndRand(rng io.Reader, signingKey ed25519.PrivateKey, errs = append(errs, fmt.Errorf("failed to add publish fact: %w", err)) } } + for _, fact := range api.BuildIngressDatalogFacts(allIngress) { + if err := addFact(fact); err != nil { + errs = append(errs, fmt.Errorf("failed to add ingress fact: %w", err)) + } + } // No policy means no grants. A mesh with no roles defined used to mint // every non-router an unrestricted token; a fresh control plane, or one @@ -791,12 +798,31 @@ func VerifyBiscuitRole(biscuitData []byte, controlPlanePubKey ed25519.PublicKey, return RequireRole(b, controlPlanePubKey, expectedRole, timeout) } +// newAuthorizer creates an Authorizer from a fresh copy of b. biscuit-go v2's +// (*Biscuit).Authorizer appends onto b's internal symbol slice in place when +// spare capacity exists, so concurrent Authorizer calls on a shared *Biscuit +// (for example, router.memberCredential) race without re-unmarshaling. +func newAuthorizer(b *biscuit.Biscuit, key ed25519.PublicKey, timeout time.Duration) (biscuit.Authorizer, error) { + if b == nil { + return nil, errors.New("nil biscuit") + } + raw, err := b.Serialize() + if err != nil { + return nil, err + } + fresh, err := biscuit.Unmarshal(raw) + if err != nil { + return nil, err + } + return fresh.Authorizer(key, AuthorizerOptions(timeout)...) +} + // RequireRole checks that the token carries role(expectedRole) under the given // key. It checks nothing else: a token received from a peer must already have // passed VerifyBiscuitAndGetKey, which is where expiry and the peer binding are // enforced, and key must be the key that verified it. func RequireRole(b *biscuit.Biscuit, key ed25519.PublicKey, expectedRole string, timeout time.Duration) error { - authorizer, err := b.Authorizer(key, AuthorizerOptions(timeout)...) + authorizer, err := newAuthorizer(b, key, timeout) if err != nil { return fmt.Errorf("failed to create authorizer: %w", err) } @@ -824,7 +850,7 @@ func RequireRole(b *biscuit.Biscuit, key ed25519.PublicKey, expectedRole string, // have passed verification and key must be the key that verified it; what // is decided here is the grant alone. func RequirePublish(b *biscuit.Biscuit, key ed25519.PublicKey, svcType, svcName string, policyRules []biscuit.Rule, timeout time.Duration) error { - authorizer, err := b.Authorizer(key, AuthorizerOptions(timeout)...) + authorizer, err := newAuthorizer(b, key, timeout) if err != nil { return fmt.Errorf("failed to create authorizer: %w", err) } @@ -841,6 +867,30 @@ func RequirePublish(b *biscuit.Biscuit, key ed25519.PublicKey, svcType, svcName return nil } +// RequireIngress reports whether the credential grants exposing the inbound +// service svcType://svcName on external router HTTP ingress under mode ("edge" +// or "passthrough"): api.IngressPolicies over the token's granted_ingress_* +// facts, those the mesh policy rules derive from its role() and label() facts, +// and an ingress() fact. b must have passed verification and key must be the +// key that verified it. +func RequireIngress(b *biscuit.Biscuit, key ed25519.PublicKey, svcType, svcName, mode string, policyRules []biscuit.Rule, timeout time.Duration) error { + authorizer, err := newAuthorizer(b, key, timeout) + if err != nil { + return fmt.Errorf("failed to create authorizer: %w", err) + } + authorizer.AddFact(api.IngressFact(svcType, svcName, mode)) + for _, r := range policyRules { + authorizer.AddRule(r) + } + for _, p := range api.IngressPolicies { + authorizer.AddPolicy(p) + } + if err := authorizer.Authorize(); err != nil { + return fmt.Errorf("credential does not grant %s ingress for %s://%s", mode, svcType, svcName) + } + return nil +} + // VerifiedBiscuitClaims holds the authority facts, effective expiration, // revocation IDs, and TaskAuthorizationRule chain extracted from a verified // Biscuit token. @@ -849,6 +899,8 @@ type VerifiedBiscuitClaims struct { VerifyingKey ed25519.PublicKey User string Email string + Groups []string + IdpRoles []string Roles []string NodePeerID string ActorNodePeerID string @@ -858,6 +910,28 @@ type VerifiedBiscuitClaims struct { TaskRules []*api.TaskAuthorizationRule } +// IdentityClaimsMap returns the attested identity claims keyed by Datalog fact +// name ("user", "email", "group", "idp_role", "node") for policy/allow matching. +func (c *VerifiedBiscuitClaims) IdentityClaimsMap() map[string][]string { + out := make(map[string][]string) + if c.User != "" { + out[api.FactUser] = []string{c.User} + } + if c.Email != "" { + out[api.FactEmail] = []string{c.Email} + } + if len(c.Groups) > 0 { + out[api.FactGroup] = append([]string(nil), c.Groups...) + } + if len(c.IdpRoles) > 0 { + out[api.FactIdpRole] = append([]string(nil), c.IdpRoles...) + } + if c.NodePeerID != "" { + out[api.FactNode] = []string{c.NodePeerID} + } + return out +} + // Principal returns the primary subject identifier for audit logs and border // JWTs: Email when present, otherwise User, otherwise "node:" or // "peer:". @@ -984,6 +1058,8 @@ func InspectVerifiedBiscuit(biscuitData []byte, trustedPublicKeys []ed25519.Publ VerifyingKey: verifyingKey, User: firstString(api.FactUser), Email: firstString(api.FactEmail), + Groups: queryStrings(api.FactGroup), + IdpRoles: queryStrings(api.FactIdpRole), Roles: queryStrings(api.FactRole), NodePeerID: nodePeerID, ActorNodePeerID: actorNodePeerID, diff --git a/internal/identity/ingress.go b/internal/identity/ingress.go new file mode 100644 index 00000000..f3e939af --- /dev/null +++ b/internal/identity/ingress.go @@ -0,0 +1,234 @@ +// 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 identity + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/google/agentmesh/api" +) + +// DecodeBase64Biscuit decodes a base64 or unpadded base64url encoded Biscuit. +func DecodeBase64Biscuit(encoded string) ([]byte, error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return nil, fmt.Errorf("empty biscuit token") + } + if b, err := base64.StdEncoding.DecodeString(encoded); err == nil { + return b, nil + } + return base64.RawURLEncoding.DecodeString(encoded) +} + +// TargetFactsFromBiscuit queries api.TargetFactRules on a provider's own +// verified Biscuit to produce the target_fact(type, val) facts injected when +// evaluating BaselineTargetCheck against a caller. +func TargetFactsFromBiscuit(b *biscuit.Biscuit, trustedKeys []ed25519.PublicKey, timeout time.Duration) ([]biscuit.Fact, error) { + var auth biscuit.Authorizer + var lastErr error + for _, pub := range trustedKeys { + candidate, err := newAuthorizer(b, pub, timeout) + if err != nil { + lastErr = err + continue + } + candidate.AddPolicy(api.AllowIfTruePolicy) + if err := candidate.Authorize(); err == nil { + auth = candidate + break + } else { + lastErr = err + } + } + if auth == nil { + return nil, fmt.Errorf("provider biscuit verification failed: %w", lastErr) + } + var out []biscuit.Fact + for _, rule := range api.TargetFactRules { + if factSet, err := auth.Query(rule); err == nil { + for _, fact := range factSet { + out = append(out, fact) + } + } + } + return out, nil +} + +// IngressEvalParams configures Datalog + TAR + ServiceIngress.allow evaluation +// for an external HTTP ingress request in INGRESS_AUTH_MODE_EDGE. +type IngressEvalParams struct { + ServiceType string + ServiceName string + Method string + Path string + MCPTool string + AllowMCPStreamInit bool + TargetFacts []biscuit.Fact + EnforceTargetCheck bool + MeshPolicyRules []biscuit.Rule + LocalPolicies []biscuit.Policy + LocalChecks []biscuit.Check + LocalRules []biscuit.Rule + Allow []string + Timeout time.Duration +} + +// IngressCallerResult describes an authorized external ingress caller. +type IngressCallerResult struct { + Principal string + Roles []string + TaskName string + ActorNodeID string + IdentityFacts map[string][]string +} + +func runIngressAuthorizer(authorizer biscuit.Authorizer, p IngressEvalParams) ([]string, error) { + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactService, + IDs: []biscuit.Term{biscuit.String(p.ServiceType), biscuit.String(p.ServiceName)}, + }}) + if p.Method != "" { + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactMethod, + IDs: []biscuit.Term{biscuit.String(p.Method)}, + }}) + authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactPath, + IDs: []biscuit.Term{biscuit.String(p.Path)}, + }}) + } + EnforceExpiration(authorizer) + for _, tf := range p.TargetFacts { + authorizer.AddFact(tf) + } + if p.EnforceTargetCheck { + authorizer.AddCheck(api.BaselineTargetCheck) + } + for _, pol := range p.LocalPolicies { + authorizer.AddPolicy(pol) + } + for _, chk := range p.LocalChecks { + authorizer.AddCheck(chk) + } + for _, r := range p.LocalRules { + authorizer.AddRule(r) + } + for _, pol := range api.BaselinePolicies { + authorizer.AddPolicy(pol) + } + for _, r := range api.BaselineRules { + authorizer.AddRule(r) + } + for _, r := range api.BaselineHTTPRules { + authorizer.AddRule(r) + } + for _, r := range p.MeshPolicyRules { + authorizer.AddRule(r) + } + if err := authorizer.Authorize(); err != nil { + return nil, err + } + + var roles []string + if facts, err := authorizer.Query(biscuit.Rule{ + Head: biscuit.Predicate{Name: "get_roles", IDs: []biscuit.Term{biscuit.Variable("r")}}, + Body: []biscuit.Predicate{{Name: api.FactRole, IDs: []biscuit.Term{biscuit.Variable("r")}}}, + }); err == nil { + for _, f := range facts { + if len(f.IDs) == 1 { + if s, ok := f.IDs[0].(biscuit.String); ok { + roles = append(roles, string(s)) + } + } + } + sort.Strings(roles) + } + return roles, nil +} + +// ValidateIngressCallerPreDial checks the caller's verified Biscuit claims, +// TAR attenuation chain, and ServiceIngress.allow ACL locally before opening a +// network connection or /mesh/auth handshake to the serving provider. +func ValidateIngressCallerPreDial(claims *VerifiedBiscuitClaims, p IngressEvalParams) (map[string][]string, error) { + if claims == nil || claims.Biscuit == nil { + return nil, fmt.Errorf("missing verified biscuit claims") + } + if slices.Contains(claims.Roles, api.RoleRouter) { + return nil, fmt.Errorf("router biscuits cannot be used as HTTP edge bearer tokens") + } + if claims.NodePeerID != "" && len(claims.TaskRules) == 0 { + return nil, fmt.Errorf("unattenuated transport member biscuits cannot be used as HTTP edge bearer tokens; exchange at /oauth/token") + } + if len(claims.TaskRules) > 0 { + taskReq := api.TaskRequestContext{ + ServiceType: p.ServiceType, + ServiceName: p.ServiceName, + HasHTTP: p.Method != "", + Method: p.Method, + Path: p.Path, + MCPTool: p.MCPTool, + AllowMCPStreamInit: p.AllowMCPStreamInit, + } + if err := api.EvaluateTaskRules(claims.TaskRules, taskReq, time.Now()); err != nil { + return nil, err + } + } + idMap := claims.IdentityClaimsMap() + if !api.MatchIngressAllow(p.Allow, idMap) { + return nil, fmt.Errorf("caller does not satisfy service ingress allow list") + } + return idMap, nil +} + +// AuthorizeIngressDelegatedBiscuit authorizes a verified Delegated Session +// Biscuit or task-attenuated Task Biscuit (minted via RFC 8693 /oauth/token, +// POST /token/exchange, or SDK task attenuation) against mesh Datalog policy, +// local node attenuation, TAR blocks, and ServiceIngress.allow. Unattenuated +// standing member/router transport Biscuits are rejected. +func AuthorizeIngressDelegatedBiscuit(claims *VerifiedBiscuitClaims, p IngressEvalParams) (*IngressCallerResult, error) { + idMap, err := ValidateIngressCallerPreDial(claims, p) + if err != nil { + return nil, err + } + + authorizer, err := newAuthorizer(claims.Biscuit, claims.VerifyingKey, p.Timeout) + if err != nil { + return nil, fmt.Errorf("failed to create biscuit authorizer: %w", err) + } + roles, err := runIngressAuthorizer(authorizer, p) + if err != nil { + return nil, err + } + + actorNodeID := claims.ActorNodePeerID + if actorNodeID == "" { + actorNodeID = claims.NodePeerID + } + + return &IngressCallerResult{ + Principal: claims.Principal(), + Roles: roles, + TaskName: claims.InnermostTaskName(), + ActorNodeID: actorNodeID, + IdentityFacts: idMap, + }, nil +} diff --git a/internal/identity/ingress_test.go b/internal/identity/ingress_test.go new file mode 100644 index 00000000..058b922e --- /dev/null +++ b/internal/identity/ingress_test.go @@ -0,0 +1,363 @@ +// 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 identity + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "sync" + "testing" + "time" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/google/agentmesh/api" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func testGenPeerID(t *testing.T) peer.ID { + t.Helper() + priv, _, err := crypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatalf("GenerateEd25519Key: %v", err) + } + id, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + return id +} + +func TestRequireIngress(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + providerPeer := testGenPeerID(t) + roles := []*api.PolicyRole{ + { + Name: "agent-host", + AllowedPublish: []string{"a2a://*"}, + AllowedIngress: []*api.IngressGrant{ + {Services: []string{"a2a://assistant"}}, + { + Services: []string{"a2a://paired"}, + AllowedAuthModes: []api.IngressAuthMode{api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + }, + }, + }, + } + + raw, err := MintBootstrapBiscuitToken(priv, providerPeer, "agent-host", time.Now().Add(time.Hour), roles, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + b, key, err := VerifyBiscuitAndGetKey(raw, providerPeer, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("VerifyBiscuitAndGetKey: %v", err) + } + + if err := RequireIngress(b, key, "a2a", "assistant", api.IngressAuthModeStringEdge, nil, time.Second); err != nil { + t.Fatalf("expected a2a://assistant edge to be allowed: %v", err) + } + if err := RequireIngress(b, key, "a2a", "assistant", api.IngressAuthModeStringPassthrough, nil, time.Second); err == nil { + t.Fatal("expected a2a://assistant passthrough to be denied") + } + if err := RequireIngress(b, key, "a2a", "paired", api.IngressAuthModeStringPassthrough, nil, time.Second); err != nil { + t.Fatalf("expected a2a://paired passthrough to be allowed: %v", err) + } + + // Verify Delegated Session Biscuits NEVER receive allowed_ingress facts. + delRaw, err := MintDelegatedBiscuitToken(priv, jwt.MapClaims{"email": "alice@example.com"}, providerPeer, time.Now().Add(time.Hour), []string{"agent-host"}, roles, nil, false) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken: %v", err) + } + delB, _, err := UnmarshalInbound(delRaw) + if err != nil { + t.Fatalf("UnmarshalInbound: %v", err) + } + if err := RequireIngress(delB, pub, "a2a", "assistant", api.IngressAuthModeStringEdge, nil, time.Second); err == nil { + t.Fatal("delegated session token must never grant ingress publishing") + } +} + +func TestAuthorizeIngressCallers(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + actorPeer := testGenPeerID(t) + providerPeer := testGenPeerID(t) + + roles := []*api.PolicyRole{ + { + Name: "caller-role", + AllowedServices: []string{"a2a://assistant", "mcp://calc"}, + AllowedTargets: []string{"node:" + providerPeer.String()}, + }, + } + bindings := []*api.PolicyBinding{ + {Role: "caller-role", Members: []string{"email:*@google.com"}}, + } + compiled, warnings := api.BuildPolicyRules(roles, bindings) + if len(warnings) != 0 { + t.Fatalf("BuildPolicyRules warnings: %v", warnings) + } + meshRules, err := api.ParseDatalogRules(api.PolicyRuleTexts(compiled)) + if err != nil { + t.Fatalf("ParseDatalogRules: %v", err) + } + + provRaw, err := MintBootstrapBiscuitToken(priv, providerPeer, "agent-host", time.Now().Add(time.Hour), nil, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + provB, err := biscuit.Unmarshal(provRaw) + if err != nil { + t.Fatalf("Unmarshal provider biscuit: %v", err) + } + targetFacts, err := TargetFactsFromBiscuit(provB, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("TargetFactsFromBiscuit: %v", err) + } + + // 1. Unattenuated transport member Biscuit MUST be rejected as an HTTP edge Bearer token. + memberRaw, err := MintBootstrapBiscuitToken(priv, actorPeer, "caller-role", time.Now().Add(time.Hour), roles, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + memberClaims, err := InspectVerifiedBiscuit(memberRaw, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("InspectVerifiedBiscuit: %v", err) + } + if _, err := AuthorizeIngressDelegatedBiscuit(memberClaims, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Timeout: time.Second, + }); err == nil { + t.Fatal("expected unattenuated member transport biscuit to be rejected at HTTP edge") + } + + // 1b. Task-attenuated member Biscuit (minted via RFC 8693 /oauth/token on a node) + // IS accepted for its narrowed service and rejected outside its TAR scope. + attMemberRaw, err := AttenuateBiscuit(memberRaw, &api.TaskAuthorizationRule{ + Name: "node-task-a2a", + Rules: []*api.TaskRule{ + {AllowedServices: []string{"a2a://assistant"}}, + }, + }) + if err != nil { + t.Fatalf("AttenuateBiscuit: %v", err) + } + attMemberClaims, err := InspectVerifiedBiscuit(attMemberRaw, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("InspectVerifiedBiscuit attenuated member: %v", err) + } + attRes, err := AuthorizeIngressDelegatedBiscuit(attMemberClaims, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("expected task-attenuated member biscuit to be accepted: %v", err) + } + if attRes.ActorNodeID != actorPeer.String() || attRes.TaskName != "node-task-a2a" { + t.Fatalf("unexpected task-attenuated result: %+v", attRes) + } + if _, err := AuthorizeIngressDelegatedBiscuit(attMemberClaims, IngressEvalParams{ + ServiceType: "mcp", + ServiceName: "calc", + Method: "POST", + Path: "/", + AllowMCPStreamInit: true, + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Timeout: time.Second, + }); err == nil { + t.Fatal("expected out-of-scope mcp://calc to be rejected for task-attenuated member biscuit") + } + + // 2. Delegated Session Biscuit with TAR attenuation and allow list. + tarRule := &api.TaskAuthorizationRule{ + Name: "assist-only", + Rules: []*api.TaskRule{ + {AllowedServices: []string{"a2a://assistant"}}, + }, + } + delRaw, err := MintDelegatedBiscuitToken(priv, jwt.MapClaims{ + "sub": "uid-1", + "email": "yaroslav@google.com", + }, actorPeer, time.Now().Add(time.Hour), []string{"caller-role"}, roles, tarRule, true) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken: %v", err) + } + b64 := base64.StdEncoding.EncodeToString(delRaw) + decoded, err := DecodeBase64Biscuit(b64) + if err != nil { + t.Fatalf("DecodeBase64Biscuit: %v", err) + } + delClaims, err := InspectVerifiedBiscuit(decoded, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("InspectVerifiedBiscuit: %v", err) + } + + res, err := AuthorizeIngressDelegatedBiscuit(delClaims, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Allow: []string{"email:yaroslav@google.com"}, + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("AuthorizeIngressDelegatedBiscuit: %v", err) + } + if res.Principal != "yaroslav@google.com" || res.TaskName != "assist-only" { + t.Fatalf("got principal=%q task=%q, want yaroslav@google.com / assist-only", res.Principal, res.TaskName) + } + + // TAR blocks mcp://calc even though role grants it. + if _, err := AuthorizeIngressDelegatedBiscuit(delClaims, IngressEvalParams{ + ServiceType: "mcp", + ServiceName: "calc", + Method: "POST", + Path: "/mcp", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Timeout: time.Second, + }); err == nil { + t.Fatal("expected TAR to deny mcp://calc") + } + + // Allow list blocks non-matching email. + if _, err := AuthorizeIngressDelegatedBiscuit(delClaims, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Allow: []string{"email:someone-else@google.com"}, + Timeout: time.Second, + }); err == nil { + t.Fatal("expected allow list mismatch to deny") + } + + // 3. Router Biscuits (even if attenuated with a TAR block) MUST be rejected as HTTP edge bearer tokens. + routerRaw, err := MintBootstrapBiscuitToken(priv, actorPeer, api.RoleRouter, time.Now().Add(time.Hour), []*api.PolicyRole{ + {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + }, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken(router): %v", err) + } + attRouterRaw, err := AttenuateBiscuit(routerRaw, tarRule) + if err != nil { + t.Fatalf("AttenuateBiscuit(router): %v", err) + } + routerClaims, err := InspectVerifiedBiscuit(attRouterRaw, []ed25519.PublicKey{pub}, time.Second) + if err != nil { + t.Fatalf("InspectVerifiedBiscuit(router): %v", err) + } + if _, err := ValidateIngressCallerPreDial(routerClaims, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + }); err == nil { + t.Fatal("expected router biscuit to be rejected at HTTP edge") + } +} + +func TestConcurrentSharedBiscuitAuthorizerNoRace(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + providerPID := testGenPeerID(t) + roles := []*api.PolicyRole{ + { + Name: api.RoleNode, + AllowedTargets: []string{"*"}, + AllowedServices: []string{"*"}, + AllowedPublish: []string{"*"}, + AllowedIngress: []*api.IngressGrant{ + { + Services: []string{"*"}, + AllowedAuthModes: []api.IngressAuthMode{ + api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + }, + }, + }, + }, + } + raw, _, err := MintBiscuitToken(priv, jwt.MapClaims{"sub": "node-1"}, nil, providerPID, time.Now().Add(time.Hour), []string{api.RoleNode}, roles, map[string]string{"env": "prod"}) + if err != nil { + t.Fatalf("MintBiscuitToken: %v", err) + } + sharedBiscuit, err := biscuit.Unmarshal(raw) + if err != nil { + t.Fatalf("Unmarshal: %v", err) + } + meshRules, _ := api.BuildPolicyRules(roles, nil) + var rules []biscuit.Rule + for _, pr := range meshRules { + rules = append(rules, pr.Rule) + } + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + if err := RequirePublish(sharedBiscuit, pub, "a2a", "coder", rules, time.Second); err != nil { + t.Errorf("RequirePublish: %v", err) + return + } + if err := RequireIngress(sharedBiscuit, pub, "a2a", "coder", "edge", rules, time.Second); err != nil { + t.Errorf("RequireIngress: %v", err) + return + } + if err := RequireRole(sharedBiscuit, pub, api.RoleNode, time.Second); err != nil { + t.Errorf("RequireRole: %v", err) + return + } + if _, err := TargetFactsFromBiscuit(sharedBiscuit, []ed25519.PublicKey{pub}, time.Second); err != nil { + t.Errorf("TargetFactsFromBiscuit: %v", err) + return + } + } + }() + } + wg.Wait() +} diff --git a/internal/node/auth_gate.go b/internal/node/auth_gate.go index 11a54d35..0872d957 100644 --- a/internal/node/auth_gate.go +++ b/internal/node/auth_gate.go @@ -30,6 +30,10 @@ import ( // never authenticated while the stream waited. const streamScopeUnauthenticated = "unauthenticated" +// streamScopeNotRouter labels a stream the gate refused on a router-only +// protocol: its peer authenticated, but not as mesh:role:router. +const streamScopeNotRouter = "not_router" + // authWait bounds how long a stream waits for its peer's handshake: a // router's auth deadline for the connection, so a peer that never // authenticates is cut off with it. @@ -66,12 +70,40 @@ func (h *meshHost) NewStream(ctx context.Context, p peer.ID, pids ...protocol.ID return h.Host.NewStream(ctx, p, pids...) } +// routerHost is meshHost for a protocol only routers speak to a member, such +// as external HTTP ingress forwarding. A handler installed through it is +// served only to a peer that authenticated as mesh:role:router: a router this +// node attached to, or one that completed the handshake with this node and +// carried the role in its credential. Any other authenticated peer is refused +// before the handler reads a byte, so a member cannot reach the handler's +// parser at all; the handler's own credential check then only confirms what +// the gate decided. +type routerHost struct { + meshHost +} + +func (h *routerHost) SetStreamHandler(pid protocol.ID, handler network.StreamHandler) { + h.Host.SetStreamHandler(pid, h.n.requireRouter(handler)) +} + +func (h *routerHost) SetStreamHandlerMatch(pid protocol.ID, match func(protocol.ID) bool, handler network.StreamHandler) { + h.Host.SetStreamHandlerMatch(pid, match, h.n.requireRouter(handler)) +} + // isPeerAuthenticated reports whether p is a router this node authenticated // with on an open session, or a peer whose handshake with this node holds. func (n *AgentMeshNode) isPeerAuthenticated(p peer.ID) bool { return n.isAuthenticatedAndConnected(p) || n.isAdmitted(p) } +// isAuthenticatedRouter reports whether p authenticated as a router: on a +// session this node opened to it after verifying role("mesh:role:router"), +// or by completing the handshake with this node with a credential that +// carried the role. +func (n *AgentMeshNode) isAuthenticatedRouter(p peer.ID) bool { + return n.isAuthenticatedAndConnected(p) || n.isAdmittedRouter(p) +} + // ensurePeerAuthenticated returns once p is authenticated: for an unattached // router the control plane lists, by running the handshake (one at a time per // router). A router this node is already attached to whose session dropped is @@ -108,6 +140,23 @@ func (n *AgentMeshNode) requireAuthenticated(next network.StreamHandler) network } } +// requireRouter wraps a stream handler so it runs only for a peer that +// authenticated as a router. The peer gets the same wait for its handshake +// as requireAuthenticated; a peer that then holds any other role is refused +// at once, logged, and counted under streamScopeNotRouter. +func (n *AgentMeshNode) requireRouter(next network.StreamHandler) network.StreamHandler { + return n.requireAuthenticated(func(s network.Stream) { + p := s.Conn().RemotePeer() + if !n.isAuthenticatedRouter(p) { + streamsRefusedTotal.WithLabelValues(streamScopeNotRouter, string(s.Protocol())).Inc() + logger.Warnf("[AuthZ] Refusing %s stream from %s: peer is authenticated but not a router", s.Protocol(), p) + _ = s.Reset() + return + } + next(s) + }) +} + // isCatalogRouter reports whether p is a router the control plane lists. func (n *AgentMeshNode) isCatalogRouter(p peer.ID) bool { n.mu.Lock() diff --git a/internal/node/config.go b/internal/node/config.go index d74f706c..f0be11c9 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -110,9 +110,29 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { } // Nodes serve what the control plane assigns them; a destination // declared here would give one node a policy of its own. - if t, err := api.ParseServiceType(svc.Type); err == nil && t == api.ServiceType_SERVICE_TYPE_EGRESS { + sType, typeErr := api.ParseServiceType(svc.Type) + if typeErr == nil && sType == api.ServiceType_SERVICE_TYPE_EGRESS { return nil, fmt.Errorf("service %q: egress destinations are assigned by the control plane (PolicyConfig.egress), not declared in the node config", svc.Name) } + if svc.Ingress != nil { + if typeErr != nil { + return nil, fmt.Errorf("service %q: %w", svc.Name, typeErr) + } + ing, err := api.ServiceIngressFromConfig(sType, svc.Ingress) + if err != nil { + return nil, fmt.Errorf("service %q: %w", svc.Name, err) + } + if api.EffectiveIngressAuthMode(ing) == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + if svc.TargetAuthPath != "" { + return nil, fmt.Errorf("service %q: ingress auth_mode %q cannot be combined with target_auth_path (would expose backend credential without caller authentication)", svc.Name, api.IngressAuthModeStringPassthrough) + } + if svc.TargetURL != "" { + if bt, pErr := parseBackendTarget(svc.TargetURL); pErr == nil && bt.auth != "" { + return nil, fmt.Errorf("service %q: ingress auth_mode %q cannot be combined with an inline target_url credential (would expose backend credential without caller authentication)", svc.Name, api.IngressAuthModeStringPassthrough) + } + } + } + } if svc.TargetAuthPath != "" && svc.TargetURL == "" { return nil, fmt.Errorf("service %q: target_auth_path needs a target_url", svc.Name) } diff --git a/internal/node/controlplane_sync.go b/internal/node/controlplane_sync.go index eea3dd64..aaaf3a94 100644 --- a/internal/node/controlplane_sync.go +++ b/internal/node/controlplane_sync.go @@ -293,7 +293,7 @@ func (n *AgentMeshNode) banPeer(p peer.ID, bannedAtMs int64) { if n.revokedPeers != nil { n.revokedPeers.Add(p.String(), bannedAtMs) } - n.authPeers.Delete(p) + n.forgetAdmission(p) if n.Host != nil { _ = n.Host.Network().ClosePeer(p) } diff --git a/internal/node/debug_handlers.go b/internal/node/debug_handlers.go index 07500240..f026ff0b 100644 --- a/internal/node/debug_handlers.go +++ b/internal/node/debug_handlers.go @@ -133,6 +133,7 @@ type meshInfoResponse struct { ConnectedPeers []string `json:"connected_peers"` AttachedRouters []string `json:"attached_routers"` RouterPeerID string `json:"router_peer_id"` + IngressURLs []string `json:"ingress_urls,omitempty"` LocalAPISocket string `json:"local_api_socket,omitempty"` } @@ -206,6 +207,7 @@ func (n *AgentMeshNode) meshInfo() (*meshInfoResponse, error) { ConnectedPeers: connectedPeers, AttachedRouters: attachedRouters, RouterPeerID: n.RouterPeerID.String(), + IngressURLs: n.routerIngressURLs(), LocalAPISocket: n.BoundSocketPath, }, nil } diff --git a/internal/node/directory_client.go b/internal/node/directory_client.go index b2358726..1a9aa9b5 100644 --- a/internal/node/directory_client.go +++ b/internal/node/directory_client.go @@ -115,11 +115,15 @@ type routerDirectory struct { } func (d *routerDirectory) Provide(ctx context.Context, key []byte, service string) error { + return d.ProvideWithIngress(ctx, key, service, nil) +} + +func (d *routerDirectory) ProvideWithIngress(ctx context.Context, key []byte, service string, ingress *api.ServiceIngress) error { addrs := d.addrs() if len(addrs) > api.DirectoryMaxAddrs { addrs = addrs[:api.DirectoryMaxAddrs] } - rec := &api.DirectoryProvide{Key: key, Service: service, Addrs: make([]string, 0, len(addrs))} + rec := &api.DirectoryProvide{Key: key, Service: service, Addrs: make([]string, 0, len(addrs)), Ingress: ingress} for _, a := range addrs { rec.Addrs = append(rec.Addrs, a.String()) } diff --git a/internal/node/ingress.go b/internal/node/ingress.go new file mode 100644 index 00000000..c0bdab10 --- /dev/null +++ b/internal/node/ingress.go @@ -0,0 +1,495 @@ +// 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 node + +import ( + "context" + "fmt" + "net/http" + "slices" + "sort" + "strings" + "time" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/google/agentmesh/api" + "github.com/google/agentmesh/internal/identity" + gostream "github.com/libp2p/go-libp2p-gostream" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const ( + externalIngressOutcomeAllow = "allow" + externalIngressOutcomeDeny = "deny" + externalIngressOutcomeUnauthenticated = "unauthenticated" + externalIngressOutcomeNotFound = "not_found" + externalIngressOutcomeRateLimited = "rate_limited" + externalIngressOutcomeInvalid = "invalid" +) + +var externalIngressRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_node_external_ingress_requests_total", + Help: "External HTTP ingress requests received from enrolled routers over /mesh/ingress/1.0.0, by auth mode and outcome", + }, + []string{"mode", "outcome"}, +) + +func init() { + for _, mode := range []string{api.IngressAuthModeStringEdge, api.IngressAuthModeStringPassthrough, "unknown"} { + for _, outcome := range []string{ + externalIngressOutcomeAllow, + externalIngressOutcomeDeny, + externalIngressOutcomeUnauthenticated, + externalIngressOutcomeNotFound, + externalIngressOutcomeRateLimited, + externalIngressOutcomeInvalid, + } { + externalIngressRequestsTotal.WithLabelValues(mode, outcome) + } + } +} + +// routerIngressURLs returns the sorted, deduplicated public ingress base URLs +// advertised by routers in this node's catalog. +func (n *AgentMeshNode) routerIngressURLs() []string { + cands := n.routerCandidates() + seen := make(map[string]bool, len(cands)) + var out []string + for _, c := range cands { + u := strings.TrimSuffix(strings.TrimSpace(c.IngressURL), "/") + if u == "" || seen[u] { + continue + } + seen[u] = true + out = append(out, u) + } + sort.Strings(out) + return out +} + +// logIngressURLs logs the public HTTP ingress URLs for a registered service +// whenever routers in the catalog advertise an ingress_url. +func (n *AgentMeshNode) logIngressURLs(info *api.ServiceInfo) { + if info == nil || info.GetIngress() == nil { + return + } + typeStr, err := api.ServiceTypeToString(info.GetType()) + if err != nil { + return + } + modeStr, err := api.IngressAuthModeToString(api.EffectiveIngressAuthMode(info.GetIngress())) + if err != nil { + modeStr = api.IngressAuthModeStringEdge + } + pid, err := n.localPeerID() + if err != nil || pid == "" { + return + } + urls := n.routerIngressURLs() + if len(urls) == 0 { + logger.Infof("[Ingress] Service %s/%s configured for %s ingress; no router currently advertises an ingress_url", typeStr, info.GetName(), modeStr) + return + } + for _, base := range urls { + logger.Infof("[Ingress] Public %s ingress URL for %s/%s: %s/%s/%s/%s", modeStr, typeStr, info.GetName(), base, pid.String(), typeStr, info.GetName()) + } +} + +// StartExternalIngressServer starts the HTTP-over-libp2p server on +// api.IngressProtocolID (/mesh/ingress/1.0.0) that receives external HTTP +// requests forwarded by enrolled routers. The listener is built on routerHost, +// so a stream from any peer that did not authenticate as a router is reset at +// the gate before the HTTP server reads it; the handler's X-Mesh-Biscuit check +// below is the second, independent layer. +func (n *AgentMeshNode) StartExternalIngressServer(ctx context.Context) error { + gated := &routerHost{meshHost{Host: n.Host, n: n}} + listener, err := gostream.Listen(gated, api.IngressProtocolID) + if err != nil { + return err + } + + server := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + Handler: n.externalIngressHandler(), + } + + go func() { + logger.Infof("[Ingress] Starting external ingress HTTP server on protocol %s", api.IngressProtocolID) + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + logger.Errorf("[Ingress] External ingress server error: %v", err) + } + }() + + go func() { + <-ctx.Done() + if err := server.Close(); err != nil { + logger.Errorf("[Ingress] Failed to close external ingress server: %v", err) + } + if err := listener.Close(); err != nil { + logger.Errorf("[Ingress] Failed to close external ingress listener: %v", err) + } + }() + + return nil +} + +func (n *AgentMeshNode) externalIngressHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rc := &readCloserWithCount{ReadCloser: r.Body} + r.Body = rc + wc := &responseWriterWithCount{ResponseWriter: w, statusCode: http.StatusOK} + w = wc + + var remotePeer peer.ID + var target string + modeMetric := "unknown" + + defer func() { + peerIDStr := "" + if remotePeer != "" { + peerIDStr = remotePeer.String() + } + finalTarget := target + if finalTarget == "" { + finalTarget = "unauthenticated_or_failed" + } + logger.Infow("Stream Accounting", + "peer_id", peerIDStr, + "target", finalTarget, + "protocol", string(api.IngressProtocolID), + "bytes_read", rc.bytesRead.Load(), + "bytes_written", wc.bytesWritten.Load(), + ) + }() + + path := r.URL.Path + if hasDotSegment(path) { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + serviceTypeStr := strings.ToLower(parts[0]) + serviceName := parts[1] + upstreamPath := "" + if len(parts) > 2 { + upstreamPath = parts[2] + } + + serviceType, err := api.ParseServiceType(serviceTypeStr) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Invalid service type", http.StatusBadRequest) + return + } + if !api.IsInboundServiceType(serviceType) { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeNotFound).Inc() + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + remotePeer, err = peer.Decode(r.RemoteAddr) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Invalid remote peer", http.StatusBadRequest) + return + } + if n.revokedPeers != nil && n.revokedPeers.Contains(remotePeer.String()) { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Refusing banned peer %s", remotePeer) + refuse(w, http.StatusForbidden, "Peer is revoked", proxyStatusDenied) + return + } + if n.externalIngressLimiter != nil && !n.externalIngressLimiter.Allow(remotePeer.String()) { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeRateLimited).Inc() + logger.Warnf("[ExternalIngress] Rate limit exceeded for router %s", remotePeer) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + + // Verify the forwarding peer is an enrolled, non-revoked router holding + // role("mesh:role:router") bound to remotePeer in Block 0. + routerBiscuitHeader := r.Header.Get(api.HeaderMeshBiscuit) + if routerBiscuitHeader == "" { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeUnauthenticated).Inc() + http.Error(w, "Missing X-Mesh-Biscuit header", http.StatusUnauthorized) + return + } + routerBiscuitBytes, err := identity.DecodeBase64Biscuit(routerBiscuitHeader) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Invalid X-Mesh-Biscuit encoding", http.StatusBadRequest) + return + } + routerClaims, err := n.VerifyLocalBiscuit(routerBiscuitBytes) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Router biscuit verification failed for %s: %v", remotePeer, err) + refuse(w, http.StatusForbidden, "Router authentication failed", proxyStatusDenied) + return + } + if err := identity.RequireAuthorityBinding(routerClaims.Biscuit, remotePeer); err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Router biscuit binding mismatch for %s: %v", remotePeer, err) + refuse(w, http.StatusForbidden, "Router authentication failed", proxyStatusDenied) + return + } + if !slices.Contains(routerClaims.Roles, api.RoleRouter) { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Peer %s lacks %s role on %s", remotePeer, api.RoleRouter, api.IngressProtocolID) + refuse(w, http.StatusForbidden, "Forbidden: only enrolled routers may forward external ingress", proxyStatusDenied) + return + } + + if n.services == nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + logger.Error("[ExternalIngress] Service registry not initialized") + http.Error(w, "Service registry not initialized", http.StatusServiceUnavailable) + return + } + svc, ok := n.services.GetTyped(serviceType, serviceName) + if !ok || svc.Handler() == nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeNotFound).Inc() + http.Error(w, "Service not found", http.StatusNotFound) + return + } + ing := svc.Info().GetIngress() + if ing == nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeNotFound).Inc() + logger.Warnf("[ExternalIngress] Refusing external ingress to internal-only service %s://%s from router %s", serviceTypeStr, truncateForLog(serviceName), remotePeer) + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + effectiveMode := api.EffectiveIngressAuthMode(ing) + modeStr, err := api.IngressAuthModeToString(effectiveMode) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + refuse(w, http.StatusForbidden, "Unsupported service ingress auth mode", proxyStatusConfigurationError) + return + } + modeMetric = modeStr + + if wireMode := strings.TrimSpace(r.Header.Get(api.HeaderMeshIngress)); wireMode != "" && wireMode != modeStr { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Router %s sent mode %q for %s://%s configured as %q", remotePeer, truncateForLog(wireMode), serviceTypeStr, serviceName, modeStr) + refuse(w, http.StatusForbidden, "Ingress auth mode mismatch", proxyStatusDenied) + return + } + + target = serviceTypeStr + "://" + serviceName + backendPath := "/" + upstreamPath + + if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + api.SanitizeExternalIngressHeaders(r.Header, false) + r.Header.Set(api.HeaderPeerID, remotePeer.String()) + r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringPassthrough) + + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeAllow).Inc() + logger.Infow("Border Crossing", + "direction", "inbound", + "protocol", string(api.IngressProtocolID), + "mode", api.IngressAuthModeStringPassthrough, + "router_peer_id", remotePeer.String(), + "target", target, + "method", r.Method, + "path", backendPath, + ) + rewriteExternalIngressRequestPath(r, parts, upstreamPath) + svc.Handler().ServeHTTP(w, r) + return + } + + // INGRESS_AUTH_MODE_EDGE: an enrolled router may fetch the public A2A + // AgentCard without caller credentials so external A2A clients can + // discover SecuritySchemes before authenticating. + callerBiscuitHeader := strings.TrimSpace(r.Header.Get(api.HeaderMeshIngressCallerBiscuit)) + if serviceType == api.ServiceType_SERVICE_TYPE_A2A && + r.Method == http.MethodGet && + upstreamPath == a2aAgentCardPath && + callerBiscuitHeader == "" { + api.SanitizeExternalIngressHeaders(r.Header, true) + r.Header.Set(api.HeaderPeerID, remotePeer.String()) + r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringEdge) + + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeAllow).Inc() + logger.Infow("Border Crossing", + "direction", "inbound", + "protocol", string(api.IngressProtocolID), + "mode", api.IngressAuthModeStringEdge, + "router_peer_id", remotePeer.String(), + "principal", "anonymous:agent-card", + "target", target, + "method", r.Method, + "path", backendPath, + ) + rewriteExternalIngressRequestPath(r, parts, upstreamPath) + svc.Handler().ServeHTTP(w, r) + return + } + + if callerBiscuitHeader == "" { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeUnauthenticated).Inc() + logger.Warnf("[ExternalIngress] Missing caller biscuit on edge ingress request from router %s for %s", remotePeer, target) + http.Error(w, "Missing caller credentials on edge ingress request", http.StatusUnauthorized) + return + } + + callerBiscuitBytes, err := identity.DecodeBase64Biscuit(callerBiscuitHeader) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + logger.Warnf("[ExternalIngress] Invalid caller biscuit encoding via router %s for %s: %v", remotePeer, target, err) + http.Error(w, "Invalid caller biscuit encoding", http.StatusBadRequest) + return + } + verifiedCaller, err := n.VerifyLocalBiscuit(callerBiscuitBytes) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Caller biscuit verification failed via router %s for %s: %v", remotePeer, target, err) + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusDenied) + return + } + + evalParams, err := n.buildExternalIngressEvalParams(serviceTypeStr, serviceName, r.Method, backendPath, ing.GetAllow()) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Failed to build evaluation context for %s: %v", target, err) + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusConfigurationError) + return + } + + if serviceType == api.ServiceType_SERVICE_TYPE_MCP { + preParams := evalParams + preParams.AllowMCPStreamInit = true + if _, authErr := identity.AuthorizeIngressDelegatedBiscuit(verifiedCaller, preParams); authErr != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] Pre-body MCP AuthZ failed via router %s for %s: %v", remotePeer, target, authErr) + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusDenied) + return + } + mcpTool, allowInit, bodyErr := api.InspectMCPHTTPRequestBody(r, api.DefaultMaxMCPBodyInspectBytes) + if bodyErr != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] MCP body inspection failed via router %s for %s: %v", remotePeer, target, bodyErr) + refuse(w, http.StatusRequestEntityTooLarge, bodyErr.Error(), proxyStatusDenied) + return + } + evalParams.MCPTool = mcpTool + evalParams.AllowMCPStreamInit = allowInit + } + + callerRes, err := identity.AuthorizeIngressDelegatedBiscuit(verifiedCaller, evalParams) + if err != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + logger.Warnf("[ExternalIngress] AuthZ denied via router %s for %s: %v", remotePeer, target, err) + refuse(w, http.StatusForbidden, "Authorization failed", proxyStatusDenied) + return + } + + api.SanitizeExternalIngressHeaders(r.Header, true) + r.Header.Set(api.HeaderPeerID, remotePeer.String()) + r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringEdge) + if callerRes.Principal != "" { + r.Header.Set(api.HeaderMeshPrincipal, callerRes.Principal) + } + if len(callerRes.Roles) > 0 { + r.Header.Set(api.HeaderMeshRoles, strings.Join(callerRes.Roles, ",")) + } + if callerRes.TaskName != "" { + r.Header.Set(api.HeaderMeshTask, callerRes.TaskName) + } + + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeAllow).Inc() + logger.Infow("Border Crossing", + "direction", "inbound", + "protocol", string(api.IngressProtocolID), + "mode", api.IngressAuthModeStringEdge, + "router_peer_id", remotePeer.String(), + "principal", callerRes.Principal, + "roles", callerRes.Roles, + "task", callerRes.TaskName, + "target", target, + "method", r.Method, + "path", backendPath, + ) + + rewriteExternalIngressRequestPath(r, parts, upstreamPath) + ctx := r.Context() + if len(callerBiscuitBytes) > 0 { + ctx = WithCallerBiscuit(ctx, callerBiscuitBytes) + } + svc.Handler().ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (n *AgentMeshNode) buildExternalIngressEvalParams(serviceType, serviceName, method, path string, allow []string) (identity.IngressEvalParams, error) { + var targetFacts []biscuit.Fact + enforceTarget := false + if ourIdentity := n.GetIdentity(); len(ourIdentity) > 0 { + ourB, err := biscuit.Unmarshal(ourIdentity) + if err != nil { + return identity.IngressEvalParams{}, fmt.Errorf("failed to unmarshal node identity: %w", err) + } + tf, err := identity.TargetFactsFromBiscuit(ourB, n.trustedPublicKeys(), n.BiscuitTimeout) + if err != nil { + return identity.IngressEvalParams{}, fmt.Errorf("failed to extract node target facts: %w", err) + } + targetFacts = tf + enforceTarget = true + } + + n.MeshPolicyMu.RLock() + meshRules := n.MeshPolicyRules + n.MeshPolicyMu.RUnlock() + + p := identity.IngressEvalParams{ + ServiceType: serviceType, + ServiceName: serviceName, + Method: method, + Path: path, + TargetFacts: targetFacts, + EnforceTargetCheck: enforceTarget, + MeshPolicyRules: meshRules, + Allow: allow, + Timeout: n.BiscuitTimeout, + } + if n.nodeConfig != nil { + p.LocalPolicies = n.nodeConfig.Policies + p.LocalChecks = n.nodeConfig.Checks + p.LocalRules = n.nodeConfig.Rules + } + return p, nil +} + +func rewriteExternalIngressRequestPath(r *http.Request, parts []string, upstreamPath string) { + if upstreamPath == "" { + r.URL.Path = "/" + if len(parts) == 2 { + r.Header.Set(api.HeaderMeshNoTrailingSlash, "true") + } + } else { + r.URL.Path = "/" + upstreamPath + } + r.URL.RawPath = "" +} diff --git a/internal/node/ingress_test.go b/internal/node/ingress_test.go new file mode 100644 index 00000000..3de2c6ff --- /dev/null +++ b/internal/node/ingress_test.go @@ -0,0 +1,707 @@ +// 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 node + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/eclipse-biscuit/biscuit-go/v2/parser" + "github.com/golang-jwt/jwt/v5" + "github.com/google/agentmesh/api" + "github.com/google/agentmesh/internal/identity" + libp2pcrypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func mustTestPeerID(t *testing.T) (peer.ID, libp2pcrypto.PrivKey) { + t.Helper() + priv, _, err := libp2pcrypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatalf("GenerateEd25519Key: %v", err) + } + pid, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + return pid, priv +} + +type fakeIngressDHT struct { + fakeDHT + ingressCalls []ingressProvideCall + failIngressErr error +} + +type ingressProvideCall struct { + key []byte + service string + ingress *api.ServiceIngress +} + +func (f *fakeIngressDHT) ProvideWithIngress(_ context.Context, key []byte, service string, ing *api.ServiceIngress) error { + f.ingressCalls = append(f.ingressCalls, ingressProvideCall{ + key: append([]byte(nil), key...), + service: service, + ingress: ing, + }) + return f.failIngressErr +} + +func TestServiceRegistry_ProvideWithIngressOnlyOnNamedKey(t *testing.T) { + dht := &fakeIngressDHT{} + reg := newServiceRegistryForTest(dht) + + var callbackInfo *api.ServiceInfo + reg.onIngressRegistered = func(info *api.ServiceInfo) { + callbackInfo = info + } + + svc := &fakeService{ + info: &api.ServiceInfo{ + Name: "assistant", + Type: api.ServiceType_SERVICE_TYPE_A2A, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com"}, + }, + }, + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}), + } + if err := reg.Register(context.Background(), svc); err != nil { + t.Fatalf("Register: %v", err) + } + if len(dht.ingressCalls) != 1 { + t.Fatalf("ProvideWithIngress calls = %d, want 1", len(dht.ingressCalls)) + } + if dht.ingressCalls[0].service != "a2a://assistant" { + t.Errorf("ProvideWithIngress service = %q, want a2a://assistant", dht.ingressCalls[0].service) + } + if len(dht.calls) != 1 { + t.Fatalf("plain Provide calls (type key) = %d, want 1", len(dht.calls)) + } + if callbackInfo == nil || callbackInfo.GetName() != "assistant" { + t.Errorf("onIngressRegistered callback info = %v, want assistant", callbackInfo) + } +} + +func TestCompleteNodeConfig_ValidatesServiceIngress(t *testing.T) { + cfg := api.NodeConfig{ + Services: []api.ServiceConfig{ + { + Name: "assistant", + Type: "a2a", + TargetURL: "http://127.0.0.1:8081", + Ingress: &api.IngressConfig{AuthMode: "edge", Allow: []string{"email:alice@example.com"}}, + }, + }, + } + if _, err := CompleteNodeConfig(cfg); err != nil { + t.Fatalf("CompleteNodeConfig valid ingress failed: %v", err) + } + + badCfg := api.NodeConfig{ + Services: []api.ServiceConfig{ + { + Name: "assistant", + Type: "a2a", + TargetURL: "http://127.0.0.1:8081", + Ingress: &api.IngressConfig{AuthMode: "bogus"}, + }, + }, + } + if _, err := CompleteNodeConfig(badCfg); err == nil { + t.Fatal("CompleteNodeConfig accepted invalid ingress auth_mode") + } +} + +func TestServiceRegistry_RefusedIngressFallsBackToInternalProvide(t *testing.T) { + dht := &fakeIngressDHT{ + failIngressErr: errors.New("router refused ingress role ceiling"), + } + reg := newServiceRegistryForTest(dht) + + var callbackInfo *api.ServiceInfo + reg.onIngressRegistered = func(info *api.ServiceInfo) { + callbackInfo = info + } + + svc := &fakeService{ + info: &api.ServiceInfo{ + Name: "ungranted", + Type: api.ServiceType_SERVICE_TYPE_A2A, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }, + }, + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}), + } + + beforeIngressFail := counterValue(t, serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeIngress)) + if err := reg.Register(context.Background(), svc); err != nil { + t.Fatalf("Register: %v", err) + } + afterIngressFail := counterValue(t, serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeIngress)) + if afterIngressFail != beforeIngressFail+1 { + t.Errorf("ingress failure counter = %v, want %v", afterIngressFail, beforeIngressFail+1) + } + if callbackInfo != nil { + t.Errorf("onIngressRegistered called with %v when ingress announcement failed, want nil", callbackInfo) + } + if len(dht.ingressCalls) != 1 { + t.Fatalf("ProvideWithIngress calls = %d, want 1", len(dht.ingressCalls)) + } + nameKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "ungranted") + typeKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "") + if len(dht.calls) != 2 || !bytes.Equal(dht.calls[0], nameKey) || !bytes.Equal(dht.calls[1], typeKey) { + t.Fatalf("plain Provide calls = %d, want 2 (fallback nameKey + typeKey)", len(dht.calls)) + } + + // ReprovideAll also falls back while ingress remains refused, then succeeds without fallback once granted. + if withheld := reg.ReprovideAll(context.Background()); withheld != 0 { + t.Fatalf("ReprovideAll withheld = %d, want 0", withheld) + } + if len(dht.ingressCalls) != 2 || len(dht.calls) != 4 { + t.Fatalf("after refused ReprovideAll: ingressCalls=%d calls=%d, want 2 and 4", len(dht.ingressCalls), len(dht.calls)) + } + + dht.failIngressErr = nil + if withheld := reg.ReprovideAll(context.Background()); withheld != 0 { + t.Fatalf("ReprovideAll after policy fix withheld = %d, want 0", withheld) + } + if len(dht.ingressCalls) != 3 || len(dht.calls) != 5 { + t.Fatalf("after policy fix ReprovideAll: ingressCalls=%d calls=%d, want 3 and 5 (typeKey only)", len(dht.ingressCalls), len(dht.calls)) + } +} + +func TestPassthroughWithBackendCredentialRejected(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(authFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + t.Run("CompleteNodeConfig rejects passthrough with target_auth_path", func(t *testing.T) { + cfg := api.NodeConfig{ + Services: []api.ServiceConfig{ + { + Name: "webhook", + Type: "a2a", + TargetURL: "http://127.0.0.1:8081", + TargetAuthPath: authFile, + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }, + }, + } + if _, err := CompleteNodeConfig(cfg); err == nil { + t.Fatal("expected error for passthrough + target_auth_path, got nil") + } + }) + + t.Run("CompleteNodeConfig rejects passthrough with inline target_url credential", func(t *testing.T) { + cfg := api.NodeConfig{ + Services: []api.ServiceConfig{ + { + Name: "webhook", + Type: "a2a", + TargetURL: "http://bearer:secret-token@127.0.0.1:8081", + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }, + }, + } + if _, err := CompleteNodeConfig(cfg); err == nil { + t.Fatal("expected error for passthrough + inline target_url credential, got nil") + } + }) + + t.Run("CompleteNodeConfig allows edge with target_auth_path", func(t *testing.T) { + cfg := api.NodeConfig{ + Services: []api.ServiceConfig{ + { + Name: "assistant", + Type: "a2a", + TargetURL: "http://127.0.0.1:8081", + TargetAuthPath: authFile, + Ingress: &api.IngressConfig{AuthMode: "edge"}, + }, + }, + } + if _, err := CompleteNodeConfig(cfg); err != nil { + t.Fatalf("expected edge + target_auth_path to succeed, got: %v", err) + } + }) + + t.Run("buildRegisterRequest and NewServiceFromRequest reject passthrough with credential", func(t *testing.T) { + if _, err := buildRegisterRequest(api.ServiceConfig{ + Name: "webhook", + Type: "a2a", + TargetURL: "http://127.0.0.1:8081", + TargetAuthPath: authFile, + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }); err == nil { + t.Fatal("buildRegisterRequest accepted passthrough + target_auth_path") + } + if _, err := buildRegisterRequest(api.ServiceConfig{ + Name: "webhook", + Type: "a2a", + TargetURL: "http://bearer:secret-token@127.0.0.1:8081", + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }); err == nil { + t.Fatal("buildRegisterRequest accepted passthrough + inline target_url credential") + } + if _, err := NewServiceFromRequest(&api.RegisterServiceRequest{ + Service: &api.ServiceInfo{ + Name: "webhook", + Type: api.ServiceType_SERVICE_TYPE_A2A, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }, + }, + Backend: &api.RegisterServiceRequest_TargetUrl{ + TargetUrl: "http://bearer:secret-token@127.0.0.1:8081", + }, + }); err == nil { + t.Fatal("NewServiceFromRequest accepted passthrough + inline backend credential") + } + }) +} + +func TestExternalIngressHandler(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519.GenerateKey: %v", err) + } + + routerPID, _ := mustTestPeerID(t) + otherRouterPID, _ := mustTestPeerID(t) + memberPID, _ := mustTestPeerID(t) + servingPID, servingPriv := mustTestPeerID(t) + actorPID, _ := mustTestPeerID(t) + + exp := time.Now().Add(time.Hour) + + routerBiscuit, _, err := identity.MintBiscuitToken( + cpPriv, + jwt.MapClaims{"sub": "router-1"}, + nil, + routerPID, + exp, + []string{api.RoleRouter}, + nil, + nil, + ) + if err != nil { + t.Fatalf("MintBiscuitToken router: %v", err) + } + routerB64 := base64.StdEncoding.EncodeToString(routerBiscuit) + + memberBiscuit, _, err := identity.MintBiscuitToken( + cpPriv, + jwt.MapClaims{"sub": "member-1", "email": "alice@example.com"}, + nil, + memberPID, + exp, + []string{"external-caller"}, + nil, + nil, + ) + if err != nil { + t.Fatalf("MintBiscuitToken member: %v", err) + } + memberB64 := base64.StdEncoding.EncodeToString(memberBiscuit) + + servingBiscuit, _, err := identity.MintBiscuitToken( + cpPriv, + jwt.MapClaims{"sub": "owner-1", "email": "owner@example.com"}, + nil, + servingPID, + exp, + []string{"provider"}, + nil, + nil, + ) + if err != nil { + t.Fatalf("MintBiscuitToken serving node: %v", err) + } + + policyRoles := []*api.PolicyRole{ + { + Name: "external-caller", + AllowedServices: []string{"a2a://assistant", "mcp://files"}, + AllowedTargets: []string{"email:owner@example.com"}, + }, + } + policyBindings := []*api.PolicyBinding{ + { + Role: "external-caller", + Members: []string{"email:alice@example.com", "email:bob@example.com"}, + }, + } + compiled, warnings := api.BuildPolicyRules(policyRoles, policyBindings) + if len(warnings) != 0 { + t.Fatalf("BuildPolicyRules warnings: %v", warnings) + } + meshRules, err := api.ParseDatalogRules(api.PolicyRuleTexts(compiled)) + if err != nil { + t.Fatalf("ParseDatalogRules: %v", err) + } + + delegatedAlice, err := identity.MintDelegatedBiscuitToken( + cpPriv, + jwt.MapClaims{"sub": "user-alice", "email": "alice@example.com"}, + actorPID, + exp, + []string{"external-caller"}, + policyRoles, + &api.TaskAuthorizationRule{ + Name: "task-read-only", + Rules: []*api.TaskRule{ + { + AllowedServices: []string{"a2a://assistant"}, + }, + { + AllowedServices: []string{"mcp://files"}, + Operation: &api.TaskOperation{AllowedTools: []string{"read_file"}}, + }, + }, + }, + true, + ) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken alice: %v", err) + } + delegatedAliceB64 := base64.StdEncoding.EncodeToString(delegatedAlice) + + delegatedBob, err := identity.MintDelegatedBiscuitToken( + cpPriv, + jwt.MapClaims{"sub": "user-bob", "email": "bob@example.com"}, + actorPID, + exp, + []string{"external-caller"}, + policyRoles, + nil, + true, + ) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken bob: %v", err) + } + delegatedBobB64 := base64.StdEncoding.EncodeToString(delegatedBob) + + localDenyAdmin, err := parser.FromStringPolicy(`deny if path($p), $p == "/admin"`) + if err != nil { + t.Fatalf("FromStringPolicy: %v", err) + } + + reg := newServiceRegistryForTest(&fakeDHT{}) + + var capturedHeaders http.Header + var capturedPath string + + recordHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + capturedPath = r.URL.Path + if r.URL.Path == "/.well-known/agent-card.json" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"assistant","version":"1.0.0"}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + for _, svc := range []*fakeService{ + { + info: &api.ServiceInfo{ + Name: "assistant", + Type: api.ServiceType_SERVICE_TYPE_A2A, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com"}, + }, + }, + handler: recordHandler, + }, + { + info: &api.ServiceInfo{ + Name: "files", + Type: api.ServiceType_SERVICE_TYPE_MCP, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + }, + }, + handler: recordHandler, + }, + { + info: &api.ServiceInfo{ + Name: "webhook", + Type: api.ServiceType_SERVICE_TYPE_A2A, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }, + }, + handler: recordHandler, + }, + { + info: &api.ServiceInfo{ + Name: "internal-only", + Type: api.ServiceType_SERVICE_TYPE_A2A, + }, + handler: recordHandler, + }, + } { + if err := reg.Register(context.Background(), svc); err != nil { + t.Fatalf("Register %s: %v", svc.info.Name, err) + } + } + + n := &AgentMeshNode{ + config: Options{PrivKey: servingPriv}, + trustedKeys: []TrustedKey{{Key: cpPub, ReceivedAt: time.Now()}}, + MeshPolicyRules: meshRules, + nodeConfig: &NodeConfigComplete{Policies: []biscuit.Policy{localDenyAdmin}}, + services: reg, + BiscuitTimeout: time.Second, + } + n.SetIdentityCache(servingBiscuit) + + h := n.externalIngressHandler() + + t.Run("non-router peer rejected with 403", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = memberPID.String() + req.Header.Set(api.HeaderMeshBiscuit, memberB64) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("router biscuit bound to different peer rejected with 403", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = otherRouterPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("internal-only service rejected with 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/internal-only/", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("unauthenticated A2A agent card GET allowed in edge mode, POST denied", func(t *testing.T) { + getReq := httptest.NewRequest(http.MethodGet, "http://mesh/a2a/assistant/.well-known/agent-card.json", nil) + getReq.RemoteAddr = routerPID.String() + getReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + getReq.Header.Set("Authorization", "Bearer should-be-stripped") + getReq.Header.Set("Cookie", "session=secret") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, getReq) + if rec.Code != http.StatusOK { + t.Fatalf("GET agent-card status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if capturedHeaders.Get("Authorization") != "" || capturedHeaders.Get("Cookie") != "" { + t.Errorf("expected Authorization and Cookie stripped on agent card fetch, got %v", capturedHeaders) + } + + postReq := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + postReq.RemoteAddr = routerPID.String() + postReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + postRec := httptest.NewRecorder() + h.ServeHTTP(postRec, postReq) + if postRec.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated POST status = %d, want 401: %s", postRec.Code, postRec.Body.String()) + } + }) + + t.Run("edge delegated biscuit succeeds and stamps verified caller headers", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/v1/messages", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringEdge) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + req.Header.Set("Authorization", "Bearer external-raw") + req.Header.Set("Cookie", "sid=123") + req.Header.Set(api.HeaderMeshPrincipal, "spoofed@evil.example") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if capturedPath != "/v1/messages" { + t.Errorf("backend path = %q, want /v1/messages", capturedPath) + } + if got := capturedHeaders.Get(api.HeaderMeshPrincipal); got != "alice@example.com" { + t.Errorf("X-Mesh-Principal = %q, want alice@example.com", got) + } + if got := capturedHeaders.Get(api.HeaderMeshTask); got != "task-read-only" { + t.Errorf("X-Mesh-Task = %q, want task-read-only", got) + } + if got := capturedHeaders.Get(api.HeaderMeshIngress); got != api.IngressAuthModeStringEdge { + t.Errorf("X-Mesh-Ingress = %q, want edge", got) + } + if got := capturedHeaders.Get(api.HeaderPeerID); got != routerPID.String() { + t.Errorf("X-Peer-Id = %q, want %s", got, routerPID) + } + if capturedHeaders.Get("Authorization") != "" || capturedHeaders.Get("Cookie") != "" || capturedHeaders.Get(api.HeaderMeshIngressCallerBiscuit) != "" { + t.Errorf("sensitive headers leaked to backend: %v", capturedHeaders) + } + }) + + t.Run("edge allow list rejects bob even though mesh role permits bob", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedBobB64) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("standing transport member biscuit rejected as edge caller token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngressCallerBiscuit, memberB64) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("local nodeConfig policy denies /admin on edge delegated biscuit", func(t *testing.T) { + adminReq := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/admin", strings.NewReader(`{}`)) + adminReq.RemoteAddr = routerPID.String() + adminReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + adminReq.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + adminRec := httptest.NewRecorder() + h.ServeHTTP(adminRec, adminReq) + if adminRec.Code != http.StatusForbidden { + t.Fatalf("local /admin deny status = %d, want 403: %s", adminRec.Code, adminRec.Body.String()) + } + }) + + t.Run("uninitialized service registry returns 503 and increments invalid counter", func(t *testing.T) { + uninitNode := &AgentMeshNode{ + config: Options{PrivKey: servingPriv}, + trustedKeys: []TrustedKey{{Key: cpPub, ReceivedAt: time.Now()}}, + BiscuitTimeout: time.Second, + } + before := counterValue(t, externalIngressRequestsTotal.WithLabelValues("unknown", externalIngressOutcomeInvalid)) + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + rec := httptest.NewRecorder() + uninitNode.externalIngressHandler().ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503: %s", rec.Code, rec.Body.String()) + } + after := counterValue(t, externalIngressRequestsTotal.WithLabelValues("unknown", externalIngressOutcomeInvalid)) + if after != before+1 { + t.Errorf("invalid counter = %v, want %v", after, before+1) + } + }) + + t.Run("MCP tool-level TAR enforced on external edge ingress", func(t *testing.T) { + allowBody := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read_file"}}` + allowReq := httptest.NewRequest(http.MethodPost, "http://mesh/mcp/files/", bytes.NewBufferString(allowBody)) + allowReq.RemoteAddr = routerPID.String() + allowReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + allowReq.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + allowRec := httptest.NewRecorder() + h.ServeHTTP(allowRec, allowReq) + if allowRec.Code != http.StatusOK { + t.Fatalf("allowed MCP tool status = %d, want 200: %s", allowRec.Code, allowRec.Body.String()) + } + + denyBody := `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_file"}}` + denyReq := httptest.NewRequest(http.MethodPost, "http://mesh/mcp/files/", bytes.NewBufferString(denyBody)) + denyReq.RemoteAddr = routerPID.String() + denyReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + denyReq.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) + denyRec := httptest.NewRecorder() + h.ServeHTTP(denyRec, denyReq) + if denyRec.Code != http.StatusForbidden { + t.Fatalf("denied MCP tool status = %d, want 403: %s", denyRec.Code, denyRec.Body.String()) + } + }) + + t.Run("passthrough preserves Authorization and strips Cookie and X-Mesh-*", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/webhook/events", strings.NewReader(`{"event":"ping"}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringPassthrough) + req.Header.Set("Authorization", "Bearer backend-app-secret") + req.Header.Set("X-Hub-Signature-256", "sha256=abcdef") + req.Header.Set("Cookie", "evil=cookie") + req.Header.Set(api.HeaderMeshPrincipal, "spoofed@evil.example") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("passthrough status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if got := capturedHeaders.Get("Authorization"); got != "Bearer backend-app-secret" { + t.Errorf("Authorization = %q, want Bearer backend-app-secret", got) + } + if got := capturedHeaders.Get("X-Hub-Signature-256"); got != "sha256=abcdef" { + t.Errorf("X-Hub-Signature-256 = %q, want sha256=abcdef", got) + } + if got := capturedHeaders.Get(api.HeaderMeshIngress); got != api.IngressAuthModeStringPassthrough { + t.Errorf("X-Mesh-Ingress = %q, want passthrough", got) + } + if capturedHeaders.Get("Cookie") != "" || capturedHeaders.Get(api.HeaderMeshPrincipal) != "" { + t.Errorf("expected Cookie and X-Mesh-Principal stripped in passthrough, got %v", capturedHeaders) + } + }) + + t.Run("mode mismatch header rejected fail-closed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + req.RemoteAddr = routerPID.String() + req.Header.Set(api.HeaderMeshBiscuit, routerB64) + req.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringPassthrough) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("mode mismatch status = %d, want 403: %s", rec.Code, rec.Body.String()) + } + }) +} diff --git a/internal/node/node.go b/internal/node/node.go index 66442707..d1e1a61c 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -131,13 +131,43 @@ func (a *nodeRelayACL) AllowConnect(src peer.ID, srcAddr multiaddr.Multiaddr, de // has not lapsed since. The handshake only proves the token was valid at that // instant, so without this the relay ACL would honour an admission forever. func (n *AgentMeshNode) isAdmitted(p peer.ID) bool { - v, ok := n.authPeers.Load(p) + return admissionHolds(&n.authPeers, p) +} + +// isAdmittedRouter reports whether a peer completed the auth handshake with a +// credential carrying role("mesh:role:router") and that admission holds. It +// requires isAdmitted too, so dropping a peer from authPeers (a ban, an +// eviction) withdraws its router standing with it. +func (n *AgentMeshNode) isAdmittedRouter(p peer.ID) bool { + return n.isAdmitted(p) && admissionHolds(&n.authRouters, p) +} + +// admitPeer records a completed handshake: every admitted peer in authPeers, +// and a router additionally in authRouters. A peer re-admitted without the +// role loses it. +func (n *AgentMeshNode) admitPeer(p peer.ID, expiry time.Time, router bool) { + n.authPeers.Store(p, expiry) + if router { + n.authRouters.Store(p, expiry) + } else { + n.authRouters.Delete(p) + } +} + +// forgetAdmission drops every record of p's handshake. +func (n *AgentMeshNode) forgetAdmission(p peer.ID) { + n.authPeers.Delete(p) + n.authRouters.Delete(p) +} + +func admissionHolds(m *sync.Map, p peer.ID) bool { + v, ok := m.Load(p) if !ok { return false } expiry, ok := v.(time.Time) if !ok || !time.Now().Before(expiry) { - n.authPeers.Delete(p) + m.Delete(p) return false } return true @@ -183,12 +213,17 @@ type AgentMeshNode struct { delegatedBiscuitCache *lru.Cache[string, *api.TokenExchangeResponse] stsTokenCache *lru.Cache[string, *api.STSTokenResponse] peerLabelGate *lru.Cache[string, time.Time] - authPeers sync.Map - trustedKeys []TrustedKey - keysMu sync.RWMutex - MeshPolicyRules []biscuit.Rule - meshPolicyTexts []string - MeshPolicyMu sync.RWMutex + // authPeers is every peer that completed the handshake with this node, + // keyed by peer.ID, with the instant its admission lapses. authRouters + // holds the subset whose credential carried mesh:role:router, with the + // same instant, for the protocols only a router may open (routerHost). + authPeers sync.Map + authRouters sync.Map + trustedKeys []TrustedKey + keysMu sync.RWMutex + MeshPolicyRules []biscuit.Rule + meshPolicyTexts []string + MeshPolicyMu sync.RWMutex // pendingEgress holds assignments that arrived before Start created the // service registry (SyncControlPlane runs first); Start applies them. pendingEgress []*api.EgressDestination @@ -198,10 +233,14 @@ type AgentMeshNode struct { // rateLimiter, so a peer's authenticated traffic cannot starve its own // re-authentication and vice versa. handshakeLimiter *ratelimit.PeerRateLimiter - services *ServiceRegistry - BoundHTTPAddr string - BoundSocketPath string - AllowLoopback bool + // externalIngressLimiter bounds /mesh/ingress requests per forwarding router + // separately from peer-to-peer rateLimiter, because a single router + // multiplexes external HTTP traffic for many external callers. + externalIngressLimiter *ratelimit.PeerRateLimiter + services *ServiceRegistry + BoundHTTPAddr string + BoundSocketPath string + AllowLoopback bool authSuccess chan struct{} authOnce sync.Once @@ -450,6 +489,10 @@ func NewAgentMeshNode(cfg Options) (*AgentMeshNode, error) { if err != nil { return nil, fmt.Errorf("failed to create handshake rate limiter: %w", err) } + node.externalIngressLimiter, err = ratelimit.NewPeerRateLimiterWithRate(RateLimiterSize, 200, 400) + if err != nil { + return nil, fmt.Errorf("failed to create external ingress rate limiter: %w", err) + } node.revokedPeers, err = lru.New[string, int64](RevocationCacheSize) if err != nil { return nil, fmt.Errorf("failed to create revocation cache: %w", err) @@ -696,6 +739,7 @@ func (n *AgentMeshNode) Start(ctx context.Context) error { n.services = NewServiceRegistry(n.Directory, n.config.BackendProbeTimeout) n.services.reprovideNow = n.triggerReprovide + n.services.onIngressRegistered = n.logIngressURLs n.applyPendingEgress(ctx) if _, err := n.admitByRouters(ctx); err != nil { @@ -776,6 +820,9 @@ func (n *AgentMeshNode) Start(ctx context.Context) error { if err := n.StartIngressServer(ctx); err != nil { return fmt.Errorf("failed to start ingress server: %w", err) } + if err := n.StartExternalIngressServer(ctx); err != nil { + return fmt.Errorf("failed to start external ingress server: %w", err) + } // Start connection monitor n.startConnectionMonitor(ctx, n.config.MonitorBootstrap, n.config.MonitorInterval, 3) @@ -2061,15 +2108,24 @@ func (n *AgentMeshNode) HandleAuthHandshake(s network.Stream) { } // One verifier call enforces signature, expiry and the authority-block peer - // binding, and reports when the admission lapses. - expiry, err := identity.VerifyBiscuitAndGetExpiry(exchange.Biscuit, remotePeer, n.getTrustedPublicKeys(), n.BiscuitTimeout) + // binding, and reports when the admission lapses, together with the key + // that verified so the role can be read under that same key. + b, verifyingKey, expiry, err := identity.VerifyBiscuitAdmission(exchange.Biscuit, remotePeer, n.getTrustedPublicKeys(), n.BiscuitTimeout) if err != nil { logger.Warnf("[AuthN] Authorization failed for %s: %v", remotePeer, err) return } - n.authPeers.Store(remotePeer, expiry) - logger.Infof("[AuthN] Successfully authenticated peer %s", remotePeer) + // A router that connects to this node (to forward external ingress, for + // one) is admitted like any peer, and additionally remembered as a router + // so routerHost protocols can serve it and refuse everyone else. + isRouter := identity.RequireRole(b, verifyingKey, api.RoleRouter, n.BiscuitTimeout) == nil + n.admitPeer(remotePeer, expiry, isRouter) + if isRouter { + logger.Infof("[AuthN] Successfully authenticated peer %s as router", remotePeer) + } else { + logger.Infof("[AuthN] Successfully authenticated peer %s", remotePeer) + } // Mutual response with our identity, mirroring the router handler, so // peers can verify this node's attested facts (e.g. region). diff --git a/internal/node/protocol_inventory_test.go b/internal/node/protocol_inventory_test.go new file mode 100644 index 00000000..dfa68322 --- /dev/null +++ b/internal/node/protocol_inventory_test.go @@ -0,0 +1,279 @@ +// 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 node + +import ( + "bufio" + "context" + "net/http" + "slices" + "testing" + "time" + + "github.com/libp2p/go-libp2p" + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" + "github.com/libp2p/go-libp2p/p2p/protocol/autonatv2" + circuit "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/proto" + "github.com/libp2p/go-libp2p/p2p/protocol/identify" + "github.com/libp2p/go-libp2p/p2p/protocol/ping" + "github.com/libp2p/go-msgio" + "google.golang.org/protobuf/proto" + + "github.com/google/agentmesh/api" +) + +// Every protocol a started node registers on its libp2p host belongs to +// exactly one of these sets. A protocol in none of them fails +// TestNodeProtocolInventoryIsClassified: whoever adds a stream handler has to +// say here how an unauthenticated peer is kept out of it, and the gated set +// is then checked live, not by reading the code. +var ( + // handshakeProtocols are the only protocols open to a peer that has not + // authenticated: what the handshake itself needs (identify, the mesh + // handshake) and libp2p's own reachability plumbing. Nothing here reads + // mesh data. + handshakeProtocols = []protocol.ID{ + api.AuthProtocolID, + identify.ID, + identify.IDPush, + ping.ID, + autonatv2.DialProtocol, + autonatv2.DialBackProtocol, + circuit.ProtoIDv2Stop, + } + + // credentialProtocols authenticate inside the stream: the first frame or + // request carries a Biscuit, and the handler refuses the stream itself + // when it does not verify. Each has its own test of that refusal. + credentialProtocols = []protocol.ID{ + api.MCPProtocolID, + api.GoAwayProtocolID, + "/libp2p-http", + } + + // gatedProtocols are served through meshHost or routerHost: a stream from + // a peer that has not completed the handshake is reset and counted before + // the handler sees it. The test below opens each one from a stranger and + // checks exactly that. + gatedProtocols = []protocol.ID{ + api.IngressProtocolID, + pubsub.FloodSubID, + api.DHTProtocolID, + } +) + +func TestNodeProtocolInventoryIsClassified(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + r := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + node := r.startNode(t, ctx, r.routerAddrs) + + registered := node.Host.Mux().Protocols() + slices.Sort(registered) + var unclassified []protocol.ID + var gated []protocol.ID + for _, p := range registered { + switch { + case slices.Contains(handshakeProtocols, p), slices.Contains(credentialProtocols, p): + case slices.Contains(gatedProtocols, p): + gated = append(gated, p) + default: + unclassified = append(unclassified, p) + } + } + if len(unclassified) > 0 { + t.Fatalf("node registers protocols that no set in protocol_inventory_test.go names: %v\n"+ + "Add each to handshakeProtocols (open before the handshake, nothing else may be), "+ + "credentialProtocols (the handler verifies a Biscuit in the stream) or "+ + "gatedProtocols (registered through meshHost/routerHost).", unclassified) + } + if !slices.Contains(gated, api.IngressProtocolID) { + t.Fatalf("%s is not registered on a started node; registered: %v", api.IngressProtocolID, registered) + } + + stranger, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stranger.Close() }) + if err := stranger.Connect(ctx, peer.AddrInfo{ID: node.Host.ID(), Addrs: node.Host.Addrs()}); err != nil { + t.Fatal(err) + } + + for _, p := range gated { + t.Run(string(p), func(t *testing.T) { + before := counterValue(t, streamsRefusedTotal.WithLabelValues(streamScopeUnauthenticated, string(p))) + + s, err := stranger.NewStream(ctx, node.Host.ID(), p) + if err != nil { + t.Fatalf("open %s: %v", p, err) + } + defer func() { _ = s.Reset() }() + if _, err := s.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\n\r\n")); err != nil { + t.Fatalf("write: %v", err) + } + // The gate holds the stream for the handshake; nothing may come + // back meanwhile, whatever the handler behind it would answer. + buf := make([]byte, 64) + _ = s.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + if n, err := s.Read(buf); err == nil { + t.Fatalf("%s answered %q to a peer that never authenticated", p, buf[:n]) + } + + // Dropping the connection ends the wait: the gate refuses the + // stream and counts it under this protocol. + if err := stranger.Network().ClosePeer(node.Host.ID()); err != nil { + t.Fatal(err) + } + waitForCounter(t, func() float64 { + return counterValue(t, streamsRefusedTotal.WithLabelValues(streamScopeUnauthenticated, string(p))) + }, before+1, 5*time.Second) + + if err := stranger.Connect(ctx, peer.AddrInfo{ID: node.Host.ID(), Addrs: node.Host.Addrs()}); err != nil { + t.Fatal(err) + } + }) + } +} + +// /mesh/ingress/1.0.0 is served only to a router. A member that completed +// the handshake with the node is still refused at the gate, before the HTTP +// server behind it reads a byte, and counted; a peer admitted as a router +// reaches the handler. +func TestExternalIngressProtocolServesOnlyRouters(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + r := newFakeRouter(t, "/ip4/127.0.0.1/tcp/0") + node := r.startNode(t, ctx, r.routerAddrs) + + request := "GET /a2a/assistant/.well-known/agent-card.json HTTP/1.1\r\nHost: mesh\r\n\r\n" + + t.Run("member is refused at the gate", func(t *testing.T) { + member := newAdmittedPeer(t, ctx, node, r, api.RoleNode) + if !node.isAdmitted(member.ID()) || node.isAdmittedRouter(member.ID()) { + t.Fatalf("member admitted=%v router=%v, want true/false", node.isAdmitted(member.ID()), node.isAdmittedRouter(member.ID())) + } + before := counterValue(t, streamsRefusedTotal.WithLabelValues(streamScopeNotRouter, string(api.IngressProtocolID))) + + s, err := member.NewStream(ctx, node.Host.ID(), api.IngressProtocolID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Reset() }() + if _, err := s.Write([]byte(request)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 64) + _ = s.SetReadDeadline(time.Now().Add(5 * time.Second)) + if n, err := s.Read(buf); err == nil { + t.Fatalf("ingress protocol answered a member %q; the gate must reset the stream", buf[:n]) + } + waitForCounter(t, func() float64 { + return counterValue(t, streamsRefusedTotal.WithLabelValues(streamScopeNotRouter, string(api.IngressProtocolID))) + }, before+1, 5*time.Second) + }) + + t.Run("router reaches the handler", func(t *testing.T) { + router := newAdmittedPeer(t, ctx, node, r, api.RoleRouter) + if !node.isAdmittedRouter(router.ID()) { + t.Fatal("peer admitted with the router role is not recorded as a router") + } + + s, err := router.NewStream(ctx, node.Host.ID(), api.IngressProtocolID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + if _, err := s.Write([]byte(request)); err != nil { + t.Fatal(err) + } + _ = s.SetReadDeadline(time.Now().Add(5 * time.Second)) + resp, err := http.ReadResponse(bufio.NewReader(s), nil) + if err != nil { + t.Fatalf("read HTTP response from ingress handler: %v", err) + } + defer func() { _ = resp.Body.Close() }() + // The handler ran: with no X-Mesh-Biscuit it refuses the request + // itself, which is the second layer behind the gate. + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d from the handler's own credential check", resp.StatusCode, http.StatusUnauthorized) + } + }) + + t.Run("ban withdraws router standing", func(t *testing.T) { + router := newAdmittedPeer(t, ctx, node, r, api.RoleRouter) + node.banPeer(router.ID(), time.Now().UnixMilli()) + if node.isAdmitted(router.ID()) || node.isAdmittedRouter(router.ID()) { + t.Fatal("banned peer still admitted") + } + }) +} + +// newAdmittedPeer is a libp2p host that connects to node and completes the +// mesh handshake with a credential the fake router's control plane key signed +// for the given role. +func newAdmittedPeer(t *testing.T, ctx context.Context, node *AgentMeshNode, r *fakeRouter, role string) host.Host { + t.Helper() + h, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = h.Close() }) + if err := h.Connect(ctx, peer.AddrInfo{ID: node.Host.ID(), Addrs: node.Host.Addrs()}); err != nil { + t.Fatal(err) + } + s, err := h.NewStream(ctx, node.Host.ID(), api.AuthProtocolID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = s.Close() }() + frame, err := proto.Marshal(&api.AuthFrame{Biscuit: r.mint(h.ID().String(), role)}) + if err != nil { + t.Fatal(err) + } + if err := msgio.NewVarintWriter(s).WriteMsg(frame); err != nil { + t.Fatal(err) + } + reply, err := msgio.NewVarintReaderSize(s, 1024*64).ReadMsg() + if err != nil { + t.Fatalf("handshake reply: %v", err) + } + var resp api.AuthResponse + if err := proto.Unmarshal(reply, &resp); err != nil || !resp.Success { + t.Fatalf("handshake refused: %v %s", err, resp.GetError()) + } + return h +} + +// waitForCounter polls get until it reaches want, and reports the last value +// it saw if the deadline passes first. +func waitForCounter(t *testing.T, get func() float64, want float64, within time.Duration) { + t.Helper() + deadline := time.Now().Add(within) + var got float64 + for time.Now().Before(deadline) { + got = get() + if got >= want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("counter = %v, want %v", got, want) +} diff --git a/internal/node/resource_limits.go b/internal/node/resource_limits.go index 6b1cfa3d..f2cfcfa0 100644 --- a/internal/node/resource_limits.go +++ b/internal/node/resource_limits.go @@ -113,6 +113,7 @@ func nodeLimits(conns int) rcmgr.ConcreteLimitConfig { overrides.Protocol[p] = p2plimits.Streams(conns) } overrides.Protocol[api.MCPProtocolID] = p2plimits.Streams(streamsPerConn * conns) + overrides.Protocol[api.IngressProtocolID] = p2plimits.Streams(streamsPerConn * conns) // A caller that arrives through a router is one relay stop stream, and // every caller through the same router is a stream from that one peer. @@ -122,7 +123,10 @@ func nodeLimits(conns int) rcmgr.ConcreteLimitConfig { stop := p2plimits.Streams(conns) stop.Memory = rcmgr.LimitVal64(int64(conns) * 64 << 10) overrides.Protocol[circuit.ProtoIDv2Stop] = stop - overrides.ProtocolPeer = map[protocol.ID]rcmgr.ResourceLimits{circuit.ProtoIDv2Stop: stop} + overrides.ProtocolPeer = map[protocol.ID]rcmgr.ResourceLimits{ + circuit.ProtoIDv2Stop: stop, + api.IngressProtocolID: p2plimits.Streams(conns), + } return overrides.Build(scaled) } diff --git a/internal/node/routers.go b/internal/node/routers.go index 2e1cf4ea..4c063901 100644 --- a/internal/node/routers.go +++ b/internal/node/routers.go @@ -41,6 +41,7 @@ type routerCandidate struct { Labels map[string]string Connections int ConnectionLimit int + IngressURL string } // loadRatio is how full the router is, 0 to 1; 0.5 when its last lease @@ -88,6 +89,7 @@ func candidatesFromInfo(ctx context.Context, info *api.ControlPlaneInfoResponse) Labels: r.GetLabels(), Connections: int(r.GetConnections()), ConnectionLimit: int(r.GetConnectionLimit()), + IngressURL: r.GetIngressUrl(), }) } } diff --git a/internal/node/service.go b/internal/node/service.go index 278a6cb0..0600c02b 100644 --- a/internal/node/service.go +++ b/internal/node/service.go @@ -132,6 +132,20 @@ func NewServiceFromRequest(req *api.RegisterServiceRequest) (Service, error) { if c, ok := req.Backend.(*api.RegisterServiceRequest_Command); ok && len(c.Command.GetCommand()) == 0 { return nil, fmt.Errorf("service %q: command backend has no command", info.GetName()) } + if err := api.ValidateServiceIngress(info.GetType(), info.GetIngress()); err != nil { + return nil, fmt.Errorf("service %q: %w", info.GetName(), err) + } + if info.GetIngress() != nil && api.EffectiveIngressAuthMode(info.GetIngress()) == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + if t, ok := req.Backend.(*api.RegisterServiceRequest_TargetUrl); ok { + bt, err := parseBackendTarget(t.TargetUrl) + if err != nil { + return nil, fmt.Errorf("service %q: %w", info.GetName(), err) + } + if bt.auth != "" { + return nil, fmt.Errorf("service %q: ingress auth_mode %q cannot be combined with a backend credential (would expose backend credential without caller authentication)", info.GetName(), api.IngressAuthModeStringPassthrough) + } + } + } switch info.Type { case api.ServiceType_SERVICE_TYPE_MCP: return &MCPService{baseService: baseService{info: info, backend: req.Backend}}, nil @@ -153,11 +167,26 @@ func buildRegisterRequest(sCfg api.ServiceConfig) (*api.RegisterServiceRequest, if err != nil { return nil, fmt.Errorf("invalid service type %q for service %s: %w", sCfg.Type, sCfg.Name, err) } + ing, err := api.ServiceIngressFromConfig(sType, sCfg.Ingress) + if err != nil { + return nil, fmt.Errorf("service %s: %w", sCfg.Name, err) + } + if ing != nil && api.EffectiveIngressAuthMode(ing) == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + if sCfg.TargetAuthPath != "" { + return nil, fmt.Errorf("service %s: ingress auth_mode %q cannot be combined with target_auth_path (would expose backend credential without caller authentication)", sCfg.Name, api.IngressAuthModeStringPassthrough) + } + if sCfg.TargetURL != "" { + if bt, pErr := parseBackendTarget(sCfg.TargetURL); pErr == nil && bt.auth != "" { + return nil, fmt.Errorf("service %s: ingress auth_mode %q cannot be combined with an inline target_url credential (would expose backend credential without caller authentication)", sCfg.Name, api.IngressAuthModeStringPassthrough) + } + } + } req := &api.RegisterServiceRequest{ Service: &api.ServiceInfo{ Type: sType, Name: sCfg.Name, Description: sCfg.Description, + Ingress: ing, }, } switch { diff --git a/internal/node/service_registry.go b/internal/node/service_registry.go index 78d74097..2f8064a8 100644 --- a/internal/node/service_registry.go +++ b/internal/node/service_registry.go @@ -23,8 +23,30 @@ import ( "time" "github.com/google/agentmesh/api" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" ) +const ( + provideFailureScopeIngress = "ingress" + provideFailureScopeName = "name" + provideFailureScopeType = "type" +) + +var serviceProvideFailuresTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_node_service_provide_failures_total", + Help: "Failed directory announcements for local services, by announcement scope", + }, + []string{"scope"}, +) + +func init() { + for _, scope := range []string{provideFailureScopeIngress, provideFailureScopeName, provideFailureScopeType} { + serviceProvideFailuresTotal.WithLabelValues(scope) + } +} + // directoryProvider is what ServiceRegistry asks of the directory: to // announce a key and to withdraw it. Production wires this to the node's // Directory; tests use a fake. @@ -33,6 +55,10 @@ type directoryProvider interface { Withdraw(ctx context.Context, key []byte) error } +type ingressDirectoryProvider interface { + ProvideWithIngress(ctx context.Context, key []byte, service string, ingress *api.ServiceIngress) error +} + // backendProber is implemented by services that can be asked whether their // backend is really serving. Services that cannot be asked are advertised // unconditionally. @@ -79,6 +105,10 @@ type ServiceRegistry struct { // advertised. Optional; nil outside a running node. reprovideNow func() + // onIngressRegistered is called when an ingress-enabled service is + // registered so the node can log its public router ingress URLs. + onIngressRegistered func(info *api.ServiceInfo) + // backendProbeTimeout bounds each backend probe in advertisable. Set once // at construction (see NewServiceRegistry); never mutated afterwards, so // reading it needs no lock. @@ -126,6 +156,9 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { if err := api.ValidateServiceFormat(typeStr + "://" + info.Name); err != nil { return fmt.Errorf("invalid service name %q: %w", info.Name, err) } + if err := api.ValidateServiceIngress(info.Type, info.GetIngress()); err != nil { + return fmt.Errorf("service %q: %w", info.Name, err) + } // Policy is evaluated on type://name and the registry is keyed on name, // so a second type under the same name would let a grant for one reach @@ -151,20 +184,11 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { } probeErr := advertisable(ctx, svc, r.backendProbeTimeout) + ingressAnnounced := false if probeErr != nil { logger.Warnf("[ServiceRegistry] Registered %s/%s but not advertising it: backend did not answer: %v", info.Type, info.Name, probeErr) } else { - nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) - defer nameCancel() - if err := r.dht.Provide(nameCtx, nameKey, serviceTarget(info)); err != nil { - logger.Warnf("[ServiceRegistry] Announcing %s by name: %v", info.Name, err) - } - - typeCtx, typeCancel := context.WithTimeout(ctx, 5*time.Second) - defer typeCancel() - if err := r.dht.Provide(typeCtx, typeKey, serviceTarget(info)); err != nil { - logger.Warnf("[ServiceRegistry] Announcing %s by type: %v", info.Name, err) - } + ingressAnnounced = r.announceServiceKeys(ctx, info, nameKey, typeKey) } r.mu.Lock() @@ -180,6 +204,9 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { if probeErr == nil { logger.Infof("[ServiceRegistry] Registered and announced %s/%s", info.Type, info.Name) + if ingressAnnounced && r.onIngressRegistered != nil { + r.onIngressRegistered(info) + } } else if r.reprovideNow != nil { r.reprovideNow() } @@ -218,6 +245,69 @@ func (r *ServiceRegistry) Unregister(ctx context.Context, name string) error { return nil } +// announceServiceKeys publishes the service's name and type directory keys. +// When an external ingress announcement is refused or fails, it logs an error, +// increments agentmesh_node_service_provide_failures_total{scope="ingress"}, +// and immediately falls back to an internal-only announcement without ingress +// metadata so in-mesh discovery continues working. It returns true iff the +// service declared ingress and its ingress directory announcement succeeded. +func (r *ServiceRegistry) announceServiceKeys(ctx context.Context, info *api.ServiceInfo, nameKey, typeKey []byte) bool { + target := serviceTarget(info) + ing := info.GetIngress() + ingressAnnounced := false + + if ing != nil { + if ip, ok := r.dht.(ingressDirectoryProvider); ok { + ingCtx, ingCancel := context.WithTimeout(ctx, 5*time.Second) + err := ip.ProvideWithIngress(ingCtx, nameKey, target, ing) + ingCancel() + if err == nil { + ingressAnnounced = true + } else { + if ctx.Err() == nil { + serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeIngress).Inc() + logger.Errorf("[ServiceRegistry] External ingress announcement for %s/%s failed; falling back to internal-only directory announcement: %v", info.Type, info.Name, err) + } + nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) + fallbackErr := r.dht.Provide(nameCtx, nameKey, target) + nameCancel() + if fallbackErr != nil && ctx.Err() == nil { + serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeName).Inc() + logger.Errorf("[ServiceRegistry] Announcing %s/%s by name (internal fallback): %v", info.Type, info.Name, fallbackErr) + } + } + } else { + nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) + err := r.dht.Provide(nameCtx, nameKey, target) + nameCancel() + if err == nil { + ingressAnnounced = true + } else if ctx.Err() == nil { + serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeName).Inc() + logger.Errorf("[ServiceRegistry] Announcing %s/%s by name: %v", info.Type, info.Name, err) + } + } + } else { + nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) + err := r.dht.Provide(nameCtx, nameKey, target) + nameCancel() + if err != nil && ctx.Err() == nil { + serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeName).Inc() + logger.Errorf("[ServiceRegistry] Announcing %s/%s by name: %v", info.Type, info.Name, err) + } + } + + typeCtx, typeCancel := context.WithTimeout(ctx, 5*time.Second) + typeErr := r.dht.Provide(typeCtx, typeKey, target) + typeCancel() + if typeErr != nil && ctx.Err() == nil { + serviceProvideFailuresTotal.WithLabelValues(provideFailureScopeType).Inc() + logger.Errorf("[ServiceRegistry] Announcing %s/%s by type: %v", info.Type, info.Name, typeErr) + } + + return ingressAnnounced +} + // serviceTarget is a service as a record names it, "://". func serviceTarget(info *api.ServiceInfo) string { typeStr, err := api.ServiceTypeToString(info.GetType()) @@ -362,17 +452,15 @@ Loop: return } - for _, name := range []string{info.Name, ""} { - key, err := api.ServiceKey(info.Type, name) - if err != nil { - continue - } - provideCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - if err := r.dht.Provide(provideCtx, key, serviceTarget(info)); err != nil && ctx.Err() == nil { - logger.Warnf("[ServiceRegistry] Announcing %s/%s again: %v", info.Type, info.Name, err) - } - cancel() + nameKey, err := api.ServiceKey(info.Type, info.Name) + if err != nil { + return + } + typeKey, err := api.ServiceKey(info.Type, "") + if err != nil { + return } + r.announceServiceKeys(ctx, info, nameKey, typeKey) }() } wg.Wait() diff --git a/internal/node/sts_test.go b/internal/node/sts_test.go index 08cdf1c9..21fa80bd 100644 --- a/internal/node/sts_test.go +++ b/internal/node/sts_test.go @@ -830,6 +830,30 @@ func TestInspectMCPHTTPRequestBody(t *testing.T) { if err != nil || tool != "" || !allowInit { t.Fatalf("initialize: got tool=%q allowInit=%v err=%v, want allowInit=true", tool, allowInit, err) } + + notifReq := httptest.NewRequest(http.MethodPost, "/mcp/github", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)) + tool, allowInit, err = inspectMCPHTTPRequestBody(notifReq) + if err != nil || tool != "" || !allowInit { + t.Fatalf("notifications/initialized: got tool=%q allowInit=%v err=%v, want allowInit=true", tool, allowInit, err) + } + + discoverReq := httptest.NewRequest(http.MethodPost, "/mcp/github", strings.NewReader(`{"jsonrpc":"2.0","id":0,"method":"server/discover"}`)) + tool, allowInit, err = inspectMCPHTTPRequestBody(discoverReq) + if err != nil || tool != "" || !allowInit { + t.Fatalf("server/discover: got tool=%q allowInit=%v err=%v, want allowInit=true", tool, allowInit, err) + } + + sseReq := httptest.NewRequest(http.MethodGet, "/mcp/github", nil) + tool, allowInit, err = inspectMCPHTTPRequestBody(sseReq) + if err != nil || tool != "" || !allowInit { + t.Fatalf("GET SSE stream: got tool=%q allowInit=%v err=%v, want allowInit=true", tool, allowInit, err) + } + + unknownReq := httptest.NewRequest(http.MethodPost, "/mcp/github", strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"resources/read"}`)) + tool, allowInit, err = inspectMCPHTTPRequestBody(unknownReq) + if err != nil || tool != "" || allowInit { + t.Fatalf("resources/read: got tool=%q allowInit=%v err=%v, want allowInit=false", tool, allowInit, err) + } } func TestCrossNodeBiscuitAndBannedPeerRejection(t *testing.T) { diff --git a/internal/router/config.go b/internal/router/config.go index 382ee4eb..dceb38de 100644 --- a/internal/router/config.go +++ b/internal/router/config.go @@ -16,7 +16,9 @@ package router import ( "fmt" + "net" "net/http" + "strings" "time" "github.com/google/agentmesh/api" @@ -117,6 +119,32 @@ type Options struct { // plain HTTP listener separate from the libp2p ports. Off by default: // the listener is unauthenticated, so the operator names where it binds. MetricsAddr string + // IngressAddr, when set, serves external HTTP ingress under /ingress/ on a + // dedicated HTTP/HTTPS listener. Off by default on standalone routers; + // non-loopback addresses require TLS cert/key or AllowInsecureIngressHTTP. + IngressAddr string + // IngressURL is the public base URL advertised in RouterLeaseRequest and + // rewritten into A2A AgentCards and RFC 9728 metadata (e.g. + // "https://bananas.sam-mesh.dev/ingress"). + IngressURL string + // IngressTLSCertPath and IngressTLSKeyPath enable TLS on IngressAddr. + IngressTLSCertPath string + IngressTLSKeyPath string + // AllowInsecureIngressHTTP permits a plaintext HTTP IngressAddr on a + // non-loopback interface (for example behind a TLS-terminating L7 Gateway) + // and permits http:// in IngressURL on non-loopback hosts. + AllowInsecureIngressHTTP bool + // PublicControlPlaneURL optionally overrides ControlPlaneURL in external + // RFC 9728 and A2A OAuth discovery metadata when ControlPlaneURL is a + // cluster-internal or loopback address. + PublicControlPlaneURL string + // AdvertiseSinglePortIngress reports that HTTPFallbackHandler mounts + // IngressHandler() under /ingress/ on the router's WebSocket port + // (agentmesh-one single-port mode). + AdvertiseSinglePortIngress bool + // SinglePortBindHost is the configured bind host paired with the bound WS + // port when AdvertiseSinglePortIngress is set and IngressURL is empty. + SinglePortBindHost string } // Default sets default values for options. @@ -175,5 +203,52 @@ func (o *Options) Validate() error { return fmt.Errorf("external address %q: %w", s, err) } } + if (o.IngressTLSCertPath == "") != (o.IngressTLSKeyPath == "") { + return fmt.Errorf("both IngressTLSCertPath and IngressTLSKeyPath must be specified together") + } + if o.IngressAddr != "" { + host, _, err := net.SplitHostPort(o.IngressAddr) + if err != nil { + return fmt.Errorf("ingress address %q: %w", o.IngressAddr, err) + } + if o.IngressTLSCertPath == "" && !o.AllowInsecureIngressHTTP && !isLoopbackBindHost(host) { + return fmt.Errorf("non-loopback IngressAddr %q requires TLS (--ingress-tls-cert-path / --ingress-tls-key-path) or --allow-insecure-ingress-http when behind a TLS-terminating proxy", o.IngressAddr) + } + } + normalizedIngressURL, err := api.ValidateIngressURL(o.IngressURL) + if err != nil { + return err + } + if normalizedIngressURL != "" { + o.IngressURL = normalizedIngressURL + if strings.HasPrefix(normalizedIngressURL, "http://") && !o.AllowInsecureIngressHTTP { + uHost := strings.TrimPrefix(normalizedIngressURL, "http://") + if idx := strings.IndexByte(uHost, '/'); idx >= 0 { + uHost = uHost[:idx] + } + if h, _, splitErr := net.SplitHostPort(uHost); splitErr == nil { + uHost = h + } else { + uHost = strings.Trim(uHost, "[]") + } + if !isLoopbackBindHost(uHost) { + return fmt.Errorf("non-loopback http:// IngressURL %q requires https:// or --allow-insecure-ingress-http", o.IngressURL) + } + } + } + if normalizedPublicCP, err := api.ValidateIngressURL(o.PublicControlPlaneURL); err != nil { + return fmt.Errorf("public_control_plane_url: %w", err) + } else if normalizedPublicCP != "" { + o.PublicControlPlaneURL = normalizedPublicCP + } return api.ValidateControlPlaneTransport(o.ControlPlaneURL, o.AllowInsecureControlPlane) } + +func isLoopbackBindHost(host string) bool { + h := strings.TrimSpace(host) + if strings.EqualFold(h, "localhost") { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/router/directory.go b/internal/router/directory.go index a4f01c3f..d50307c6 100644 --- a/internal/router/directory.go +++ b/internal/router/directory.go @@ -15,6 +15,7 @@ package router import ( + "bytes" "context" "errors" "fmt" @@ -269,6 +270,28 @@ func (r *Router) requirePublish(provider peer.ID, svcType api.ServiceType, svcNa return nil } +// requireIngress checks that the admitted member's credential grants exposing +// svcType://svcName on router HTTP ingress with the requested auth mode. +func (r *Router) requireIngress(provider peer.ID, svcType api.ServiceType, svcName string, mode api.IngressAuthMode) error { + typeStr, err := api.ServiceTypeToString(svcType) + if err != nil { + return invalidDirectoryRequest("%w", err) + } + modeStr, err := api.IngressAuthModeToString(mode) + if err != nil { + return invalidDirectoryRequest("%w", err) + } + v, ok := r.credentials.Load(provider) + if !ok { + return &directoryDeniedError{service: typeStr + "://" + svcName, err: errors.New("no credential held for the member")} + } + cred := v.(memberCredential) + if err := identity.RequireIngress(cred.biscuit, cred.key, typeStr, svcName, modeStr, r.meshPolicyRules(), r.config.BiscuitTimeout); err != nil { + return &directoryDeniedError{service: typeStr + "://" + svcName, err: err} + } + return nil +} + // recordProvider is whose record a provide or withdraw is about: the // sender's own, or the member a peer router names, which only a router may. func (r *Router) recordProvider(remote peer.ID, named string) (provider peer.ID, placed bool, err error) { @@ -312,14 +335,41 @@ func (r *Router) directoryProvide(ctx context.Context, remote peer.ID, q *api.Di if err := r.requirePublish(provider, svcType, svcName); err != nil { return nil, err } + if q.GetIngress() != nil { + if svcName == "" { + return nil, invalidDirectoryRequest("ingress may only be advertised on a named service record") + } + nameKey, err := api.ServiceKey(svcType, svcName) + if err != nil || !bytes.Equal(q.GetKey(), nameKey) { + return nil, invalidDirectoryRequest("ingress may only be advertised on a named service key") + } + if err := api.ValidateServiceIngress(svcType, q.GetIngress()); err != nil { + return nil, invalidDirectoryRequest("%w", err) + } + if err := r.requireIngress(provider, svcType, svcName, api.EffectiveIngressAuthMode(q.GetIngress())); err != nil { + return nil, err + } + } + } else if q.GetIngress() != nil { + svcType, svcName, err := api.ServiceOfRecord(provider, q.GetKey(), q.GetService()) + if err != nil || svcName == "" { + return nil, invalidDirectoryRequest("placed ingress record must name a valid service") + } + nameKey, err := api.ServiceKey(svcType, svcName) + if err != nil || !bytes.Equal(q.GetKey(), nameKey) { + return nil, invalidDirectoryRequest("placed ingress record must use a named service key") + } + if err := api.ValidateServiceIngress(svcType, q.GetIngress()); err != nil { + return nil, invalidDirectoryRequest("%w", err) + } } if r.records == nil { return nil, errDirectoryNotUp } - r.records.put(q.GetKey(), provider, addrs, time.Now()) + r.records.putWithIngress(q.GetKey(), provider, addrs, q.GetIngress(), time.Now()) if !placed { r.placeRecord(ctx, provider, &api.DirectoryRequest{Request: &api.DirectoryRequest_Provide{Provide: &api.DirectoryProvide{ - Key: q.GetKey(), Addrs: q.GetAddrs(), Provider: provider.String(), Service: q.GetService(), + Key: q.GetKey(), Addrs: q.GetAddrs(), Provider: provider.String(), Service: q.GetService(), Ingress: cloneServiceIngress(q.GetIngress()), }}}) } return &api.DirectoryResponse{}, nil @@ -481,7 +531,7 @@ func (r *Router) lookupProviders(ctx context.Context, remote peer.ID, key []byte return nil, errDirectoryNotUp } if r.isAuthenticatedPeerRouter(remote) { - return r.freshRecords(r.records.providers(key, time.Now()), limit), nil + return r.freshRecords(r.records.providerEntries(key, time.Now()), limit), nil } r.ensureFederated(ctx) found := r.findProviders(ctx, key, limit) @@ -496,15 +546,15 @@ func (r *Router) lookupProviders(ctx context.Context, remote peer.ID, key []byte // findProviders merges this router's records under key with what the // routers closest to it hold, each asked within directoryLookupTimeout. -func (r *Router) findProviders(ctx context.Context, key []byte, limit int) []peer.AddrInfo { +func (r *Router) findProviders(ctx context.Context, key []byte, limit int) []providerEntry { req := &api.DirectoryRequest{Request: &api.DirectoryRequest_FindProviders{FindProviders: &api.DirectoryFindProviders{Key: key, Limit: uint32(limit)}}} data, err := proto.Marshal(req) if err != nil { logger.Errorf("[Directory] Lookup not encoded: %v", err) - return r.records.providers(key, time.Now()) + return r.records.providerEntries(key, time.Now()) } targets := r.closestRouters(key) - answers := make(chan []peer.AddrInfo, len(targets)) + answers := make(chan []providerEntry, len(targets)) var wg sync.WaitGroup for _, target := range targets { wg.Add(1) @@ -519,36 +569,42 @@ func (r *Router) findProviders(ctx context.Context, key []byte, limit int) []pee return } directoryLookupsTotal.WithLabelValues(directoryResultOK).Inc() - var infos []peer.AddrInfo + var entries []providerEntry for _, rec := range resp.GetProviders() { - if info, err := addrInfoOfRecord(rec); err == nil { - infos = append(infos, info) + entry, err := providerEntryOfRecord(rec) + if err != nil { + logger.Warnf("[Directory] Dropping invalid provider record %q from router %s: %v", rec.GetPeerId(), target, err) + continue } + entries = append(entries, entry) } - answers <- infos + answers <- entries }(target) } wg.Wait() close(answers) - merged := r.records.providers(key, time.Now()) + merged := r.records.providerEntries(key, time.Now()) index := map[peer.ID]int{} - for i, info := range merged { - index[info.ID] = i + for i, entry := range merged { + index[entry.info.ID] = i } - for infos := range answers { - for _, info := range infos { - i, seen := index[info.ID] + for entries := range answers { + for _, entry := range entries { + i, seen := index[entry.info.ID] if !seen { - index[info.ID] = len(merged) - merged = append(merged, info) + index[entry.info.ID] = len(merged) + merged = append(merged, entry) continue } - for _, a := range info.Addrs { - if !containsAddr(merged[i].Addrs, a) { - merged[i].Addrs = append(merged[i].Addrs, a) + for _, a := range entry.info.Addrs { + if !containsAddr(merged[i].info.Addrs, a) { + merged[i].info.Addrs = append(merged[i].info.Addrs, a) } } + if merged[i].ingress == nil && entry.ingress != nil { + merged[i].ingress = entry.ingress + } } } return merged @@ -557,33 +613,41 @@ func (r *Router) findProviders(ctx context.Context, key []byte, limit int) []pee // freshRecords renders up to limit providers, each at the addresses this // router sees for it when it holds the provider, which are ahead of the // record, and at the record's otherwise. -func (r *Router) freshRecords(infos []peer.AddrInfo, limit int) []*api.PeerRecord { - records := make([]*api.PeerRecord, 0, min(limit, len(infos))) - for _, info := range infos { +func (r *Router) freshRecords(entries []providerEntry, limit int) []*api.PeerRecord { + records := make([]*api.PeerRecord, 0, min(limit, len(entries))) + for _, entry := range entries { if len(records) >= limit { break } - if r.isPeerAuthenticated(info.ID) && r.Host.Network().Connectedness(info.ID) == network.Connected { - if addrs := r.Host.Peerstore().Addrs(info.ID); len(addrs) > 0 { - info.Addrs = addrs + if r.isPeerAuthenticated(entry.info.ID) && r.Host.Network().Connectedness(entry.info.ID) == network.Connected { + if addrs := r.Host.Peerstore().Addrs(entry.info.ID); len(addrs) > 0 { + entry.info.Addrs = addrs } } - records = append(records, peerRecord(info)) + rec := peerRecord(entry.info) + rec.Ingress = cloneServiceIngress(entry.ingress) + records = append(records, rec) } return records } -// addrInfoOfRecord parses a record another router answered with. -func addrInfoOfRecord(rec *api.PeerRecord) (peer.AddrInfo, error) { +// providerEntryOfRecord parses a record another router answered with. +func providerEntryOfRecord(rec *api.PeerRecord) (providerEntry, error) { p, err := peer.Decode(rec.GetPeerId()) if err != nil { - return peer.AddrInfo{}, err + return providerEntry{}, err } addrs, err := recordAddrs(p, rec.GetAddrs()) if err != nil { - return peer.AddrInfo{}, err + return providerEntry{}, err + } + if err := api.ValidatePeerRecordIngress(rec.GetIngress()); err != nil { + return providerEntry{}, err } - return peer.AddrInfo{ID: p, Addrs: addrs}, nil + return providerEntry{ + info: peer.AddrInfo{ID: p, Addrs: addrs}, + ingress: cloneServiceIngress(rec.GetIngress()), + }, nil } func containsAddr(addrs []multiaddr.Multiaddr, a multiaddr.Multiaddr) bool { @@ -613,7 +677,7 @@ func (r *Router) directoryFindPeer(ctx context.Context, remote peer.ID, q *api.D return nil, err } for _, rec := range records { - if rec.GetPeerId() == p.String() { + if recPID, decErr := peer.Decode(rec.GetPeerId()); decErr == nil && recPID == p { return &api.DirectoryResponse{Peer: rec}, nil } } diff --git a/internal/router/directory_store.go b/internal/router/directory_store.go index 5063a173..a31ca8da 100644 --- a/internal/router/directory_store.go +++ b/internal/router/directory_store.go @@ -21,10 +21,12 @@ import ( "sync" "time" + "github.com/google/agentmesh/api" "github.com/libp2p/go-libp2p/core/peer" "github.com/multiformats/go-multiaddr" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "google.golang.org/protobuf/proto" ) var directoryRecordsExpiredTotal = promauto.NewCounter(prometheus.CounterOpts{ @@ -46,15 +48,34 @@ type recordStore struct { type directoryRecord struct { addrs []multiaddr.Multiaddr + ingress *api.ServiceIngress expires time.Time } +type providerEntry struct { + info peer.AddrInfo + ingress *api.ServiceIngress +} + +func cloneServiceIngress(ing *api.ServiceIngress) *api.ServiceIngress { + if ing == nil { + return nil + } + return proto.Clone(ing).(*api.ServiceIngress) +} + func newRecordStore(ttl time.Duration) *recordStore { return &recordStore{ttl: ttl, keys: map[string]map[peer.ID]*directoryRecord{}} } // put stores or replaces the provider's record under key. func (s *recordStore) put(key []byte, p peer.ID, addrs []multiaddr.Multiaddr, now time.Time) { + s.putWithIngress(key, p, addrs, nil, now) +} + +// putWithIngress stores or replaces the provider's record and optional external +// ingress configuration under key. +func (s *recordStore) putWithIngress(key []byte, p peer.ID, addrs []multiaddr.Multiaddr, ingress *api.ServiceIngress, now time.Time) { s.mu.Lock() defer s.mu.Unlock() providers := s.keys[string(key)] @@ -62,7 +83,11 @@ func (s *recordStore) put(key []byte, p peer.ID, addrs []multiaddr.Multiaddr, no providers = map[peer.ID]*directoryRecord{} s.keys[string(key)] = providers } - providers[p] = &directoryRecord{addrs: slices.Clone(addrs), expires: now.Add(s.ttl)} + providers[p] = &directoryRecord{ + addrs: slices.Clone(addrs), + ingress: cloneServiceIngress(ingress), + expires: now.Add(s.ttl), + } } // remove drops the provider's record under key and reports whether there @@ -81,19 +106,48 @@ func (s *recordStore) remove(key []byte, p peer.ID) bool { return true } -// providers is the unexpired records under key, in peer ID order so that +// removePeer drops all records held for p across every key (e.g. when p is banned). +func (s *recordStore) removePeer(p peer.ID) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + for k, providers := range s.keys { + delete(providers, p) + if len(providers) == 0 { + delete(s.keys, k) + } + } +} + +// providerEntries is the unexpired records under key, in peer ID order so that // two routers holding the same records answer in the same order. -func (s *recordStore) providers(key []byte, now time.Time) []peer.AddrInfo { +func (s *recordStore) providerEntries(key []byte, now time.Time) []providerEntry { s.mu.Lock() defer s.mu.Unlock() - var out []peer.AddrInfo + var out []providerEntry for p, rec := range s.keys[string(key)] { if !now.Before(rec.expires) { continue } - out = append(out, peer.AddrInfo{ID: p, Addrs: slices.Clone(rec.addrs)}) + out = append(out, providerEntry{ + info: peer.AddrInfo{ID: p, Addrs: slices.Clone(rec.addrs)}, + ingress: cloneServiceIngress(rec.ingress), + }) + } + slices.SortFunc(out, func(a, b providerEntry) int { return strings.Compare(string(a.info.ID), string(b.info.ID)) }) + return out +} + +// providers is the unexpired records under key, in peer ID order so that +// two routers holding the same records answer in the same order. +func (s *recordStore) providers(key []byte, now time.Time) []peer.AddrInfo { + entries := s.providerEntries(key, now) + out := make([]peer.AddrInfo, len(entries)) + for i, e := range entries { + out[i] = e.info } - slices.SortFunc(out, func(a, b peer.AddrInfo) int { return strings.Compare(string(a.ID), string(b.ID)) }) return out } diff --git a/internal/router/ingress.go b/internal/router/ingress.go new file mode 100644 index 00000000..d4f369d8 --- /dev/null +++ b/internal/router/ingress.go @@ -0,0 +1,994 @@ +// 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 router + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/google/agentmesh/api" + "github.com/google/agentmesh/internal/identity" + gostream "github.com/libp2p/go-libp2p-gostream" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-msgio" + "github.com/multiformats/go-multiaddr" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "google.golang.org/protobuf/proto" +) + +const ( + ingressModeMetadata = "metadata" + ingressModeUnknown = "unknown" + + ingressOutcomeAllow = "allow" + ingressOutcomeDeny = "deny" + ingressOutcomeUnauthenticated = "unauthenticated" + ingressOutcomeNotFound = "not_found" + ingressOutcomeRateLimited = "rate_limited" + ingressOutcomeBadGateway = "bad_gateway" + ingressOutcomeInvalid = "invalid" + ingressOutcomeUnavailable = "unavailable" + + maxIngressBodyBytes = 32 << 20 + maxIngressCardBodyBytes = 1 << 20 + a2aAgentCardSubpath = ".well-known/agent-card.json" + ingressDialTimeout = 10 * time.Second +) + +var routerIngressRequestsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "agentmesh_router_ingress_requests_total", + Help: "External HTTP ingress requests handled by this router, by mode and outcome", + }, + []string{"mode", "outcome"}, +) + +func init() { + for _, mode := range []string{api.IngressAuthModeStringEdge, api.IngressAuthModeStringPassthrough, ingressModeMetadata, ingressModeUnknown} { + for _, outcome := range []string{ + ingressOutcomeAllow, + ingressOutcomeDeny, + ingressOutcomeUnauthenticated, + ingressOutcomeNotFound, + ingressOutcomeRateLimited, + ingressOutcomeBadGateway, + ingressOutcomeInvalid, + ingressOutcomeUnavailable, + } { + routerIngressRequestsTotal.WithLabelValues(mode, outcome) + } + } +} + +// IngressAddr returns the bound TCP address of the standalone HTTP ingress +// listener when IngressAddr was configured, or nil when off or mounted on a +// shared port via IngressHandler. +func (r *Router) IngressAddr() net.Addr { + if r == nil { + return nil + } + return r.ingressAddr +} + +func (r *Router) serveIngress(addr string) error { + listener, err := net.Listen("tcp", addr) + if err != nil { + return err + } + r.ingressServer = &http.Server{ + Handler: r.IngressHandler(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + r.ingressAddr = listener.Addr() + if r.config.IngressTLSCertPath != "" { + go func() { + if err := r.ingressServer.ServeTLS(listener, r.config.IngressTLSCertPath, r.config.IngressTLSKeyPath); err != nil && err != http.ErrServerClosed { + logger.Errorf("[Ingress] HTTPS server error: %v", err) + } + }() + logger.Infof("Router HTTPS ingress listening on https://%s/ingress", r.ingressAddr) + return nil + } + go func() { + if err := r.ingressServer.Serve(listener); err != nil && err != http.ErrServerClosed { + logger.Errorf("[Ingress] HTTP server error: %v", err) + } + }() + logger.Infof("Router HTTP ingress listening on http://%s/ingress", r.ingressAddr) + return nil +} + +// IngressHandler returns the HTTP handler for /ingress/ and +// /ingress/.well-known/oauth-protected-resource, suitable either for a +// dedicated listener (--ingress-addr) or for single-port mounting in +// agentmesh-one. +func (r *Router) IngressHandler() http.Handler { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + peerStr := addr + if h, _, err := net.SplitHostPort(addr); err == nil { + peerStr = h + } + pid, err := peer.Decode(peerStr) + if err != nil { + return nil, fmt.Errorf("invalid target peer %q: %w", peerStr, err) + } + if r.gated == nil { + return nil, errors.New("router host is not initialized") + } + return gostream.Dial(network.WithAllowLimitedConn(ctx, "ingress"), r.gated, pid, api.IngressProtocolID) + }, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 60 * time.Second, + ResponseHeaderTimeout: 5 * time.Minute, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /ingress/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("GET /ingress/.well-known/oauth-protected-resource", r.handleIngressProtectedResourceMetadata) + mux.HandleFunc("/ingress/", func(w http.ResponseWriter, req *http.Request) { + r.handleIngressRequest(w, req, transport) + }) + return mux +} + +func isUnspecifiedBindHost(host string) bool { + h := strings.Trim(strings.TrimSpace(host), "[]") + if h == "" { + return true + } + ip := net.ParseIP(h) + return ip != nil && ip.IsUnspecified() +} + +func (r *Router) singlePortWSPort() string { + if r.Host == nil || r.Host.Network() == nil { + return "" + } + for _, a := range r.Host.Network().ListenAddresses() { + if _, err := a.ValueForProtocol(multiaddr.P_WS); err != nil { + continue + } + if port, err := a.ValueForProtocol(multiaddr.P_TCP); err == nil { + return port + } + } + return "" +} + +func (r *Router) leasedIngressURL() string { + if configured := strings.TrimSuffix(strings.TrimSpace(r.config.IngressURL), "/"); configured != "" { + return configured + } + if r.ingressAddr != nil { + addrStr := r.ingressAddr.String() + host, _, err := net.SplitHostPort(addrStr) + if err == nil && !isUnspecifiedBindHost(host) { + scheme := "http" + if r.config.IngressTLSCertPath != "" { + scheme = "https" + } + return scheme + "://" + addrStr + "/ingress" + } + return "" + } + if r.config.AdvertiseSinglePortIngress { + host := strings.TrimSpace(r.config.SinglePortBindHost) + if !isUnspecifiedBindHost(host) { + if port := r.singlePortWSPort(); port != "" { + return "http://" + net.JoinHostPort(host, port) + "/ingress" + } + } + } + return "" +} + +// effectiveIngressBaseURL resolves the canonical external /ingress base URL +// from operator configuration or a non-unspecified bound listener, never from +// the untrusted HTTP Host header. +func (r *Router) effectiveIngressBaseURL() string { + if leased := r.leasedIngressURL(); leased != "" { + return leased + } + if pub := strings.TrimSuffix(strings.TrimSpace(r.config.PublicControlPlaneURL), "/"); pub != "" { + return pub + "/ingress" + } + if r.config.AdvertiseSinglePortIngress { + if port := r.singlePortWSPort(); port != "" { + return "http://" + net.JoinHostPort("127.0.0.1", port) + "/ingress" + } + } + return strings.TrimSuffix(strings.TrimSpace(r.config.ControlPlaneURL), "/") + "/ingress" +} + +func (r *Router) effectivePublicControlPlaneURL() string { + if pub := strings.TrimSuffix(strings.TrimSpace(r.config.PublicControlPlaneURL), "/"); pub != "" { + return pub + } + if r.config.AdvertiseSinglePortIngress || (r.config.IngressURL != "" && r.config.AllowInsecureControlPlane) { + return strings.TrimSuffix(r.effectiveIngressBaseURL(), "/ingress") + } + return strings.TrimSuffix(strings.TrimSpace(r.config.ControlPlaneURL), "/") +} + +func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) { + base := r.effectiveIngressBaseURL() + var servers []string + if cp := r.effectivePublicControlPlaneURL(); cp != "" { + servers = append(servers, cp) + } + doc := api.OAuthProtectedResourceMetadata{ + Resource: base, + AuthorizationServers: servers, + BearerMethodsSupported: []string{"header"}, + ScopesSupported: []string{"openid", "email", "profile"}, + } + out, err := json.Marshal(doc) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeMetadata, ingressOutcomeBadGateway).Inc() + logger.Errorf("[Ingress] Failed to marshal protected resource metadata: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + routerIngressRequestsTotal.WithLabelValues(ingressModeMetadata, ingressOutcomeAllow).Inc() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(out) +} + +func (r *Router) writeIngressUnauthorized(w http.ResponseWriter, modeMetric, message string) { + routerIngressRequestsTotal.WithLabelValues(modeMetric, ingressOutcomeUnauthenticated).Inc() + metaURL := r.effectiveIngressBaseURL() + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, metaURL)) + http.Error(w, message, http.StatusUnauthorized) +} + +func biscuitRateLimitKey(b *biscuit.Biscuit, raw []byte) string { + if b != nil { + if ids := b.RevocationIds(); len(ids) > 0 && len(ids[0]) > 0 { + return "biscuit:" + base64.RawURLEncoding.EncodeToString(ids[0]) + } + } + sum := sha256.Sum256(raw) + return "biscuit:" + hex.EncodeToString(sum[:16]) +} + +func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, transport http.RoundTripper) { + if !r.isReady.Load() || r.shutdown.Load() { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeUnavailable).Inc() + logger.Warn("[Ingress] Router is not ready or is shutting down") + http.Error(w, "Router is not ready", http.StatusServiceUnavailable) + return + } + + clientIP := clientIPFromRemoteAddr(req.RemoteAddr) + req.Body = http.MaxBytesReader(w, req.Body, maxIngressBodyBytes) + + if hasDotSegment(req.URL.Path) { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Rejecting dot-segment path from %s", clientIP) + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + trimmed := strings.TrimPrefix(req.URL.Path, "/ingress/") + parts := strings.SplitN(trimmed, "/", 4) + if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Rejecting malformed ingress path from %s", clientIP) + http.Error(w, "Invalid ingress path: expected /ingress///", http.StatusBadRequest) + return + } + peerIDStr := parts[0] + serviceTypeStr := strings.ToLower(parts[1]) + serviceName := parts[2] + subpath := "" + hasTrailingOrSubpath := len(parts) == 4 + if hasTrailingOrSubpath { + subpath = parts[3] + } + + targetPeer, err := peer.Decode(peerIDStr) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Rejecting invalid peer ID from %s: %v", clientIP, err) + http.Error(w, "Invalid peer ID", http.StatusBadRequest) + return + } + svcType, err := api.ParseServiceType(serviceTypeStr) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Rejecting invalid service type %q from %s", serviceTypeStr, clientIP) + http.Error(w, "Invalid service type", http.StatusBadRequest) + return + } + if !api.IsInboundServiceType(svcType) { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeNotFound).Inc() + logger.Debugf("[Ingress] Rejecting non-inbound service type %q from %s", serviceTypeStr, clientIP) + http.Error(w, "Service not found", http.StatusNotFound) + return + } + if err := api.ValidateServiceFormat(serviceTypeStr + "://" + serviceName); err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Rejecting invalid service name from %s: %v", clientIP, err) + http.Error(w, "Invalid service name", http.StatusBadRequest) + return + } + if _, banned := r.bannedPeers.Load(targetPeer); banned { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeNotFound).Inc() + logger.Warnf("[Ingress] Refusing ingress request to banned peer %s", targetPeer) + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + key, err := api.ServiceKey(svcType, serviceName) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + logger.Debugf("[Ingress] Invalid service key for %s://%s: %v", serviceTypeStr, serviceName, err) + http.Error(w, "Invalid service key", http.StatusBadRequest) + return + } + providers, err := r.lookupProviders(req.Context(), "", key, directoryMaxLimit) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Directory lookup failed for %s://%s: %v", serviceTypeStr, serviceName, err) + http.Error(w, "Service lookup failed", http.StatusBadGateway) + return + } + var providerRec *api.PeerRecord + for _, rec := range providers { + recPID, decErr := peer.Decode(rec.GetPeerId()) + if decErr != nil { + logger.Warnf("[Ingress] Ignoring provider record with invalid peer ID %q: %v", rec.GetPeerId(), decErr) + continue + } + if recPID == targetPeer { + providerRec = rec + break + } + } + if providerRec == nil || providerRec.GetIngress() == nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeNotFound).Inc() + logger.Debugf("[Ingress] No ingress record found for %s (%s://%s)", targetPeer, serviceTypeStr, serviceName) + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + ing := providerRec.GetIngress() + effectiveMode := api.EffectiveIngressAuthMode(ing) + modeStr, err := api.IngressAuthModeToString(effectiveMode) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Unsupported ingress auth mode %v for %s", effectiveMode, targetPeer) + http.Error(w, "Unsupported ingress auth mode", http.StatusForbidden) + return + } + + r.keysMu.RLock() + routerBiscuit := r.credential.biscuit + r.keysMu.RUnlock() + if len(routerBiscuit) == 0 { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeUnavailable).Inc() + logger.Warn("[Ingress] Router credential unavailable") + http.Error(w, "Router credential unavailable", http.StatusServiceUnavailable) + return + } + routerBiscuitHeader := base64.StdEncoding.EncodeToString(routerBiscuit) + + targetURI := serviceTypeStr + "://" + serviceName + backendPath := "/" + subpath + isAgentCardGet := svcType == api.ServiceType_SERVICE_TYPE_A2A && + req.Method == http.MethodGet && + (subpath == "" || subpath == a2aAgentCardSubpath) + + var ( + verifiedCaller *identity.VerifiedBiscuitClaims + callerBiscuitHdr string + evalParams identity.IngressEvalParams + ) + + // On EDGE ingress (except unauthenticated A2A AgentCard GET), verify the + // caller's Biscuit, revocation state, actor bans, rate limit, TAR rules, and + // ServiceIngress.allow locally BEFORE dialing or authenticating the provider. + if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE && !isAgentCardGet { + bearerToken, ok := extractBearerToken(req.Header.Get("Authorization")) + if !ok { + if r.ingressLimiter != nil && !r.ingressLimiter.Allow("unauth:"+clientIP) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for unauthenticated client %s", clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + r.writeIngressUnauthorized(w, modeStr, "Missing or invalid Authorization: Bearer header") + return + } + if isCompactJWT(bearerToken) { + if r.ingressLimiter != nil && !r.ingressLimiter.Allow("unauth:"+clientIP) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for JWT client %s", clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + logger.Debugf("[Ingress] Rejecting raw JWT on edge ingress from %s for %s; exchange at /oauth/token required", clientIP, targetURI) + r.writeIngressUnauthorized(w, modeStr, "Mesh Biscuit required; exchange platform JWT at /oauth/token") + return + } + rawCaller, err := identity.DecodeBase64Biscuit(bearerToken) + if err != nil { + if r.ingressLimiter != nil && !r.ingressLimiter.Allow("unauth:"+clientIP) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for invalid token encoding from %s", clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + r.writeIngressUnauthorized(w, modeStr, "Invalid Bearer token encoding") + return + } + verified, err := identity.InspectVerifiedBiscuit(rawCaller, r.getTrustedPublicKeys(), r.config.BiscuitTimeout) + if err != nil { + if r.ingressLimiter != nil && !r.ingressLimiter.Allow("unauth:"+clientIP) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for failed biscuit verification from %s", clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + logger.Warnf("[Ingress] Biscuit verification failed from %s for %s: %v", clientIP, targetURI, err) + r.writeIngressUnauthorized(w, modeStr, "Invalid Bearer token") + return + } + if r.ingressLimiter != nil && !r.ingressLimiter.Allow(biscuitRateLimitKey(verified.Biscuit, rawCaller)) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for caller %s from %s", verified.Principal(), clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + if r.isBiscuitRevoked(verified.Biscuit) { + logger.Warnf("[Ingress] Refusing revoked Biscuit from %s for %s", clientIP, targetURI) + r.writeIngressUnauthorized(w, modeStr, "Token has been revoked") + return + } + for _, pidStr := range []string{verified.NodePeerID, verified.ActorNodePeerID, verified.ClientPeerID} { + if pidStr == "" { + continue + } + if actorPID, decErr := peer.Decode(pidStr); decErr == nil { + if _, banned := r.bannedPeers.Load(actorPID); banned { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Refusing Biscuit from banned peer %s", actorPID) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + } + + evalParams = identity.IngressEvalParams{ + ServiceType: serviceTypeStr, + ServiceName: serviceName, + Method: req.Method, + Path: backendPath, + EnforceTargetCheck: true, + MeshPolicyRules: r.meshPolicyRules(), + Allow: ing.GetAllow(), + Timeout: r.config.BiscuitTimeout, + } + if svcType == api.ServiceType_SERVICE_TYPE_MCP { + mcpTool, allowInit, bodyErr := api.InspectMCPHTTPRequestBody(req, api.DefaultMaxMCPBodyInspectBytes) + if bodyErr != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeInvalid).Inc() + logger.Warnf("[Ingress] Oversized or unreadable MCP body from %s for %s: %v", clientIP, targetURI, bodyErr) + http.Error(w, bodyErr.Error(), http.StatusRequestEntityTooLarge) + return + } + evalParams.MCPTool = mcpTool + evalParams.AllowMCPStreamInit = allowInit + } + if _, err := identity.ValidateIngressCallerPreDial(verified, evalParams); err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Pre-dial AuthZ denied caller (%s) from %s for %s: %v", verified.Principal(), clientIP, targetURI, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + verifiedCaller = verified + callerBiscuitHdr = base64.StdEncoding.EncodeToString(rawCaller) + } else if r.ingressLimiter != nil { + anonKey := "anon:" + clientIP + ":" + targetPeer.String() + if !r.ingressLimiter.Allow(anonKey) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for client %s to target %s", clientIP, targetPeer) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + } + + cred, err := r.ensureProviderAuthenticated(req.Context(), targetPeer, providerRec.GetAddrs()) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Could not reach or authenticate provider %s for %s://%s: %v", targetPeer, serviceTypeStr, serviceName, err) + http.Error(w, "Bad Gateway: provider unreachable", http.StatusBadGateway) + return + } + + // Re-check the provider's role ceiling against current mesh policy so a + // revoked role grant takes effect at the next sync without waiting for TTL. + if err := r.requireIngress(targetPeer, svcType, serviceName, effectiveMode); err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Provider %s no longer authorized to expose %s://%s (%s): %v", targetPeer, serviceTypeStr, serviceName, modeStr, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Unauthenticated GET discovery of A2A AgentCard (both well-known path and + // bare service root GET for resolvers such as a2a-go). + if isAgentCardGet { + r.serveIngressAgentCard(w, req, transport, targetPeer, serviceName, effectiveMode, modeStr, routerBiscuitHeader) + return + } + + if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeAllow).Inc() + logger.Infow("Border Crossing", + "direction", "inbound", + "protocol", string(api.IngressProtocolID), + "mode", api.IngressAuthModeStringPassthrough, + "client_ip", clientIP, + "target_peer_id", targetPeer.String(), + "target", targetURI, + "method", req.Method, + "path", backendPath, + ) + r.forwardIngressHTTP(w, req, transport, targetPeer, serviceTypeStr, serviceName, subpath, hasTrailingOrSubpath, routerBiscuitHeader, api.IngressAuthModeStringPassthrough, "") + return + } + + targetFacts, err := identity.TargetFactsFromBiscuit(cred.biscuit, r.getTrustedPublicKeys(), r.config.BiscuitTimeout) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Failed to extract target facts for %s: %v", targetPeer, err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + evalParams.TargetFacts = targetFacts + + callerRes, err := identity.AuthorizeIngressDelegatedBiscuit(verifiedCaller, evalParams) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Edge AuthZ denied Biscuit caller (%s) from %s for %s: %v", verifiedCaller.Principal(), clientIP, targetURI, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeAllow).Inc() + logger.Infow("Border Crossing", + "direction", "inbound", + "protocol", string(api.IngressProtocolID), + "mode", api.IngressAuthModeStringEdge, + "client_ip", clientIP, + "principal", callerRes.Principal, + "roles", callerRes.Roles, + "task", callerRes.TaskName, + "target_peer_id", targetPeer.String(), + "target", targetURI, + "method", req.Method, + "path", backendPath, + ) + + r.forwardIngressHTTP(w, req, transport, targetPeer, serviceTypeStr, serviceName, subpath, hasTrailingOrSubpath, routerBiscuitHeader, api.IngressAuthModeStringEdge, callerBiscuitHdr) +} + +func (r *Router) forwardIngressHTTP( + w http.ResponseWriter, + req *http.Request, + transport http.RoundTripper, + targetPeer peer.ID, + serviceTypeStr, serviceName, subpath string, + hasTrailingOrSubpath bool, + routerBiscuitHeader, modeStr, callerBiscuitHdr string, +) { + upstreamPath := "/" + serviceTypeStr + "/" + serviceName + if hasTrailingOrSubpath { + upstreamPath += "/" + subpath + } + stripAuth := modeStr == api.IngressAuthModeStringEdge + + proxy := &httputil.ReverseProxy{ + Transport: transport, + FlushInterval: -1, + Rewrite: func(pr *httputil.ProxyRequest) { + pr.Out.URL.Scheme = "http" + pr.Out.URL.Host = targetPeer.String() + pr.Out.URL.Path = upstreamPath + pr.Out.URL.RawPath = "" + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + pr.Out.Host = targetPeer.String() + + api.SanitizeExternalIngressHeaders(pr.Out.Header, stripAuth) + pr.Out.Header.Set(api.HeaderMeshBiscuit, routerBiscuitHeader) + pr.Out.Header.Set(api.HeaderMeshIngress, modeStr) + if callerBiscuitHdr != "" { + pr.Out.Header.Set(api.HeaderMeshIngressCallerBiscuit, callerBiscuitHdr) + } + }, + ErrorHandler: func(rw http.ResponseWriter, _ *http.Request, err error) { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeInvalid).Inc() + logger.Warnf("[Ingress] Request body to %s (%s://%s) exceeded size limit", targetPeer, serviceTypeStr, serviceName) + http.Error(rw, "Request Entity Too Large", http.StatusRequestEntityTooLarge) + return + } + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Upstream stream to %s (%s://%s) failed: %v", targetPeer, serviceTypeStr, serviceName, err) + http.Error(rw, "Bad Gateway", http.StatusBadGateway) + }, + } + proxy.ServeHTTP(w, req) +} + +func (r *Router) serveIngressAgentCard( + w http.ResponseWriter, + req *http.Request, + transport http.RoundTripper, + targetPeer peer.ID, + serviceName string, + effectiveMode api.IngressAuthMode, + modeStr string, + routerBiscuitHeader string, +) { + cardURL := fmt.Sprintf("http://%s/%s/%s/%s", targetPeer.String(), api.ServiceTypeStringA2A, serviceName, a2aAgentCardSubpath) + outReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, cardURL, nil) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Errorf("[Ingress] Failed to build A2A agent card request for %s/%s: %v", targetPeer, serviceName, err) + http.Error(w, "Bad Gateway", http.StatusBadGateway) + return + } + outReq.Header.Set(api.HeaderMeshBiscuit, routerBiscuitHeader) + outReq.Header.Set(api.HeaderMeshIngress, modeStr) + if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { + if authHdr := req.Header.Get("Authorization"); authHdr != "" { + outReq.Header.Set("Authorization", authHdr) + } + } + + resp, err := (&http.Client{Transport: transport, Timeout: 15 * time.Second}).Do(outReq) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Failed to fetch A2A agent card from %s/%s: %v", targetPeer, serviceName, err) + http.Error(w, "Bad Gateway: agent card fetch failed", http.StatusBadGateway) + return + } + defer func() { _ = resp.Body.Close() }() + + body := io.LimitReader(resp.Body, maxIngressCardBodyBytes) + if resp.StatusCode != http.StatusOK { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Backend returned status %d for A2A agent card %s/%s", resp.StatusCode, targetPeer, serviceName) + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, body) + return + } + + var card a2a.AgentCard + if err := json.NewDecoder(body).Decode(&card); err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] Invalid A2A agent card JSON from %s/%s: %v", targetPeer, serviceName, err) + http.Error(w, "Bad Gateway: invalid agent card JSON", http.StatusBadGateway) + return + } + + serviceBase := fmt.Sprintf("%s/%s/%s/%s", r.effectiveIngressBaseURL(), targetPeer.String(), api.ServiceTypeStringA2A, serviceName) + if err := r.rewriteAgentCardForIngress(&card, serviceBase, r.effectivePublicControlPlaneURL(), effectiveMode); err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Warnf("[Ingress] A2A agent card from %s/%s unusable on ingress: %v", targetPeer, serviceName, err) + http.Error(w, fmt.Sprintf("Bad Gateway: %v", err), http.StatusBadGateway) + return + } + + out, err := json.Marshal(&card) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + logger.Errorf("[Ingress] Failed to marshal AgentCard for %s/%s: %v", targetPeer, serviceName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + for _, h := range []string{"Cache-Control", "Last-Modified"} { + if v := resp.Header.Get(h); v != "" { + w.Header().Set(h, v) + } + } + sum := sha256.Sum256(out) + etag := `"` + hex.EncodeToString(sum[:16]) + `"` + w.Header().Set("ETag", etag) + if ingressETagMatches(req.Header.Get("If-None-Match"), etag) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeAllow).Inc() + w.WriteHeader(http.StatusNotModified) + return + } + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeAllow).Inc() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(out))) + _, _ = w.Write(out) +} + +func (r *Router) rewriteAgentCardForIngress(card *a2a.AgentCard, serviceBase, publicControlPlaneURL string, effectiveMode api.IngressAuthMode) error { + kept := make([]*a2a.AgentInterface, 0, len(card.SupportedInterfaces)) + for _, iface := range card.SupportedInterfaces { + if iface == nil || !isHTTPTransportBinding(iface.ProtocolBinding) { + continue + } + iface.URL = resolveIngressInterfaceURL(serviceBase, iface.URL) + kept = append(kept, iface) + } + if len(kept) == 0 { + return fmt.Errorf("agent card advertises no HTTP interface (JSONRPC or HTTP+JSON)") + } + card.SupportedInterfaces = kept + card.Signatures = nil + if card.Skills == nil { + card.Skills = []a2a.AgentSkill{} + } + if card.DefaultInputModes == nil { + card.DefaultInputModes = []string{} + } + if card.DefaultOutputModes == nil { + card.DefaultOutputModes = []string{} + } + + if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE { + cpBase := strings.TrimSuffix(strings.TrimSpace(publicControlPlaneURL), "/") + schemes := a2a.NamedSecuritySchemes{ + "bearer": a2a.HTTPAuthSecurityScheme{ + Scheme: "bearer", + BearerFormat: "Biscuit", + Description: "Agent Mesh delegated session or task Biscuit", + }, + } + reqs := a2a.SecurityRequirementsOptions{ + {"bearer": {}}, + } + if cpBase != "" { + schemes["agentmesh_oauth2"] = a2a.OAuth2SecurityScheme{ + Description: "Agent Mesh OAuth 2.1 Authorization Server", + Oauth2MetadataURL: cpBase + "/.well-known/oauth-authorization-server", + Flows: a2a.AuthorizationCodeOAuthFlow{ + AuthorizationURL: cpBase + "/oauth/authorize", + TokenURL: cpBase + "/oauth/token", + Scopes: map[string]string{ + "openid": "OpenID Connect identity", + "email": "Email claim", + "profile": "Profile claim", + }, + PKCERequired: true, + }, + } + reqs = append(reqs, a2a.SecurityRequirements{"agentmesh_oauth2": {}}) + } + card.SecuritySchemes = schemes + card.SecurityRequirements = reqs + } + return nil +} + +func (r *Router) ensureProviderAuthenticated(ctx context.Context, targetPeer peer.ID, rawAddrs []string) (memberCredential, error) { + if r.Host == nil { + return memberCredential{}, errors.New("router host is not initialized") + } + if r.isPeerAuthenticated(targetPeer) && r.Host.Network().Connectedness(targetPeer) == network.Connected { + if v, ok := r.credentials.Load(targetPeer); ok { + return v.(memberCredential), nil + } + } + + dialCtx, cancel := context.WithTimeout(network.WithAllowLimitedConn(ctx, "ingress"), ingressDialTimeout) + defer cancel() + + transportAddrs, relayPeers := splitProviderAddrs(targetPeer, rawAddrs) + for _, relayID := range relayPeers { + if relayID != r.Host.ID() && !r.isPeerAuthenticated(relayID) { + r.ensureFederated(dialCtx) + break + } + } + if len(transportAddrs) > 0 { + r.Host.Peerstore().AddAddrs(targetPeer, transportAddrs, 2*time.Minute) + } + if r.Host.Network().Connectedness(targetPeer) != network.Connected { + if err := r.Host.Connect(dialCtx, peer.AddrInfo{ID: targetPeer, Addrs: transportAddrs}); err != nil { + return memberCredential{}, fmt.Errorf("connect to provider %s: %w", targetPeer, err) + } + } + if v, ok := r.credentials.Load(targetPeer); ok && r.isPeerAuthenticated(targetPeer) { + return v.(memberCredential), nil + } + return r.authenticateIngressMember(dialCtx, targetPeer) +} + +func (r *Router) authenticateIngressMember(ctx context.Context, targetPeer peer.ID) (memberCredential, error) { + if _, banned := r.bannedPeers.Load(targetPeer); banned { + return memberCredential{}, fmt.Errorf("peer %s is banned", targetPeer) + } + r.keysMu.RLock() + ourBiscuit := r.credential.biscuit + r.keysMu.RUnlock() + if len(ourBiscuit) == 0 { + return memberCredential{}, errors.New("router has no credential") + } + + s, err := r.Host.NewStream(ctx, targetPeer, api.AuthProtocolID) + if err != nil { + return memberCredential{}, fmt.Errorf("open auth stream: %w", err) + } + defer func() { _ = s.Close() }() + _ = s.SetDeadline(time.Now().Add(ingressDialTimeout)) + + frameBytes, err := proto.Marshal(&api.AuthFrame{Biscuit: ourBiscuit}) + if err != nil { + return memberCredential{}, err + } + if err := msgio.NewVarintWriter(s).WriteMsg(frameBytes); err != nil { + _ = s.Reset() + return memberCredential{}, fmt.Errorf("write auth frame: %w", err) + } + + reader := msgio.NewVarintReaderSize(s, 1024*64) + respMsg, err := reader.ReadMsg() + if err != nil { + _ = s.Reset() + return memberCredential{}, fmt.Errorf("read auth response: %w", err) + } + defer reader.ReleaseMsg(respMsg) + + var resp api.AuthResponse + if err := proto.Unmarshal(respMsg, &resp); err != nil { + return memberCredential{}, fmt.Errorf("unmarshal auth response: %w", err) + } + if !resp.Success { + return memberCredential{}, fmt.Errorf("provider rejected auth handshake: %s", resp.Error) + } + + b, verifyingKey, expiry, err := identity.VerifyBiscuitAdmission(resp.Biscuit, targetPeer, r.getTrustedPublicKeys(), r.config.BiscuitTimeout) + if err != nil { + return memberCredential{}, fmt.Errorf("verify provider biscuit: %w", err) + } + if r.isBiscuitRevoked(b) { + return memberCredential{}, fmt.Errorf("provider %s biscuit is revoked", targetPeer) + } + cred := memberCredential{biscuit: b, key: verifyingKey} + r.admitPeer(targetPeer, expiry) + r.credentials.Store(targetPeer, cred) + return cred, nil +} + +func splitProviderAddrs(targetPeer peer.ID, rawAddrs []string) ([]multiaddr.Multiaddr, []peer.ID) { + var transport []multiaddr.Multiaddr + var relays []peer.ID + for _, s := range rawAddrs { + ma, err := multiaddr.NewMultiaddr(s) + if err != nil { + logger.Debugf("[Ingress] Ignoring invalid provider multiaddr %q for %s: %v", s, targetPeer, err) + continue + } + tAddr, pid := peer.SplitAddr(ma) + if pid != "" && pid != targetPeer { + logger.Debugf("[Ingress] Ignoring provider multiaddr %q naming %s instead of %s", s, pid, targetPeer) + continue + } + if tAddr == nil { + continue + } + transport = append(transport, tAddr) + if _, err := tAddr.ValueForProtocol(multiaddr.P_CIRCUIT); err == nil { + if relayStr, err := tAddr.ValueForProtocol(multiaddr.P_P2P); err == nil { + if rpid, err := peer.Decode(relayStr); err == nil { + relays = append(relays, rpid) + } + } + } + } + return transport, relays +} + +func extractBearerToken(header string) (string, bool) { + parts := strings.SplitN(strings.TrimSpace(header), " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + tok := strings.TrimSpace(parts[1]) + return tok, tok != "" +} + +func isCompactJWT(token string) bool { + return strings.Count(token, ".") == 2 +} + +func clientIPFromRemoteAddr(remoteAddr string) string { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil && host != "" { + return host + } + if remoteAddr != "" { + return remoteAddr + } + return "unknown" +} + +func hasDotSegment(p string) bool { + for _, seg := range strings.Split(p, "/") { + if seg == "." || seg == ".." { + return true + } + } + return false +} + +func isHTTPTransportBinding(binding a2a.TransportProtocol) bool { + switch strings.ToUpper(string(binding)) { + case string(a2a.TransportProtocolJSONRPC), string(a2a.TransportProtocolHTTPJSON): + return true + } + return false +} + +func resolveIngressInterfaceURL(base, raw string) string { + ref, err := url.Parse(raw) + if err != nil || ref.Scheme != "" || ref.Host != "" || ref.User != nil || ref.Opaque != "" || + strings.HasPrefix(ref.Path, "/") || hasDotSegment(ref.Path) { + return base + } + out := base + if ref.Path != "" { + out += "/" + ref.EscapedPath() + } + if ref.RawQuery != "" { + out += "?" + ref.RawQuery + } + return out +} + +func ingressETagMatches(header, etag string) bool { + for _, candidate := range strings.Split(header, ",") { + candidate = strings.TrimSpace(candidate) + if candidate == "*" || strings.TrimPrefix(candidate, "W/") == etag { + return true + } + } + return false +} diff --git a/internal/router/ingress_test.go b/internal/router/ingress_test.go new file mode 100644 index 00000000..fa5a942a --- /dev/null +++ b/internal/router/ingress_test.go @@ -0,0 +1,1021 @@ +// 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 router + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/golang-jwt/jwt/v5" + "github.com/google/agentmesh/api" + "github.com/google/agentmesh/internal/identity" + "github.com/google/agentmesh/internal/ratelimit" + gostream "github.com/libp2p/go-libp2p-gostream" + kb "github.com/libp2p/go-libp2p-kbucket" + libp2pcrypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multiaddr" +) + +func TestIngressOptionsValidate(t *testing.T) { + base := func() Options { + opts := Options{ + ControlPlaneURL: "http://127.0.0.1:8080", + KeysDBPath: filepath.Join(t.TempDir(), "router.key"), + } + opts.Default() + return opts + } + + cases := []struct { + name string + mutate func(*Options) + wantErr bool + }{ + { + name: "loopback IngressAddr allowed without TLS", + mutate: func(o *Options) { + o.IngressAddr = "127.0.0.1:0" + o.IngressURL = "http://127.0.0.1:8080/ingress/" + }, + wantErr: false, + }, + { + name: "localhost IngressAddr allowed without TLS", + mutate: func(o *Options) { + o.IngressAddr = "localhost:0" + }, + wantErr: false, + }, + { + name: "non-loopback IngressAddr rejected without TLS or opt-in", + mutate: func(o *Options) { + o.IngressAddr = "0.0.0.0:8080" + }, + wantErr: true, + }, + { + name: "non-loopback IngressAddr allowed behind TLS proxy with AllowInsecureIngressHTTP", + mutate: func(o *Options) { + o.IngressAddr = "0.0.0.0:8080" + o.AllowInsecureIngressHTTP = true + o.IngressURL = "https://bananas.sam-mesh.dev/ingress" + }, + wantErr: false, + }, + { + name: "non-loopback IngressAddr allowed with TLS cert and key", + mutate: func(o *Options) { + o.IngressAddr = "0.0.0.0:8443" + o.IngressTLSCertPath = "/etc/tls/tls.crt" + o.IngressTLSKeyPath = "/etc/tls/tls.key" + }, + wantErr: false, + }, + { + name: "mismatched TLS cert without key rejected", + mutate: func(o *Options) { + o.IngressAddr = "0.0.0.0:8443" + o.IngressTLSCertPath = "/etc/tls/tls.crt" + }, + wantErr: true, + }, + { + name: "non-loopback http IngressURL rejected without AllowInsecureIngressHTTP", + mutate: func(o *Options) { + o.IngressURL = "http://bananas.sam-mesh.dev/ingress" + }, + wantErr: true, + }, + { + name: "non-loopback http IngressURL allowed with AllowInsecureIngressHTTP", + mutate: func(o *Options) { + o.IngressURL = "http://bananas.sam-mesh.dev/ingress" + o.AllowInsecureIngressHTTP = true + }, + wantErr: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := base() + tc.mutate(&opts) + err := opts.Validate() + if tc.wantErr && err == nil { + t.Fatalf("Validate() succeeded, want error") + } + if !tc.wantErr && err != nil { + t.Fatalf("Validate() failed: %v", err) + } + }) + } +} + +func attachWithRole(t *testing.T, r *Router, pub ed25519.PublicKey, priv ed25519.PrivateKey, role *api.PolicyRole) host.Host { + t.Helper() + m := newClient(t, r) + r.authenticatedPeers.Store(m.ID(), time.Now().Add(time.Hour)) + tok, _, err := identity.MintBiscuitToken(priv, jwt.MapClaims{"sub": "member"}, nil, m.ID(), time.Now().Add(time.Hour), []string{role.GetName()}, []*api.PolicyRole{role}, nil) + if err != nil { + t.Fatalf("MintBiscuitToken: %v", err) + } + b, err := biscuit.Unmarshal(tok) + if err != nil { + t.Fatalf("biscuit.Unmarshal: %v", err) + } + r.credentials.Store(m.ID(), memberCredential{biscuit: b, key: pub}) + return m +} + +func provideWithIngress(key []byte, service string, ing *api.ServiceIngress, addrs ...string) *api.DirectoryRequest { + return &api.DirectoryRequest{ + Request: &api.DirectoryRequest_Provide{ + Provide: &api.DirectoryProvide{ + Key: key, + Service: service, + Ingress: ing, + Addrs: addrs, + }, + }, + } +} + +func TestDirectoryIngressEnforcementAndPropagation(t *testing.T) { + a, b, c := newDirectoryRouter(t), newDirectoryRouter(t), newDirectoryRouter(t) + federate(t, a, b) + federate(t, a, c) + federate(t, b, c) + a.directory.Replication = 1 + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + edgeMember := attachWithRole(t, a, pub, priv, &api.PolicyRole{ + Name: "edge-publisher", + AllowedPublish: []string{"a2a://*", "egress://openai"}, + AllowedIngress: []*api.IngressGrant{ + {Services: []string{"a2a://assistant"}}, + }, + }) + memberAddr := edgeMember.Addrs()[0].String() + "/p2p/" + edgeMember.ID().String() + + assistantKey, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "assistant") + if err != nil { + t.Fatalf("ServiceKey: %v", err) + } + + t.Run("PASSTHROUGH denied when role only grants default EDGE", func(t *testing.T) { + before := directoryCount(t, directoryOpProvide, directoryResultDenied) + resp := ask(t, edgeMember, a.Host.ID(), provideWithIngress( + assistantKey, + "a2a://assistant", + &api.ServiceIngress{AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + memberAddr, + )) + if resp.GetError() == "" { + t.Fatal("PASSTHROUGH announcement succeeded, want denial") + } + if got := directoryCount(t, directoryOpProvide, directoryResultDenied); got != before+1 { + t.Fatalf("denied count = %v, want %v", got, before+1) + } + }) + + t.Run("invalid ingress targets rejected", func(t *testing.T) { + typeWideKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "") + egressKey, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_EGRESS, "openai") + for label, req := range map[string]*api.DirectoryRequest{ + "type-wide key": provideWithIngress(typeWideKey, "a2a://assistant", &api.ServiceIngress{AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE}, memberAddr), + "peer key": provideWithIngress(api.PeerKey(edgeMember.ID()), "", &api.ServiceIngress{AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE}, memberAddr), + "egress": provideWithIngress(egressKey, "egress://openai", &api.ServiceIngress{AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE}, memberAddr), + } { + before := directoryCount(t, directoryOpProvide, directoryResultInvalid) + resp := ask(t, edgeMember, a.Host.ID(), req) + if resp.GetError() == "" { + t.Fatalf("%s: accepted, want invalid refusal", label) + } + if got := directoryCount(t, directoryOpProvide, directoryResultInvalid); got != before+1 { + t.Fatalf("%s: invalid count = %v, want %v", label, got, before+1) + } + } + }) + + t.Run("providerEntryOfRecord rejects invalid ingress metadata from peer router", func(t *testing.T) { + badRec := &api.PeerRecord{ + PeerId: edgeMember.ID().String(), + Addrs: []string{memberAddr}, + Ingress: &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + Allow: []string{"email:alice@example.com"}, + }, + } + if _, err := providerEntryOfRecord(badRec); err == nil { + t.Fatal("providerEntryOfRecord accepted PASSTHROUGH with allow list, want error") + } + }) + + t.Run("EDGE announcement accepted and propagated across federated routers", func(t *testing.T) { + ing := &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com"}, + } + resp := ask(t, edgeMember, a.Host.ID(), provideWithIngress(assistantKey, "a2a://assistant", ing, memberAddr)) + if resp.GetError() != "" { + t.Fatalf("EDGE provide failed: %s", resp.GetError()) + } + + closest := a.DHT.RoutingTable().NearestPeers(kb.ConvertKey(string(assistantKey)), 1) + if len(closest) != 1 { + t.Fatalf("closest = %v, want 1 router", closest) + } + + for name, rtr := range map[string]*Router{"own": a, "peer_b": b, "peer_c": c} { + asker := attach(t, rtr) + found := ask(t, asker, rtr.Host.ID(), findProviders(assistantKey)) + if found.GetError() != "" { + t.Fatalf("%s find_providers failed: %s", name, found.GetError()) + } + if len(found.GetProviders()) != 1 { + t.Fatalf("%s providers = %v, want 1", name, found.GetProviders()) + } + gotIng := found.GetProviders()[0].GetIngress() + if gotIng == nil || gotIng.GetAuthMode() != api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE { + t.Fatalf("%s ingress = %v, want EDGE", name, gotIng) + } + if len(gotIng.GetAllow()) != 1 || gotIng.GetAllow()[0] != "email:alice@example.com" { + t.Fatalf("%s ingress.allow = %v, want [email:alice@example.com]", name, gotIng.GetAllow()) + } + } + }) +} + +func TestRouterIngressHandlerEndToEnd(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + r := newDirectoryRouter(t) + r.config.ControlPlaneURL = "https://hub.example.com" + r.config.BiscuitTimeout = 2 * time.Second + limiter, err := ratelimit.NewPeerRateLimiterWithRate(128, ingressRateLimitPerIP, ingressRateBurstPerIP) + if err != nil { + t.Fatalf("NewPeerRateLimiterWithRate: %v", err) + } + r.ingressLimiter = limiter + + routerBiscuitRaw, err := identity.MintBootstrapBiscuitToken(cpPriv, r.Host.ID(), api.RoleRouter, time.Now().Add(time.Hour), []*api.PolicyRole{ + {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + }, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken(router): %v", err) + } + r.keysMu.Lock() + r.trustedPublicKeys = []ed25519.PublicKey{cpPub} + r.credential = credential{biscuit: routerBiscuitRaw, expiration: time.Now().Add(time.Hour)} + r.keysMu.Unlock() + + providerRole := &api.PolicyRole{ + Name: "agent-host", + AllowedPublish: []string{"a2a://*", "mcp://*"}, + AllowedIngress: []*api.IngressGrant{ + { + Services: []string{"a2a://echo", "mcp://calc"}, + }, + { + Services: []string{"a2a://paired"}, + AllowedAuthModes: []api.IngressAuthMode{api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + }, + }, + } + callerRole := &api.PolicyRole{ + Name: "caller", + AllowedServices: []string{"a2a://echo", "mcp://calc"}, + AllowedTargets: []string{"*"}, + } + roles := []*api.PolicyRole{providerRole, callerRole} + bindings := []*api.PolicyBinding{ + {Role: "caller", Members: []string{"email:alice@example.com", "email:bob@example.com"}}, + } + compiled, warnings := api.BuildPolicyRules(roles, bindings) + if len(warnings) != 0 { + t.Fatalf("BuildPolicyRules warnings: %v", warnings) + } + meshRules, err := api.ParseDatalogRules(api.PolicyRuleTexts(compiled)) + if err != nil { + t.Fatalf("ParseDatalogRules: %v", err) + } + r.policy.Store(&meshPolicy{rules: meshRules}) + + provider := attachWithRole(t, r, cpPub, cpPriv, providerRole) + providerAddr := provider.Addrs()[0].String() + "/p2p/" + provider.ID().String() + + // Simulated node /mesh/ingress/1.0.0 HTTP listener inspecting forwarded headers. + type capturedReq struct { + Path string `json:"path"` + Auth string `json:"auth"` + MeshIngress string `json:"mesh_ingress"` + MeshBiscuit bool `json:"mesh_biscuit"` + CallerBiscuit bool `json:"caller_biscuit"` + SpoofedHeader string `json:"spoofed_header"` + } + ingListener, err := gostream.Listen(provider, api.IngressProtocolID) + if err != nil { + t.Fatalf("gostream.Listen: %v", err) + } + t.Cleanup(func() { _ = ingListener.Close() }) + + upstreamMux := http.NewServeMux() + upstreamMux.HandleFunc("/a2a/echo/.well-known/agent-card.json", func(w http.ResponseWriter, _ *http.Request) { + card := a2a.AgentCard{ + Name: "Echo Agent", + Description: "Test A2A service", + Version: "1.0.0", + SupportedInterfaces: []*a2a.AgentInterface{ + {ProtocolBinding: a2a.TransportProtocolJSONRPC, URL: ""}, + }, + Signatures: []a2a.AgentCardSignature{{Protected: "p", Signature: "s"}}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + }) + upstreamMux.HandleFunc("/a2a/paired/.well-known/agent-card.json", func(w http.ResponseWriter, _ *http.Request) { + card := a2a.AgentCard{ + Name: "Paired Agent", + Description: "Passthrough A2A service", + Version: "1.0.0", + SupportedInterfaces: []*a2a.AgentInterface{ + {ProtocolBinding: a2a.TransportProtocolJSONRPC, URL: "rpc"}, + }, + SecuritySchemes: a2a.NamedSecuritySchemes{ + "pair_token": a2a.HTTPAuthSecurityScheme{Scheme: "bearer", Description: "a2a-serve pair token"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + }) + upstreamMux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(capturedReq{ + Path: req.URL.Path, + Auth: req.Header.Get("Authorization"), + MeshIngress: req.Header.Get(api.HeaderMeshIngress), + MeshBiscuit: req.Header.Get(api.HeaderMeshBiscuit) != "", + CallerBiscuit: req.Header.Get(api.HeaderMeshIngressCallerBiscuit) != "", + SpoofedHeader: req.Header.Get("X-Mesh-Spoofed"), + }) + }) + upstreamSrv := &http.Server{Handler: upstreamMux, ReadHeaderTimeout: 5 * time.Second} + go func() { _ = upstreamSrv.Serve(ingListener) }() + t.Cleanup(func() { _ = upstreamSrv.Close() }) + + // Announce: + // 1. a2a://internal (no ingress -> must 404 externally) + // 2. a2a://echo (EDGE + allow: ["email:alice@example.com"]) + // 3. mcp://calc (EDGE) + // 4. a2a://paired (PASSTHROUGH) + mustProvide := func(svcType api.ServiceType, name string, ing *api.ServiceIngress) { + t.Helper() + key, err := api.ServiceKey(svcType, name) + if err != nil { + t.Fatal(err) + } + typeStr, _ := api.ServiceTypeToString(svcType) + resp := ask(t, provider, r.Host.ID(), provideWithIngress(key, typeStr+"://"+name, ing, providerAddr)) + if resp.GetError() != "" { + t.Fatalf("provide %s://%s failed: %s", typeStr, name, resp.GetError()) + } + } + mustProvide(api.ServiceType_SERVICE_TYPE_A2A, "internal", nil) + mustProvide(api.ServiceType_SERVICE_TYPE_A2A, "echo", &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com"}, + }) + mustProvide(api.ServiceType_SERVICE_TYPE_MCP, "calc", &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + }) + mustProvide(api.ServiceType_SERVICE_TYPE_A2A, "paired", &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }) + + if err := r.serveIngress("127.0.0.1:0"); err != nil { + t.Fatalf("serveIngress: %v", err) + } + t.Cleanup(func() { + if r.ingressServer != nil { + _ = r.ingressServer.Close() + } + }) + r.isReady.Store(true) + + if r.IngressAddr() == nil { + t.Fatal("IngressAddr() is nil after serveIngress") + } + baseURL := "http://" + r.IngressAddr().String() + "/ingress" + client := &http.Client{Timeout: 5 * time.Second} + + t.Run("healthz and RFC 9728 protected resource metadata", func(t *testing.T) { + resp, err := client.Get(baseURL + "/healthz") + if err != nil { + t.Fatalf("GET /ingress/healthz: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + + metaResp, err := client.Get(baseURL + "/.well-known/oauth-protected-resource") + if err != nil { + t.Fatalf("GET oauth-protected-resource: %v", err) + } + defer func() { _ = metaResp.Body.Close() }() + if metaResp.StatusCode != http.StatusOK { + t.Fatalf("metadata status = %d, want 200", metaResp.StatusCode) + } + var doc api.OAuthProtectedResourceMetadata + if err := json.NewDecoder(metaResp.Body).Decode(&doc); err != nil { + t.Fatalf("decode metadata: %v", err) + } + if doc.Resource != baseURL { + t.Fatalf("Resource = %q, want %q", doc.Resource, baseURL) + } + if len(doc.AuthorizationServers) != 1 || doc.AuthorizationServers[0] != "https://hub.example.com" { + t.Fatalf("AuthorizationServers = %v, want [https://hub.example.com]", doc.AuthorizationServers) + } + }) + + t.Run("internal-only service returns 404", func(t *testing.T) { + resp, err := client.Get(baseURL + "/" + provider.ID().String() + "/a2a/internal/.well-known/agent-card.json") + if err != nil { + t.Fatalf("GET internal service: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("internal service status = %d, want 404", resp.StatusCode) + } + }) + + t.Run("EDGE unauthenticated request returns 401 with RFC 9728 WWW-Authenticate", func(t *testing.T) { + resp, err := client.Post(baseURL+"/"+provider.ID().String()+"/a2a/echo", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("POST echo: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + www := resp.Header.Get("WWW-Authenticate") + wantSub := baseURL + "/.well-known/oauth-protected-resource" + if !strings.Contains(www, wantSub) { + t.Fatalf("WWW-Authenticate = %q, want substring %q", www, wantSub) + } + }) + + t.Run("EDGE rejects raw compact JWT with 401 pointing callers to /oauth/token exchange", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST with compact JWT: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("compact JWT status = %d, want 401", resp.StatusCode) + } + www := resp.Header.Get("WWW-Authenticate") + wantSub := baseURL + "/.well-known/oauth-protected-resource" + if !strings.Contains(www, wantSub) { + t.Fatalf("WWW-Authenticate = %q, want substring %q", www, wantSub) + } + }) + + t.Run("EDGE AgentCard discovery rewrites URL, injects Biscuit + OAuth2 SecuritySchemes, and honors ETag", func(t *testing.T) { + cardEndpoint := baseURL + "/" + provider.ID().String() + "/a2a/echo" + resp, err := client.Get(cardEndpoint) + if err != nil { + t.Fatalf("GET bare A2A service URL: %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("AgentCard status = %d (%s), want 200", resp.StatusCode, string(body)) + } + etag := resp.Header.Get("ETag") + if etag == "" { + t.Fatal("missing ETag header on AgentCard response") + } + var card a2a.AgentCard + if err := json.Unmarshal(body, &card); err != nil { + t.Fatalf("Unmarshal AgentCard: %v", err) + } + if len(card.SupportedInterfaces) != 1 || card.SupportedInterfaces[0].URL != cardEndpoint { + t.Fatalf("SupportedInterfaces = %+v, want URL %q", card.SupportedInterfaces, cardEndpoint) + } + if len(card.Signatures) != 0 { + t.Fatalf("Signatures = %v, want cleared after URL rewrite", card.Signatures) + } + for _, schemeName := range []a2a.SecuritySchemeName{"bearer", "agentmesh_oauth2"} { + if _, ok := card.SecuritySchemes[schemeName]; !ok { + t.Fatalf("SecuritySchemes missing %q: %+v", schemeName, card.SecuritySchemes) + } + } + if _, hasOIDC := card.SecuritySchemes["oidc"]; hasOIDC { + t.Fatalf("SecuritySchemes unexpectedly advertised raw oidc scheme: %+v", card.SecuritySchemes) + } + + // Conditional GET with If-None-Match returns 304 Not Modified. + condReq, _ := http.NewRequest(http.MethodGet, cardEndpoint+"/.well-known/agent-card.json", nil) + condReq.Header.Set("If-None-Match", etag) + condResp, err := client.Do(condReq) + if err != nil { + t.Fatalf("conditional GET AgentCard: %v", err) + } + _ = condResp.Body.Close() + if condResp.StatusCode != http.StatusNotModified { + t.Fatalf("conditional GET status = %d, want 304", condResp.StatusCode) + } + }) + + t.Run("EDGE Delegated Session Biscuit, ServiceIngress.allow, TAR MCP tool check, and revocation", func(t *testing.T) { + aliceRaw, err := identity.MintDelegatedBiscuitToken(cpPriv, jwt.MapClaims{ + "sub": "alice", + "email": "alice@example.com", + }, provider.ID(), time.Now().Add(time.Hour), []string{"caller"}, roles, &api.TaskAuthorizationRule{ + Name: "calc-add-only", + Rules: []*api.TaskRule{ + {AllowedServices: []string{"a2a://echo"}}, + { + AllowedServices: []string{"mcp://calc"}, + Operation: &api.TaskOperation{AllowedTools: []string{"add"}}, + }, + }, + }, true) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken(alice): %v", err) + } + aliceTok := base64.StdEncoding.EncodeToString(aliceRaw) + + // Authorized A2A POST from Alice -> 200, headers sanitized, caller biscuit attached. + req, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo/message:send", strings.NewReader(`{"jsonrpc":"2.0"}`)) + req.Header.Set("Authorization", "Bearer "+aliceTok) + req.Header.Set("X-Mesh-Spoofed", "evil") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST echo as alice: %v", err) + } + var got capturedReq + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode upstream echo: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("alice status = %d, want 200", resp.StatusCode) + } + if got.Path != "/a2a/echo/message:send" || got.MeshIngress != api.IngressAuthModeStringEdge || !got.MeshBiscuit || !got.CallerBiscuit || got.Auth != "" || got.SpoofedHeader != "" { + t.Fatalf("unexpected upstream headers: %+v", got) + } + + // Bob has mesh role "caller", but a2a://echo sets ServiceIngress.allow: ["email:alice@example.com"] -> 403. + bobRaw, err := identity.MintDelegatedBiscuitToken(cpPriv, jwt.MapClaims{ + "sub": "bob", + "email": "bob@example.com", + }, provider.ID(), time.Now().Add(time.Hour), []string{"caller"}, roles, nil, false) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken(bob): %v", err) + } + bobReq, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo/message:send", strings.NewReader(`{}`)) + bobReq.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(bobRaw)) + bobResp, err := client.Do(bobReq) + if err != nil { + t.Fatalf("POST echo as bob: %v", err) + } + _ = bobResp.Body.Close() + if bobResp.StatusCode != http.StatusForbidden { + t.Fatalf("bob status = %d, want 403 (blocked by ServiceIngress.allow)", bobResp.StatusCode) + } + + // MCP tools/call: "add" allowed by TAR -> 200; "drop_db" denied by TAR -> 403. + addReq, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/mcp/calc", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add"}}`)) + addReq.Header.Set("Authorization", "Bearer "+aliceTok) + addResp, err := client.Do(addReq) + if err != nil { + t.Fatalf("MCP add: %v", err) + } + _ = addResp.Body.Close() + if addResp.StatusCode != http.StatusOK { + t.Fatalf("MCP add status = %d, want 200", addResp.StatusCode) + } + + dropReq, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/mcp/calc", strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"drop_db"}}`)) + dropReq.Header.Set("Authorization", "Bearer "+aliceTok) + dropResp, err := client.Do(dropReq) + if err != nil { + t.Fatalf("MCP drop_db: %v", err) + } + _ = dropResp.Body.Close() + if dropResp.StatusCode != http.StatusForbidden { + t.Fatalf("MCP drop_db status = %d, want 403", dropResp.StatusCode) + } + + // Revoked Biscuit -> 401 (both hex and base64url revocation IDs work). + aliceB, _ := biscuit.Unmarshal(aliceRaw) + for _, revID := range aliceB.RevocationIds() { + r.recordRevokedBiscuitID(hex.EncodeToString(revID), time.Now().Add(time.Hour)) + } + revReq, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo", bytes.NewReader([]byte(`{}`))) + revReq.Header.Set("Authorization", "Bearer "+aliceTok) + revResp, err := client.Do(revReq) + if err != nil { + t.Fatalf("POST with revoked biscuit: %v", err) + } + _ = revResp.Body.Close() + if revResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("revoked biscuit status = %d, want 401", revResp.StatusCode) + } + }) + + t.Run("PASSTHROUGH preserves backend Authorization and SecuritySchemes while stripping X-Mesh-*", func(t *testing.T) { + cardEndpoint := baseURL + "/" + provider.ID().String() + "/a2a/paired" + cardResp, err := client.Get(cardEndpoint + "/.well-known/agent-card.json") + if err != nil { + t.Fatalf("GET paired AgentCard: %v", err) + } + var card a2a.AgentCard + if err := json.NewDecoder(cardResp.Body).Decode(&card); err != nil { + t.Fatalf("decode paired AgentCard: %v", err) + } + _ = cardResp.Body.Close() + if len(card.SupportedInterfaces) != 1 || card.SupportedInterfaces[0].URL != cardEndpoint+"/rpc" { + t.Fatalf("paired SupportedInterfaces = %+v, want %q", card.SupportedInterfaces, cardEndpoint+"/rpc") + } + if _, ok := card.SecuritySchemes["pair_token"]; !ok { + t.Fatalf("PASSTHROUGH overwrote backend SecuritySchemes: %+v", card.SecuritySchemes) + } + + req, _ := http.NewRequest(http.MethodPost, cardEndpoint+"/rpc", strings.NewReader(`{"jsonrpc":"2.0"}`)) + req.Header.Set("Authorization", "Bearer a2p_secret_pair_token") + req.Header.Set("X-Mesh-Spoofed", "evil") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST passthrough: %v", err) + } + var got capturedReq + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode passthrough upstream: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("passthrough status = %d, want 200", resp.StatusCode) + } + if got.Auth != "Bearer a2p_secret_pair_token" || got.MeshIngress != api.IngressAuthModeStringPassthrough || got.SpoofedHeader != "" { + t.Fatalf("unexpected passthrough upstream headers: %+v", got) + } + }) + + t.Run("banned provider returns immediate 404", func(t *testing.T) { + r.bannedPeers.Store(provider.ID(), struct{}{}) + r.records.removePeer(provider.ID()) + resp, err := client.Get(baseURL + "/" + provider.ID().String() + "/a2a/paired/.well-known/agent-card.json") + if err != nil { + t.Fatalf("GET banned provider: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("banned provider status = %d, want 404", resp.StatusCode) + } + }) +} + +func TestLeasedIngressURLAndHostHeaderHardening(t *testing.T) { + r := &Router{ + config: Options{ + ControlPlaneURL: "https://internal-cp.cluster.local:8443", + PublicControlPlaneURL: "https://bananas.sam-mesh.dev", + }, + } + + for _, addr := range []*net.TCPAddr{ + {IP: net.IPv4zero, Port: 8090}, + {IP: net.IPv6zero, Port: 8090}, + } { + r.ingressAddr = addr + if got := r.leasedIngressURL(); got != "" { + t.Fatalf("leasedIngressURL() for unspecified %s = %q, want empty string", addr, got) + } + if got := r.effectiveIngressBaseURL(); got != "https://bananas.sam-mesh.dev/ingress" { + t.Fatalf("effectiveIngressBaseURL() for unspecified %s = %q, want https://bananas.sam-mesh.dev/ingress", addr, got) + } + } + + // Unbound IngressHandler must never reflect a spoofed Host header into + // RFC 9728 metadata or WWW-Authenticate headers. + r.ingressAddr = nil + h := r.IngressHandler() + req := httptest.NewRequest(http.MethodGet, "http://evil.attacker.example/ingress/.well-known/oauth-protected-resource", nil) + req.Host = "evil.attacker.example" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("metadata status = %d, want 200", rec.Code) + } + if strings.Contains(rec.Body.String(), "evil.attacker.example") { + t.Fatalf("metadata leaked spoofed Host header: %s", rec.Body.String()) + } + var doc api.OAuthProtectedResourceMetadata + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("Unmarshal metadata: %v", err) + } + if doc.Resource != "https://bananas.sam-mesh.dev/ingress" { + t.Fatalf("Resource = %q, want https://bananas.sam-mesh.dev/ingress", doc.Resource) + } +} + +func TestEdgeIngressPreDialRejectsBeforeDialingProvider(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + r := newDirectoryRouter(t) + r.config.ControlPlaneURL = "https://hub.example.com" + r.config.BiscuitTimeout = 2 * time.Second + r.isReady.Store(true) + + routerBiscuitRaw, err := identity.MintBootstrapBiscuitToken(cpPriv, r.Host.ID(), api.RoleRouter, time.Now().Add(time.Hour), []*api.PolicyRole{ + {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + }, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + r.keysMu.Lock() + r.trustedPublicKeys = []ed25519.PublicKey{cpPub} + r.credential = credential{biscuit: routerBiscuitRaw, expiration: time.Now().Add(time.Hour)} + r.keysMu.Unlock() + + // Create a synthetic unreachable provider PeerID with a closed loopback port. + // If handleIngressRequest dialed the provider before checking the caller's + // Biscuit, it would fail with 502 Bad Gateway instead of 401/403. + priv, _, err := libp2pcrypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatalf("GenerateEd25519Key: %v", err) + } + unreachablePeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + key, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "echo") + if err != nil { + t.Fatalf("ServiceKey: %v", err) + } + r.records.putWithIngress( + key, + unreachablePeer, + []multiaddr.Multiaddr{multiaddr.StringCast("/ip4/127.0.0.1/tcp/1")}, + &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + Allow: []string{"email:alice@example.com"}, + }, + time.Now(), + ) + + callerRole := &api.PolicyRole{ + Name: "caller", + AllowedServices: []string{"a2a://echo"}, + AllowedTargets: []string{"*"}, + } + bobRaw, err := identity.MintDelegatedBiscuitToken(cpPriv, jwt.MapClaims{ + "sub": "bob", + "email": "bob@example.com", + }, unreachablePeer, time.Now().Add(time.Hour), []string{"caller"}, []*api.PolicyRole{callerRole}, nil, false) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken(bob): %v", err) + } + standingNodeRaw, _, err := identity.MintBiscuitToken(cpPriv, jwt.MapClaims{ + "sub": "alice", + "email": "alice@example.com", + }, nil, unreachablePeer, time.Now().Add(time.Hour), []string{"caller"}, []*api.PolicyRole{callerRole}, nil) + if err != nil { + t.Fatalf("MintBiscuitToken(standing): %v", err) + } + + h := r.IngressHandler() + targetPath := "/ingress/" + unreachablePeer.String() + "/a2a/echo/message:send" + + cases := []struct { + name string + authHeader string + wantStatus int + }{ + { + name: "unauthenticated rejected 401 before dial", + authHeader: "", + wantStatus: http.StatusUnauthorized, + }, + { + name: "compact JWT rejected 401 before dial", + authHeader: "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig", + wantStatus: http.StatusUnauthorized, + }, + { + name: "ServiceIngress.allow mismatch rejected 403 before dial", + authHeader: "Bearer " + base64.StdEncoding.EncodeToString(bobRaw), + wantStatus: http.StatusForbidden, + }, + { + name: "unattenuated standing node Biscuit rejected 403 before dial", + authHeader: "Bearer " + base64.StdEncoding.EncodeToString(standingNodeRaw), + wantStatus: http.StatusForbidden, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, targetPath, strings.NewReader(`{}`)) + if tc.authHeader != "" { + req.Header.Set("Authorization", tc.authHeader) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d (%s), want %d (must not dial unreachable provider)", rec.Code, strings.TrimSpace(rec.Body.String()), tc.wantStatus) + } + }) + } +} + +func TestRevocationSetDoesNotEvictLiveEntries(t *testing.T) { + _, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + priv, _, err := libp2pcrypto.GenerateEd25519Key(rand.Reader) + if err != nil { + t.Fatalf("GenerateEd25519Key: %v", err) + } + pid, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + firstRaw, err := identity.MintDelegatedBiscuitToken(cpPriv, jwt.MapClaims{"sub": "first"}, pid, time.Now().Add(time.Hour), nil, nil, nil, false) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken: %v", err) + } + firstB, err := biscuit.Unmarshal(firstRaw) + if err != nil { + t.Fatalf("biscuit.Unmarshal: %v", err) + } + + var set revocationSet + now := time.Now() + // Record the first Biscuit's root revocation ID in hex format. + set.add(hex.EncodeToString(firstB.RevocationIds()[0]), now.Add(time.Hour)) + + // Add 5,000 more live revocations (> old 4096 LRU capacity) plus expired entries. + for i := 0; i < 5000; i++ { + var id [32]byte + id[0] = byte(i >> 8) + id[1] = byte(i) + id[2] = 1 + set.add(base64.RawURLEncoding.EncodeToString(id[:]), now.Add(time.Hour)) + } + set.mu.Lock() + set.entries["already-expired"] = now.Add(-time.Minute) + set.mu.Unlock() + + if !set.isRevoked(firstB, now) { + t.Fatal("first revoked Biscuit was evicted after 5000 inserts; deny list must never evict live entries") + } + if got := set.activeCount(now); got != 5001 { + t.Fatalf("activeCount = %d, want 5001", got) + } +} + +func TestGatewaySafeIngressRateLimiting(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + r := newDirectoryRouter(t) + r.config.ControlPlaneURL = "https://hub.example.com" + r.config.BiscuitTimeout = 2 * time.Second + limiter, err := ratelimit.NewPeerRateLimiterWithRate(128, 1, 2) + if err != nil { + t.Fatalf("NewPeerRateLimiterWithRate: %v", err) + } + r.ingressLimiter = limiter + r.isReady.Store(true) + + routerBiscuitRaw, err := identity.MintBootstrapBiscuitToken(cpPriv, r.Host.ID(), api.RoleRouter, time.Now().Add(time.Hour), []*api.PolicyRole{ + {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + }, nil) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + r.keysMu.Lock() + r.trustedPublicKeys = []ed25519.PublicKey{cpPub} + r.credential = credential{biscuit: routerBiscuitRaw, expiration: time.Now().Add(time.Hour)} + r.keysMu.Unlock() + + providerRole := &api.PolicyRole{ + Name: "agent-host", + AllowedPublish: []string{"a2a://*"}, + AllowedIngress: []*api.IngressGrant{{Services: []string{"a2a://echo"}}}, + } + callerRole := &api.PolicyRole{ + Name: "caller", + AllowedServices: []string{"a2a://echo"}, + AllowedTargets: []string{"*"}, + } + roles := []*api.PolicyRole{providerRole, callerRole} + provider := attachWithRole(t, r, cpPub, cpPriv, providerRole) + providerAddr := provider.Addrs()[0].String() + "/p2p/" + provider.ID().String() + + ingListener, err := gostream.Listen(provider, api.IngressProtocolID) + if err != nil { + t.Fatalf("gostream.Listen: %v", err) + } + t.Cleanup(func() { _ = ingListener.Close() }) + upstreamSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { _ = upstreamSrv.Serve(ingListener) }() + t.Cleanup(func() { _ = upstreamSrv.Close() }) + + key, _ := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "echo") + resp := ask(t, provider, r.Host.ID(), provideWithIngress(key, "a2a://echo", &api.ServiceIngress{ + AuthMode: api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + }, providerAddr)) + if resp.GetError() != "" { + t.Fatalf("provide failed: %s", resp.GetError()) + } + + mintCaller := func(sub string) string { + raw, err := identity.MintDelegatedBiscuitToken(cpPriv, jwt.MapClaims{"sub": sub}, provider.ID(), time.Now().Add(time.Hour), []string{"caller"}, roles, nil, false) + if err != nil { + t.Fatalf("MintDelegatedBiscuitToken(%s): %v", sub, err) + } + return base64.StdEncoding.EncodeToString(raw) + } + tokA := mintCaller("alice") + tokB := mintCaller("bob") + + h := r.IngressHandler() + sharedGatewayRemoteAddr := "10.128.0.5:44321" + targetPath := "/ingress/" + provider.ID().String() + "/a2a/echo" + + send := func(tok string) int { + req := httptest.NewRequest(http.MethodPost, targetPath, strings.NewReader(`{}`)) + req.RemoteAddr = sharedGatewayRemoteAddr + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code + } + + // Exhaust Caller A's burst of 2 through the shared Gateway IP. + if code := send(tokA); code != http.StatusOK { + t.Fatalf("Caller A req 1 = %d, want 200", code) + } + if code := send(tokA); code != http.StatusOK { + t.Fatalf("Caller A req 2 = %d, want 200", code) + } + if code := send(tokA); code != http.StatusTooManyRequests { + t.Fatalf("Caller A req 3 = %d, want 429", code) + } + + // Caller B sharing the exact same Gateway RemoteAddr must NOT be starved. + if code := send(tokB); code != http.StatusOK { + t.Fatalf("Caller B behind same Gateway IP = %d, want 200", code) + } +} diff --git a/internal/router/metrics.go b/internal/router/metrics.go index fef7a1db..1ba8d75c 100644 --- a/internal/router/metrics.go +++ b/internal/router/metrics.go @@ -119,6 +119,7 @@ type routerStateCollector struct { watermarkDesc *prometheus.Desc relayLimitDesc *prometheus.Desc bannedDesc *prometheus.Desc + revokedDesc *prometheus.Desc dhtDesc *prometheus.Desc recordsDesc *prometheus.Desc biscuitExpiryDesc *prometheus.Desc @@ -167,6 +168,10 @@ func newRouterStateCollector(r *Router) *routerStateCollector { "agentmesh_router_banned_peers", "Peers on the ban list synced from the control plane", nil, nil), + revokedDesc: prometheus.NewDesc( + "agentmesh_router_revoked_tokens", + "Active revoked Biscuit tokens held in this router's deny list", + nil, nil), dhtDesc: prometheus.NewDesc( "agentmesh_router_dht_routing_table_size", "Peers in the Kademlia routing table", @@ -185,7 +190,7 @@ func newRouterStateCollector(r *Router) *routerStateCollector { func (c *routerStateCollector) Describe(ch chan<- *prometheus.Desc) { for _, d := range []*prometheus.Desc{ c.infoDesc, c.readyDesc, c.drainingDesc, c.authenticatedDesc, c.peerRoutersDesc, c.connectedDesc, c.connectionsDesc, c.watermarkDesc, c.relayLimitDesc, - c.bannedDesc, c.dhtDesc, c.recordsDesc, c.biscuitExpiryDesc, connsPerSourceIPDesc, + c.bannedDesc, c.revokedDesc, c.dhtDesc, c.recordsDesc, c.biscuitExpiryDesc, connsPerSourceIPDesc, } { ch <- d } @@ -228,6 +233,7 @@ func (c *routerStateCollector) Collect(ch chan<- prometheus.Metric) { ch <- prometheus.MustNewConstMetric(c.connectionsDesc, prometheus.GaugeValue, float64(inbound), "inbound") ch <- prometheus.MustNewConstMetric(c.connectionsDesc, prometheus.GaugeValue, float64(outbound), "outbound") ch <- prometheus.MustNewConstMetric(c.bannedDesc, prometheus.GaugeValue, float64(syncMapLen(&r.bannedPeers))) + ch <- prometheus.MustNewConstMetric(c.revokedDesc, prometheus.GaugeValue, float64(r.revokedTokens.activeCount(time.Now()))) ch <- prometheus.MustNewConstMetric(c.dhtDesc, prometheus.GaugeValue, float64(r.DHT.RoutingTable().Size())) ch <- prometheus.MustNewConstMetric(c.recordsDesc, prometheus.GaugeValue, float64(r.records.size())) diff --git a/internal/router/metrics_test.go b/internal/router/metrics_test.go index 7eeab451..3a902691 100644 --- a/internal/router/metrics_test.go +++ b/internal/router/metrics_test.go @@ -89,6 +89,7 @@ func TestRouterMetricsListener(t *testing.T) { r.authenticatedPeers.Store(peer.ID("peer-a"), true) r.authenticatedPeers.Store(peer.ID("peer-b"), true) r.bannedPeers.Store(peer.ID("peer-x"), true) + r.recordRevokedBiscuitID("revoked-token-1", time.Now().Add(time.Hour)) // Two peer routers listed, of which one is authenticated. r.peerRouters.Store(peer.ID("peer-a"), true) r.peerRouters.Store(peer.ID("peer-r"), true) @@ -119,6 +120,7 @@ func TestRouterMetricsListener(t *testing.T) { `agentmesh_router_peer_routers{state="listed"} 2`, `agentmesh_router_peer_routers{state="authenticated"} 1`, "agentmesh_router_banned_peers 1", + "agentmesh_router_revoked_tokens 1", "agentmesh_router_connected_peers 0", "agentmesh_router_dht_routing_table_size 0", "agentmesh_router_directory_records 1", diff --git a/internal/router/resource_limits.go b/internal/router/resource_limits.go index 594f1684..c3d7fb06 100644 --- a/internal/router/resource_limits.go +++ b/internal/router/resource_limits.go @@ -93,6 +93,7 @@ func hubLimits(highWaterMark, conns, circuits int) rcmgr.ConcreteLimitConfig { protocols := map[protocol.ID]rcmgr.ResourceLimits{ circuit.ProtoIDv2Hop: relayStreams, circuit.ProtoIDv2Stop: relayStreams, + api.IngressProtocolID: streams(connCap), } // Every connection, not every member: a member attached elsewhere holds // a connection here for its DHT queries, with a DHT stream and gossip @@ -120,6 +121,7 @@ func hubLimits(highWaterMark, conns, circuits int) rcmgr.ConcreteLimitConfig { ProtocolPeer: map[protocol.ID]rcmgr.ResourceLimits{ circuit.ProtoIDv2Hop: perPeerCircuits, circuit.ProtoIDv2Stop: perPeerCircuits, + api.IngressProtocolID: perPeerCircuits, }, } return overrides.Build(scaled) diff --git a/internal/router/router.go b/internal/router/router.go index 4ce31fb2..4bc34d84 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -395,6 +395,12 @@ type Router struct { metricsServer *http.Server metricsAddr net.Addr + ingressServer *http.Server + ingressAddr net.Addr + ingressLimiter *ratelimit.PeerRateLimiter + + revokedTokens revocationSet + // identityLoggedAt is when the "Router Online" line was last written. identityLoggedAt atomic.Pointer[time.Time] // leasedAddrs is the address list of the last accepted lease, joined. @@ -416,12 +422,18 @@ func NewRouter(ctx context.Context, config Options) (*Router, error) { cancel() return nil, fmt.Errorf("failed to create handshake rate limiter: %w", err) } + ingressLimiter, err := ratelimit.NewPeerRateLimiterWithRate(handshakeLimiterSize, ingressRateLimitPerIP, ingressRateBurstPerIP) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to create ingress rate limiter: %w", err) + } return &Router{ config: config, ctx: ctx, cancel: cancel, handshakeLimiter: handshakeLimiter, + ingressLimiter: ingressLimiter, syncTrigger: make(chan struct{}, 1), }, nil } @@ -600,6 +612,14 @@ func (r *Router) Start() (retErr error) { }, }) + if r.config.IngressAddr != "" { + if err := r.serveIngress(r.config.IngressAddr); err != nil { + return fmt.Errorf("failed to start ingress listener: %w", err) + } + } + + r.isReady.Store(true) + // 5. Start background routines r.wg.Add(5) go r.runControlPlaneSyncLoop() @@ -614,7 +634,6 @@ func (r *Router) Start() (retErr error) { p2plimits.ObserveStreamPressure(hostNode.Network().Conns, busiestConnStreams, logger.Warnf).Run(r.ctx, streamPressureInterval) }() - r.isReady.Store(true) logger.Infof("Router Online. PeerID: %s, ListenAddrs: %v", r.Host.ID(), r.Host.Addrs()) return nil } @@ -1048,6 +1067,7 @@ func (r *Router) listenForControlPlaneEvents(ctx context.Context) { if bannedPeer, err := peer.Decode(event.PeerId); err == nil { logger.Infof("[Router Event] Received BANNED event for peer %s, evicting from authenticated peers and adding to blocklist", bannedPeer) r.forgetPeer(bannedPeer) + r.records.removePeer(bannedPeer) // The timestamp is what stops an /info answer that was // already in flight from undoing this (see // reconcileBannedPeers). @@ -1121,17 +1141,181 @@ func (r *Router) runControlPlaneSyncLoop() { // signs for is refused until the rotation is learned and the credential // refreshed (syncKeys). func (r *Router) syncControlPlane() { - if err := r.syncKeys(); err != nil { + if err := r.syncKeys(); err != nil && r.ctx.Err() == nil { logger.Errorf("Failed keys sync: %v", err) } r.renewLease() r.connectBootstrapRouters() - if err := r.syncPolicy(); err != nil { + if err := r.syncPolicy(); err != nil && r.ctx.Err() == nil { logger.Errorf("Failed policy sync: %v", err) } + if err := r.syncRevocations(); err != nil && r.ctx.Err() == nil { + logger.Errorf("Failed revocations sync: %v", err) + } r.refreshExpiringCredential() } +func (r *Router) syncRevocations() error { + r.keysMu.RLock() + tok := r.credential.biscuit + r.keysMu.RUnlock() + if len(tok) == 0 { + return nil + } + fetchedAt := time.Now() + resp, err := r.controlPlane(10*time.Second).FetchRevocations(r.ctx, tok) + if errors.Is(err, cpclient.ErrNotFound) { + return nil + } + if err != nil { + return err + } + expiry := fetchedAt.Add(24 * time.Hour) + for _, revID := range resp.GetRevocationIds() { + r.recordRevokedBiscuitID(revID, expiry) + } + r.credentials.Range(func(k, v any) bool { + pid, ok := k.(peer.ID) + cred, credOK := v.(memberCredential) + if ok && credOK && r.isBiscuitRevoked(cred.biscuit) { + logger.Infof("[Revocations] Evicting peer %s with revoked biscuit", pid) + r.forgetPeer(pid) + if r.records != nil { + r.records.removePeer(pid) + } + if r.Host != nil { + _ = r.Host.Network().ClosePeer(pid) + } + } + return true + }) + if len(resp.GetBannedPeerIds()) > 0 { + r.reconcileBannedPeers(resp.GetBannedPeerIds(), fetchedAt) + } + return nil +} + +const revokedPruneMin = 64 + +// revocationSet holds active revoked Biscuit authority revocation IDs synced +// from the control plane. Entries expire by timestamp and are never evicted by +// capacity, so high-churn revocation cannot push live revocations out of the +// deny list. Zero-value safe for test routers constructed as &Router{}. +type revocationSet struct { + mu sync.Mutex + entries map[string]time.Time + pruneAt int +} + +func normalizeRevocationKey(revID string) string { + revID = strings.TrimSpace(revID) + if revID == "" { + return "" + } + if (len(revID) == 64 || len(revID) == 128) && isHexDigits(revID) { + if raw, err := hex.DecodeString(revID); err == nil && len(raw) > 0 { + return base64.RawURLEncoding.EncodeToString(raw) + } + } + return revID +} + +func isHexByte(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +func isHexDigits(s string) bool { + for i := 0; i < len(s); i++ { + if !isHexByte(s[i]) { + return false + } + } + return len(s) > 0 +} + +func (s *revocationSet) add(revID string, expiry time.Time) { + key := normalizeRevocationKey(revID) + if key == "" { + return + } + now := time.Now() + if !now.Before(expiry) { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.entries == nil { + s.entries = make(map[string]time.Time) + } + if len(s.entries) >= max(revokedPruneMin, s.pruneAt) { + for k, exp := range s.entries { + if !now.Before(exp) { + delete(s.entries, k) + } + } + s.pruneAt = max(revokedPruneMin, 2*len(s.entries)) + } + if existing, ok := s.entries[key]; !ok || expiry.After(existing) { + s.entries[key] = expiry + } +} + +func (s *revocationSet) isRevoked(b *biscuit.Biscuit, now time.Time) bool { + if s == nil || b == nil { + return false + } + ids := b.RevocationIds() + if len(ids) == 0 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.entries) == 0 { + return false + } + for _, id := range ids { + key := base64.RawURLEncoding.EncodeToString(id) + if exp, ok := s.entries[key]; ok { + if now.Before(exp) { + return true + } + delete(s.entries, key) + } + } + return false +} + +func (s *revocationSet) activeCount(now time.Time) int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for k, exp := range s.entries { + if now.Before(exp) { + n++ + } else { + delete(s.entries, k) + } + } + return n +} + +func (r *Router) recordRevokedBiscuitID(revID string, expiry time.Time) { + if r == nil { + return + } + r.revokedTokens.add(revID, expiry) +} + +func (r *Router) isBiscuitRevoked(b *biscuit.Biscuit) bool { + if r == nil || b == nil { + return false + } + return r.revokedTokens.isRevoked(b, time.Now()) +} + // refreshExpiringCredential refreshes the router's own credential once less // than a fifth of its lifetime is left, so a credential is never presented // close to its expiry. @@ -1276,6 +1460,7 @@ func (r *Router) sendLease(ctx context.Context, ttl *time.Duration, attempts int ChallengeSignature: sig, Connections: inbound, ConnectionLimit: int32(r.config.HighWaterMark), + IngressUrl: r.leasedIngressURL(), } if ttl != nil { req.Ttl = durationpb.New(*ttl) @@ -1427,6 +1612,7 @@ func (r *Router) reconcileBannedPeers(peerIDs []string, fetchedAt time.Time) { logger.Infof("[Federation] Peer %s is banned, adding to blocklist", p) r.bannedPeers.Store(p, fetchedAt) r.forgetPeer(p) + r.records.removePeer(p) if r.Host != nil { _ = r.Host.Network().ClosePeer(p) } @@ -1652,7 +1838,11 @@ var authHandshakeTimeout = 10 * time.Second // handshakeLimiterSize is how many distinct peers' handshake budgets are // tracked at once (LRU beyond that). -const handshakeLimiterSize = 4096 +const ( + handshakeLimiterSize = 4096 + ingressRateLimitPerIP = 200 + ingressRateBurstPerIP = 400 +) // HandleAuthHandshake processes incoming auth connections. // It is part of mutual auth: @@ -1709,6 +1899,14 @@ func (r *Router) HandleAuthHandshake(s network.Stream) { _ = s.Reset() return } + if r.isBiscuitRevoked(b) { + if r.authFailureLog.allow(remotePeer, time.Now()) { + logger.Warnf("[AuthN] Rejecting revoked biscuit for peer %s", remotePeer) + } + authHandshakesTotal.WithLabelValues(handshakeUnauthorized).Inc() + _ = s.Reset() + return + } r.admitPeer(remotePeer, expiry) r.credentials.Store(remotePeer, memberCredential{biscuit: b, key: verifyingKey}) @@ -1782,6 +1980,9 @@ func (r *Router) performMutualAuth(s network.Stream) error { if err != nil { return fmt.Errorf("failed to verify peer router biscuit: %w", err) } + if r.isBiscuitRevoked(b) { + return fmt.Errorf("peer router %s biscuit is revoked", remotePeer) + } // Enforce required role inside the biscuit if err := identity.RequireRole(b, verifyingKey, r.config.RequiredRole, r.config.BiscuitTimeout); err != nil { @@ -1809,6 +2010,14 @@ func (r *Router) Close() error { r.cancel() var errs []error + if r.ingressServer != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := r.ingressServer.Shutdown(shutdownCtx); err != nil { + _ = r.ingressServer.Close() + errs = append(errs, err) + } + shutdownCancel() + } if r.metricsServer != nil { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) if err := r.metricsServer.Shutdown(shutdownCtx); err != nil { diff --git a/internal/standalone/standalone.go b/internal/standalone/standalone.go index 80ab4a18..302543fd 100644 --- a/internal/standalone/standalone.go +++ b/internal/standalone/standalone.go @@ -34,6 +34,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" "github.com/google/agentmesh/api" @@ -363,17 +364,42 @@ func (s *Server) Start(ctx context.Context) (retErr error) { } mux.Handle(consoleBasePath+"/", consoleSrv.Handler()) + // Mount external HTTP ingress directly onto the embedded router's ingress + // handler so /ingress/* never flows through the control plane. + var ingressHandler atomic.Pointer[http.Handler] + mux.Handle("/ingress/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := ingressHandler.Load() + if h == nil { + http.Error(w, "Router ingress is not ready", http.StatusServiceUnavailable) + return + } + (*h).ServeHTTP(w, r) + })) + wsAddr, err := wsListenMultiaddr(s.opts.BindAddress) if err != nil { return err } + bindHost, _, err := net.SplitHostPort(s.opts.BindAddress) + if err != nil { + return err + } + if bindHost == "" { + bindHost = "0.0.0.0" + } var externalAddrs []string + var ingressURL string + var publicControlPlaneURL string + allowInsecureIngress := true if s.opts.ExternalURL != "" { ext, err := externalMultiaddr(s.opts.ExternalURL) if err != nil { return err } externalAddrs = []string{ext} + publicControlPlaneURL = strings.TrimRight(s.opts.ExternalURL, "/") + ingressURL = publicControlPlaneURL + "/ingress" + allowInsecureIngress = strings.HasPrefix(s.opts.ExternalURL, "http://") } routerKeyPath := filepath.Join(s.opts.DataDir, routerKeyFile) @@ -383,6 +409,7 @@ func (s *Server) Start(ctx context.Context) (retErr error) { rtr, err := router.NewRouter(ctx, router.Options{ ControlPlaneURL: "http://" + cp.Addr(), + PublicControlPlaneURL: publicControlPlaneURL, ListenAddrs: append([]string{wsAddr}, s.opts.P2PListen...), ExternalAddrs: externalAddrs, AllowLoopback: !s.opts.Router.DisallowLoopback, @@ -399,12 +426,18 @@ func (s *Server) Start(ctx context.Context) (retErr error) { // proxy (Cloud Run, L7 LBs) or NAT where every peer shares a few // source IPs; libp2p's default 8-conns-per-IP cap would throttle // the whole listener. - ConnsPerSourceIP: s.opts.Router.ConnsPerSourceIP, - HTTPFallbackHandler: s.wrapPublicHTTPHandler(mux), + ConnsPerSourceIP: s.opts.Router.ConnsPerSourceIP, + HTTPFallbackHandler: s.wrapPublicHTTPHandler(mux), + IngressURL: ingressURL, + AllowInsecureIngressHTTP: allowInsecureIngress, + AdvertiseSinglePortIngress: true, + SinglePortBindHost: bindHost, }) if err != nil { return fmt.Errorf("failed to create router: %w", err) } + ingH := rtr.IngressHandler() + ingressHandler.Store(&ingH) if err := rtr.Start(); err != nil { _ = rtr.Close() return fmt.Errorf("failed to start router: %w", err) @@ -469,6 +502,12 @@ func (s *Server) PublicURL() string { return "http://" + s.publicAddr } +// IngressURL is the public base URL under which the embedded router serves +// external HTTP ingress on the single port. +func (s *Server) IngressURL() string { + return s.PublicURL() + "/ingress" +} + // AdminToken returns the resolved admin API token. func (s *Server) AdminToken() string { return s.adminToken } @@ -549,12 +588,21 @@ func (s *Server) seedPolicyOnFirstBoot(ctx context.Context) error { } // defaultDevPolicyRoles mirrors the Helm bootstrap job's role set with the -// node role opened up for zero-config development use. +// node role opened up for zero-config development use while keeping external +// HTTP ingress restricted to zero-trust INGRESS_AUTH_MODE_EDGE unless an +// operator explicitly grants INGRESS_AUTH_MODE_PASSTHROUGH via --policy-file +// or POST /policies. func defaultDevPolicyRoles() []*api.PolicyRole { + devIngress := []*api.IngressGrant{{ + Services: []string{"*"}, + AllowedAuthModes: []api.IngressAuthMode{ + api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + }, + }} return []*api.PolicyRole{ - {Name: "mesh-admin", AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, + {Name: "mesh-admin", AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}, AllowedIngress: devIngress}, {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, - {Name: api.RoleNode, AllowedServices: []string{"*"}, AllowedPublish: []string{"*"}, AllowedTargets: []string{"*"}, AllowedLabels: []string{"*"}}, + {Name: api.RoleNode, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}, AllowedPublish: []string{"*"}, AllowedLabels: []string{"*"}, AllowedIngress: devIngress}, } } diff --git a/internal/standalone/standalone_test.go b/internal/standalone/standalone_test.go index 028563e7..c2a48082 100644 --- a/internal/standalone/standalone_test.go +++ b/internal/standalone/standalone_test.go @@ -15,15 +15,23 @@ package standalone import ( + "context" + "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "slices" "testing" + "time" + "github.com/google/agentmesh/api" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" dto "github.com/prometheus/client_model/go" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) func TestWsListenMultiaddr(t *testing.T) { @@ -198,3 +206,146 @@ func TestWrapPublicHTTPHandlerCountsProxiedRequestsWithoutExternalURL(t *testing t.Errorf("next handler served %d requests, want 4", served) } } + +func roleGrantsIngressMode(r *api.PolicyRole, mode api.IngressAuthMode) bool { + for _, g := range r.GetAllowedIngress() { + modes := g.GetAllowedAuthModes() + if len(modes) == 0 { + if mode == api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE { + return true + } + continue + } + if slices.Contains(modes, mode) { + return true + } + } + return false +} + +func TestDefaultDevPolicyRolesSafeIngressCeiling(t *testing.T) { + roles := defaultDevPolicyRoles() + var nodeRole *api.PolicyRole + for _, r := range roles { + if r.GetName() == api.RoleNode { + nodeRole = r + break + } + } + if nodeRole == nil { + t.Fatalf("defaultDevPolicyRoles missing %s", api.RoleNode) + } + if !roleGrantsIngressMode(nodeRole, api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE) { + t.Errorf("default %s role denied INGRESS_AUTH_MODE_EDGE, want allowed", api.RoleNode) + } + if roleGrantsIngressMode(nodeRole, api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH) { + t.Errorf("default %s role allowed INGRESS_AUTH_MODE_PASSTHROUGH, want denied by safe default", api.RoleNode) + } +} + +func TestStandaloneSinglePortIngressAndSeededPolicy(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + srv, err := New(Options{ + BindAddress: "127.0.0.1:0", + DataDir: t.TempDir(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := srv.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + wantIngressURL := srv.PublicURL() + "/ingress" + if got := srv.IngressURL(); got != wantIngressURL { + t.Fatalf("srv.IngressURL() = %q, want %q", got, wantIngressURL) + } + + client := &http.Client{Timeout: 5 * time.Second} + + // Wait briefly for the embedded router's initial lease to be visible on /info. + var leasedIngressURL string + deadline := time.Now().Add(5 * time.Second) + for { + infoReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.PublicURL()+"/info", nil) + infoReq.Header.Set("Accept", "application/x-protobuf") + resp, err := client.Do(infoReq) + if err == nil && resp.StatusCode == http.StatusOK { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + var info api.ControlPlaneInfoResponse + if proto.Unmarshal(body, &info) == nil && len(info.GetRouters()) == 1 { + leasedIngressURL = info.GetRouters()[0].GetIngressUrl() + if leasedIngressURL != "" { + break + } + } + } else if resp != nil { + _ = resp.Body.Close() + } + if time.Now().After(deadline) { + t.Fatalf("router lease on /info never advertised ingress_url (got %q)", leasedIngressURL) + } + time.Sleep(25 * time.Millisecond) + } + if leasedIngressURL != wantIngressURL { + t.Fatalf("/info router ingress_url = %q, want %q", leasedIngressURL, wantIngressURL) + } + + // RFC 9728 metadata on /ingress/.well-known/oauth-protected-resource must + // advertise the public single-port URL, not the internal loopback CP port. + metaResp, err := client.Get(srv.IngressURL() + "/.well-known/oauth-protected-resource") + if err != nil { + t.Fatalf("GET oauth-protected-resource: %v", err) + } + defer func() { _ = metaResp.Body.Close() }() + if metaResp.StatusCode != http.StatusOK { + t.Fatalf("oauth-protected-resource status = %d, want 200", metaResp.StatusCode) + } + var meta api.OAuthProtectedResourceMetadata + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { + t.Fatalf("decode metadata: %v", err) + } + if meta.Resource != wantIngressURL { + t.Fatalf("meta.Resource = %q, want %q", meta.Resource, wantIngressURL) + } + if len(meta.AuthorizationServers) != 1 || meta.AuthorizationServers[0] != srv.PublicURL() { + t.Fatalf("meta.AuthorizationServers = %v, want [%s]", meta.AuthorizationServers, srv.PublicURL()) + } + + // Seeded dev policy includes allowed_ingress on mesh:role:node. + polReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.PublicURL()+"/admin/policy", nil) + polReq.Header.Set("Authorization", "Bearer "+srv.AdminToken()) + polResp, err := client.Do(polReq) + if err != nil { + t.Fatalf("GET /admin/policy: %v", err) + } + polBody, _ := io.ReadAll(polResp.Body) + _ = polResp.Body.Close() + if polResp.StatusCode != http.StatusOK { + t.Fatalf("/admin/policy status = %d: %s", polResp.StatusCode, string(polBody)) + } + var pol api.PolicyConfig + if err := protojson.Unmarshal(polBody, &pol); err != nil { + t.Fatalf("unmarshal PolicyConfig: %v", err) + } + var nodeRole *api.PolicyRole + for _, role := range pol.GetRoles() { + if role.GetName() == api.RoleNode { + nodeRole = role + break + } + } + if nodeRole == nil || len(nodeRole.GetAllowedIngress()) == 0 { + t.Fatalf("seeded PolicyConfig for %s lacks allowed_ingress: %+v", api.RoleNode, pol.GetRoles()) + } + if !roleGrantsIngressMode(nodeRole, api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE) { + t.Errorf("seeded %s role denied INGRESS_AUTH_MODE_EDGE, want allowed", api.RoleNode) + } + if roleGrantsIngressMode(nodeRole, api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH) { + t.Errorf("seeded %s role allowed INGRESS_AUTH_MODE_PASSTHROUGH, want denied without explicit --policy-file", api.RoleNode) + } +} diff --git a/internal/storage/migration_test.go b/internal/storage/migration_test.go index 51d2d907..ed933e46 100644 --- a/internal/storage/migration_test.go +++ b/internal/storage/migration_test.go @@ -275,3 +275,80 @@ func TestMigrationRenamesSamPrefixesToMesh(t *testing.T) { t.Errorf("egress ServedBy = %+v, want [mesh:role:node]", egress) } } + +func TestRouterIngressURLAndPolicyIngressRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "cp.db") + db := openAtSchemaVersion(t, path, 20) + now := time.Now() + if _, err := db.Exec(`INSERT INTO routers (peer_id, multiaddresses, last_lease_renewal, expires_at, connected_peers, dht_size, connections, connection_limit) + VALUES ('12D3KooWRouterOld', '["/ip4/127.0.0.1/tcp/4001"]', ?, ?, '[]', 1, 0, 100)`, now.UnixMilli(), now.Add(time.Hour).UnixMilli()); err != nil { + t.Fatalf("insert pre-v21 router: %v", err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + store, err := NewSQLStore("sqlite", path) + if err != nil { + t.Fatalf("NewSQLStore: %v", err) + } + defer func() { _ = store.Close() }() + ctx := context.Background() + + if err := store.UpsertRouterLease(ctx, &RouterLease{ + PeerID: "12D3KooWRouterNew", + Addresses: []string{"/ip4/127.0.0.1/tcp/4002"}, + LastRenewal: now, + ExpiresAt: now.Add(time.Hour), + IngressURL: "https://bananas.sam-mesh.dev/ingress", + }); err != nil { + t.Fatalf("UpsertRouterLease: %v", err) + } + + routers, err := store.GetActiveRouters(ctx) + if err != nil { + t.Fatalf("GetActiveRouters: %v", err) + } + if len(routers) != 2 { + t.Fatalf("len(routers) = %d, want 2", len(routers)) + } + var gotOld, gotNew string + for _, r := range routers { + switch r.PeerID { + case "12D3KooWRouterOld": + gotOld = r.IngressURL + case "12D3KooWRouterNew": + gotNew = r.IngressURL + } + } + if gotOld != "" { + t.Errorf("pre-v21 router IngressURL = %q, want empty", gotOld) + } + if gotNew != "https://bananas.sam-mesh.dev/ingress" { + t.Errorf("new router IngressURL = %q, want https://bananas.sam-mesh.dev/ingress", gotNew) + } + + wantGrant := &api.IngressGrant{ + Services: []string{"a2a://*"}, + AllowedAuthModes: []api.IngressAuthMode{api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH}, + } + if err := store.SaveMeshPolicy(ctx, []*api.PolicyRole{ + { + Name: "agent-host", + AllowedIngress: []*api.IngressGrant{wantGrant}, + }, + }, nil); err != nil { + t.Fatalf("SaveMeshPolicy: %v", err) + } + roles, _, err := store.GetMeshPolicy(ctx) + if err != nil { + t.Fatalf("GetMeshPolicy: %v", err) + } + if len(roles) != 1 || len(roles[0].AllowedIngress) != 1 { + t.Fatalf("roles = %+v, want 1 role with 1 ingress grant", roles) + } + gotGrant := roles[0].AllowedIngress[0] + if !reflect.DeepEqual(gotGrant.Services, wantGrant.Services) || !reflect.DeepEqual(gotGrant.AllowedAuthModes, wantGrant.AllowedAuthModes) { + t.Errorf("got grant %+v, want %+v", gotGrant, wantGrant) + } +} diff --git a/internal/storage/round_trip_test.go b/internal/storage/round_trip_test.go index 25b2d812..d67cfca7 100644 --- a/internal/storage/round_trip_test.go +++ b/internal/storage/round_trip_test.go @@ -827,6 +827,7 @@ func TestRouterLeaseRoundTripsEveryField(t *testing.T) { DHTSize: 42, Connections: 1200, ConnectionLimit: 4000, + IngressURL: "https://bananas.sam-mesh.dev/ingress", } requireAllFieldsSet(t, want) diff --git a/internal/storage/sql_store.go b/internal/storage/sql_store.go index b702b894..57a93d94 100644 --- a/internal/storage/sql_store.go +++ b/internal/storage/sql_store.go @@ -581,6 +581,16 @@ var migrations = []migration{ `ALTER TABLE nodes ADD COLUMN ephemeral BOOLEAN DEFAULT FALSE NOT NULL`, }, }, + { + // Routers may advertise a public HTTP/HTTPS ingress base URL on /info. + version: 21, + postgres: []string{ + `ALTER TABLE routers ADD COLUMN IF NOT EXISTS ingress_url TEXT`, + }, + sqlite: []string{ + `ALTER TABLE routers ADD COLUMN ingress_url TEXT`, + }, + }, } var meshPrefixMigration = []string{ @@ -1304,23 +1314,23 @@ func (s *SQLStore) UpsertRouterLease(ctx context.Context, lease *RouterLease) er return err } - const columns = `peer_id, multiaddresses, last_lease_renewal, expires_at, connected_peers, dht_size, connections, connection_limit` + const columns = `peer_id, multiaddresses, last_lease_renewal, expires_at, connected_peers, dht_size, connections, connection_limit, ingress_url` var query string if s.isPostgres() { query = s.rebind(` INSERT INTO routers (` + columns + `) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (peer_id) - DO UPDATE SET multiaddresses = EXCLUDED.multiaddresses, last_lease_renewal = EXCLUDED.last_lease_renewal, expires_at = EXCLUDED.expires_at, connected_peers = EXCLUDED.connected_peers, dht_size = EXCLUDED.dht_size, connections = EXCLUDED.connections, connection_limit = EXCLUDED.connection_limit`) + DO UPDATE SET multiaddresses = EXCLUDED.multiaddresses, last_lease_renewal = EXCLUDED.last_lease_renewal, expires_at = EXCLUDED.expires_at, connected_peers = EXCLUDED.connected_peers, dht_size = EXCLUDED.dht_size, connections = EXCLUDED.connections, connection_limit = EXCLUDED.connection_limit, ingress_url = EXCLUDED.ingress_url`) } else { query = s.rebind(` INSERT INTO routers (` + columns + `) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (peer_id) - DO UPDATE SET multiaddresses = excluded.multiaddresses, last_lease_renewal = excluded.last_lease_renewal, expires_at = excluded.expires_at, connected_peers = excluded.connected_peers, dht_size = excluded.dht_size, connections = excluded.connections, connection_limit = excluded.connection_limit`) + DO UPDATE SET multiaddresses = excluded.multiaddresses, last_lease_renewal = excluded.last_lease_renewal, expires_at = excluded.expires_at, connected_peers = excluded.connected_peers, dht_size = excluded.dht_size, connections = excluded.connections, connection_limit = excluded.connection_limit, ingress_url = excluded.ingress_url`) } - _, err = s.db.ExecContext(ctx, query, lease.PeerID, string(addrsBytes), lease.LastRenewal.UnixMilli(), lease.ExpiresAt.UnixMilli(), string(peersBytes), lease.DHTSize, lease.Connections, lease.ConnectionLimit) + _, err = s.db.ExecContext(ctx, query, lease.PeerID, string(addrsBytes), lease.LastRenewal.UnixMilli(), lease.ExpiresAt.UnixMilli(), string(peersBytes), lease.DHTSize, lease.Connections, lease.ConnectionLimit, lease.IngressURL) return err } @@ -1328,7 +1338,7 @@ func (s *SQLStore) UpsertRouterLease(ctx context.Context, lease *RouterLease) er // reads the same to every replica and caller; the control plane shuffles the // list it hands members (routerAddresses) so they spread across the routers. func (s *SQLStore) GetActiveRouters(ctx context.Context) ([]RouterLease, error) { - query := s.rebind(`SELECT peer_id, multiaddresses, last_lease_renewal, expires_at, connected_peers, dht_size, connections, connection_limit FROM routers WHERE expires_at > ? ORDER BY peer_id`) + query := s.rebind(`SELECT peer_id, multiaddresses, last_lease_renewal, expires_at, connected_peers, dht_size, connections, connection_limit, ingress_url FROM routers WHERE expires_at > ? ORDER BY peer_id`) rows, err := s.db.QueryContext(ctx, query, time.Now().UnixMilli()) if err != nil { return nil, err @@ -1339,10 +1349,10 @@ func (s *SQLStore) GetActiveRouters(ctx context.Context) ([]RouterLease, error) for rows.Next() { var l RouterLease var addrsStr string - var peersStr sql.NullString + var peersStr, ingressURL sql.NullString var dhtSize, connections, connectionLimit sql.NullInt64 var lastRenewalUnix, expiresAtUnix int64 - if err := rows.Scan(&l.PeerID, &addrsStr, &lastRenewalUnix, &expiresAtUnix, &peersStr, &dhtSize, &connections, &connectionLimit); err != nil { + if err := rows.Scan(&l.PeerID, &addrsStr, &lastRenewalUnix, &expiresAtUnix, &peersStr, &dhtSize, &connections, &connectionLimit, &ingressURL); err != nil { return nil, err } if err := json.Unmarshal([]byte(addrsStr), &l.Addresses); err != nil { @@ -1362,6 +1372,9 @@ func (s *SQLStore) GetActiveRouters(ctx context.Context) ([]RouterLease, error) if connectionLimit.Valid { l.ConnectionLimit = int(connectionLimit.Int64) } + if ingressURL.Valid { + l.IngressURL = ingressURL.String + } l.LastRenewal = time.UnixMilli(lastRenewalUnix) l.ExpiresAt = time.UnixMilli(expiresAtUnix) leases = append(leases, l) @@ -1458,6 +1471,18 @@ func (s *SQLStore) saveMeshPolicyTx(ctx context.Context, tx *sql.Tx, roles []*ap return err } } + for _, g := range r.AllowedIngress { + if g == nil { + continue + } + encoded, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(g) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'ingress', ?)"), r.Name, string(encoded)); err != nil { + return err + } + } } for _, b := range bindings { @@ -1529,6 +1554,12 @@ func (s *SQLStore) GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api return nil, nil, fmt.Errorf("role %s: stored http grant does not parse: %w", roleName, err) } r.Http = append(r.Http, g) + case "ingress": + g := &api.IngressGrant{} + if err := protojson.Unmarshal([]byte(resValue), g); err != nil { + return nil, nil, fmt.Errorf("role %s: stored ingress grant does not parse: %w", roleName, err) + } + r.AllowedIngress = append(r.AllowedIngress, g) } } } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 6b59b8e5..45171fde 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -69,6 +69,7 @@ type RouterLease struct { // number it accepts. Listed on /info for members to choose routers by. Connections int ConnectionLimit int + IngressURL string } // User represents a human identity in the mesh. diff --git a/sdk/js/src/gen/agentmesh_pb.ts b/sdk/js/src/gen/agentmesh_pb.ts index cec41542..5cda8a51 100644 --- a/sdk/js/src/gen/agentmesh_pb.ts +++ b/sdk/js/src/gen/agentmesh_pb.ts @@ -26,7 +26,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file agentmesh.proto. */ export const file_agentmesh: GenFile = /*@__PURE__*/ - fileDesc("Cg9hZ2VudG1lc2gucHJvdG8SDGFnZW50bWVzaC52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMIrEBCgxSb3V0ZXJHb0F3YXkSMQoGcmVhc29uGAEgASgOMiEuYWdlbnRtZXNoLnYxLlJvdXRlckdvQXdheS5SZWFzb24SLgoLcmV0cnlfYWZ0ZXIYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iPgoGUmVhc29uEhYKElJFQVNPTl9VTlNQRUNJRklFRBAAEgwKCERSQUlOSU5HEAESDgoKT1ZFUkxPQURFRBACItwBCglNZXNoRXZlbnQSKgoEdHlwZRgBIAEoDjIcLmFnZW50bWVzaC52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiL5AQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjcKBmxhYmVscxgFIAMoCzInLmFnZW50bWVzaC52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKXAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEkAKBmxhYmVscxgFIAMoCzIwLmFnZW50bWVzaC52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USLgoGc3RhdHVzGAEgASgOMh4uYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLU2VydmljZUluZm8SJwoEdHlwZRgBIAEoDjIZLmFnZW50bWVzaC52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJIoEBCg5Db21tYW5kQmFja2VuZBIPCgdjb21tYW5kGAEgAygJEjIKA2VudhgCIAMoCzIlLmFnZW50bWVzaC52MS5Db21tYW5kQmFja2VuZC5FbnZFbnRyeRoqCghFbnZFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIpYBChZSZWdpc3RlclNlcnZpY2VSZXF1ZXN0EioKB3NlcnZpY2UYASABKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SFAoKdGFyZ2V0X3VybBgCIAEoCUgAEi8KB2NvbW1hbmQYAyABKAsyHC5hZ2VudG1lc2gudjEuQ29tbWFuZEJhY2tlbmRIAEIJCgdiYWNrZW5kImkKEkRpc2NvdmVyZWRQcm92aWRlchIPCgdwZWVyX2lkGAEgASgJEhcKD2xvY2FsX3Byb3h5X3VybBgCIAEoCRIQCghzcnZfbmFtZRgDIAEoCRIXCg9zcnZfZGVzY3JpcHRpb24YBCABKAki6AEKGENvbnRyb2xQbGFuZUluZm9SZXNwb25zZRITCgtvaWRjX2lzc3VlchgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSEAoIYXVkaWVuY2UYAyABKAkSGAoQcm91dGVyX2FkZHJlc3NlcxgEIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYBSADKAkSKQoHcm91dGVycxgGIAMoCzIYLmFnZW50bWVzaC52MS5Sb3V0ZXJJbmZvEjQKCWRpcmVjdG9yeRgHIAEoCzIhLmFnZW50bWVzaC52MS5EaXJlY3RvcnlQYXJhbWV0ZXJzIpABChNEaXJlY3RvcnlQYXJhbWV0ZXJzEhMKC3JlcGxpY2F0aW9uGAEgASgFEjUKEnJlcHJvdmlkZV9pbnRlcnZhbBgCIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhItCgpyZWNvcmRfdHRsGAMgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIvsBChBEaXJlY3RvcnlSZXF1ZXN0EjEKB3Byb3ZpZGUYASABKAsyHi5hZ2VudG1lc2gudjEuRGlyZWN0b3J5UHJvdmlkZUgAEj4KDmZpbmRfcHJvdmlkZXJzGAIgASgLMiQuYWdlbnRtZXNoLnYxLkRpcmVjdG9yeUZpbmRQcm92aWRlcnNIABI0CglmaW5kX3BlZXIYAyABKAsyHy5hZ2VudG1lc2gudjEuRGlyZWN0b3J5RmluZFBlZXJIABIzCgh3aXRoZHJhdxgEIAEoCzIfLmFnZW50bWVzaC52MS5EaXJlY3RvcnlXaXRoZHJhd0gAQgkKB3JlcXVlc3QiUQoQRGlyZWN0b3J5UHJvdmlkZRILCgNrZXkYASABKAwSDQoFYWRkcnMYAiADKAkSEAoIcHJvdmlkZXIYAyABKAkSDwoHc2VydmljZRgEIAEoCSIyChFEaXJlY3RvcnlXaXRoZHJhdxILCgNrZXkYASABKAwSEAoIcHJvdmlkZXIYAiABKAkiNAoWRGlyZWN0b3J5RmluZFByb3ZpZGVycxILCgNrZXkYASABKAwSDQoFbGltaXQYAiABKA0iJAoRRGlyZWN0b3J5RmluZFBlZXISDwoHcGVlcl9pZBgBIAEoCSJ3ChFEaXJlY3RvcnlSZXNwb25zZRIrCglwcm92aWRlcnMYASADKAsyGC5hZ2VudG1lc2gudjEuUGVlclJlY29yZBImCgRwZWVyGAIgASgLMhguYWdlbnRtZXNoLnYxLlBlZXJSZWNvcmQSDQoFZXJyb3IYAyABKAkiLAoKUGVlclJlY29yZBIPCgdwZWVyX2lkGAEgASgJEg0KBWFkZHJzGAIgAygJIsQBCgpSb3V0ZXJJbmZvEg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEjQKBmxhYmVscxgDIAMoCzIkLmFnZW50bWVzaC52MS5Sb3V0ZXJJbmZvLkxhYmVsc0VudHJ5EhMKC2Nvbm5lY3Rpb25zGAQgASgFEhgKEGNvbm5lY3Rpb25fbGltaXQYBSABKAUaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwSJgoDdHRsGAggASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEhMKC2Nvbm5lY3Rpb25zGAkgASgFEhgKEGNvbm5lY3Rpb25fbGltaXQYCiABKAUiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK9AQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAUgAygJEiUKBGh0dHAYBiADKAsyFy5hZ2VudG1lc2gudjEuSFRUUEdyYW50EhcKD2FsbG93ZWRfcHVibGlzaBgHIAMoCSI8CglIVFRQR3JhbnQSDwoHc2VydmljZRgBIAEoCRIPCgdtZXRob2RzGAIgAygJEg0KBXBhdGhzGAMgAygJIqECChFFZ3Jlc3NEZXN0aW5hdGlvbhIMCgRuYW1lGAEgASgJEhIKCnRhcmdldF91cmwYAiABKAkSEgoKY3JlZGVudGlhbBgDIAEoCRIRCglzZXJ2ZWRfYnkYBCADKAkSLgoGYnJva2VyGAUgASgLMh4uYWdlbnRtZXNoLnYxLkNyZWRlbnRpYWxCcm9rZXISLAoKaW5zcGVjdGlvbhgGIAEoCzIYLmFnZW50bWVzaC52MS5JbnNwZWN0aW9uEiYKBG1vZGUYByABKA4yGC5hZ2VudG1lc2gudjEuRWdyZXNzTW9kZRINCgVwb3J0cxgIIAMoDRIVCg1wcmVzZXJ2ZV9ob3N0GAkgASgIEhcKD2ZvcndhcmRfY29udGV4dBgKIAEoCCI5CgpJbnNwZWN0aW9uEisKCmluc3BlY3RvcnMYASADKAsyFy5hZ2VudG1lc2gudjEuSW5zcGVjdG9yIm8KCUluc3BlY3RvchIvCgttb2RlbF9hcm1vchgBIAEoCzIYLmFnZW50bWVzaC52MS5Nb2RlbEFybW9ySAASKQoIZXh0X3Byb2MYAiABKAsyFS5hZ2VudG1lc2gudjEuRXh0UHJvY0gAQgYKBGtpbmQikQEKCk1vZGVsQXJtb3ISEAoIdGVtcGxhdGUYASABKAkSMgoIcmVzcG9uc2UYAiABKA4yIC5hZ2VudG1lc2gudjEuUmVzcG9uc2VJbnNwZWN0aW9uEhEKCWZhaWxfb3BlbhgDIAEoCBIqCgd0aW1lb3V0GAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIogCCgdFeHRQcm9jEg4KBnRhcmdldBgBIAEoCRIKCgJjYRgCIAEoCRIaChJjbGllbnRfY2VydGlmaWNhdGUYAyABKAkSPAoPcHJvY2Vzc2luZ19tb2RlGAQgASgLMiMuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZRIbChNhbGxvd19tb2RlX292ZXJyaWRlGAUgASgIEjIKD21lc3NhZ2VfdGltZW91dBgGIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIaChJmYWlsdXJlX21vZGVfYWxsb3cYByABKAgSGgoSbWF4X2J1ZmZlcmVkX2J5dGVzGAggASgNIv8EChVFeHRQcm9jUHJvY2Vzc2luZ01vZGUSSwoTcmVxdWVzdF9oZWFkZXJfbW9kZRgBIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJMChRyZXNwb25zZV9oZWFkZXJfbW9kZRgCIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJHChFyZXF1ZXN0X2JvZHlfbW9kZRgDIAEoDjIsLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuQm9keU1vZGUSSAoScmVzcG9uc2VfYm9keV9tb2RlGAQgASgOMiwuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJMChRyZXF1ZXN0X3RyYWlsZXJfbW9kZRgFIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJNChVyZXNwb25zZV90cmFpbGVyX21vZGUYBiABKA4yLi5hZ2VudG1lc2gudjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkhlYWRlck1vZGUiOQoKSGVhZGVyTW9kZRIXChNIRUFERVJfTU9ERV9ERUZBVUxUEAASCAoEU0VORBABEggKBFNLSVAQAiJgCghCb2R5TW9kZRIICgROT05FEAASDAoIU1RSRUFNRUQQARIMCghCVUZGRVJFRBACEhQKEEJVRkZFUkVEX1BBUlRJQUwQAxIYChRGVUxMX0RVUExFWF9TVFJFQU1FRBAEIuEBChBDcmVkZW50aWFsQnJva2VyEhcKDXN0YXRpY19zZWNyZXQYASABKAlIABI3Cg9vaWRjX2ZlZGVyYXRpb24YAiABKAsyHC5hZ2VudG1lc2gudjEuT0lEQ0ZlZGVyYXRpb25IABI2Cg9hd3NfYXNzdW1lX3JvbGUYAyABKAsyGy5hZ2VudG1lc2gudjEuQVdTQXNzdW1lUm9sZUgAEjsKEXBsYXRmb3JtX2lkZW50aXR5GAQgASgLMh4uYWdlbnRtZXNoLnYxLlBsYXRmb3JtSWRlbnRpdHlIAEIGCgRraW5kIl8KDk9JRENGZWRlcmF0aW9uEhYKDnRva2VuX2VuZHBvaW50GAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEhMKC2ltcGVyc29uYXRlGAMgASgJEg4KBnNjb3BlcxgEIAMoCSI5Cg1BV1NBc3N1bWVSb2xlEhAKCHJvbGVfYXJuGAEgASgJEhYKDnNlc3Npb25fcG9saWN5GAIgASgJIiIKEFBsYXRmb3JtSWRlbnRpdHkSDgoGc2NvcGVzGAEgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIpcBCgxQb2xpY3lDb25maWcSJwoFcm9sZXMYASADKAsyGC5hZ2VudG1lc2gudjEuUG9saWN5Um9sZRItCghiaW5kaW5ncxgCIAMoCzIbLmFnZW50bWVzaC52MS5Qb2xpY3lCaW5kaW5nEi8KBmVncmVzcxgDIAMoCzIfLmFnZW50bWVzaC52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0IjAKF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYASADKAkiPAoaUG9saWN5Q29uZmlnVXBkYXRlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSIaChhFZ3Jlc3NBc3NpZ25tZW50c1JlcXVlc3QiTAoZRWdyZXNzQXNzaWdubWVudHNSZXNwb25zZRIvCgZlZ3Jlc3MYASADKAsyHy5hZ2VudG1lc2gudjEuRWdyZXNzRGVzdGluYXRpb24iZgoMS2V5c1Jlc3BvbnNlEhMKC3B1YmxpY19rZXlzGAEgAygMEi0KCXNpZ25fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKc2lnbmF0dXJlcxgDIAMoDCJrChNUb2tlblJlZnJlc2hSZXF1ZXN0EhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYASABKAwSGQoRY2hhbGxlbmdlX3VuaXhfbXMYAiABKAMSDwoHcGVlcl9pZBgDIAEoCRILCgNqd3QYBCABKAkidQoUVG9rZW5SZWZyZXNoUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNZXJyb3JfbWVzc2FnZRgDIAEoCSJAChFOb2RlQ2F0YWxvZ1JlcG9ydBIrCghzZXJ2aWNlcxgBIAMoCzIZLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5mbyIlChJUb2tlblJldm9rZVJlcXVlc3QSDwoHcGVlcl9pZBgBIAEoCSI1ChNUb2tlblJldm9rZVJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDQoFZXJyb3IYAiABKAkiqQEKG0Jvb3RzdHJhcFRva2VuQ3JlYXRlUmVxdWVzdBIMCgRyb2xlGAEgASgJEhAKCG93bmVyX2lkGAIgASgJEhEKCXR0bF9ob3VycxgDIAEoBRISCgptYXhfdXNhZ2VzGAQgASgFEhMKC2Rlc2NyaXB0aW9uGAUgASgJEhsKE2F1dG9ub21vdXNfcmVjb3ZlcnkYBiABKAgSEQoJZXBoZW1lcmFsGAcgASgIIooBChxCb290c3RyYXBUb2tlbkNyZWF0ZVJlc3BvbnNlEgoKAmlkGAEgASgJEg0KBXRva2VuGAIgASgJEgwKBHJvbGUYAyABKAkSEAoIb3duZXJfaWQYBCABKAkSLwoLZXhwaXJlX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIr4CCg5Cb290c3RyYXBUb2tlbhIKCgJpZBgBIAEoCRIMCgRyb2xlGAIgASgJEhAKCG93bmVyX2lkGAMgASgJEhIKCm1heF91c2FnZXMYBCABKAUSFAoMdXNhZ2VzX2NvdW50GAUgASgFEhMKC2Rlc2NyaXB0aW9uGAYgASgJEi8KC2NyZWF0ZV90aW1lGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtleHBpcmVfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLcmV2b2tlX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKE2F1dG9ub21vdXNfcmVjb3ZlcnkYCiABKAgSEQoJZXBoZW1lcmFsGAsgASgIIkoKGkJvb3RzdHJhcFRva2VuTGlzdFJlc3BvbnNlEiwKBnRva2VucxgBIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbiLWAgoRRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSDwoHcGVlcl9pZBgCIAEoCRIQCgh0b2tlbl9pZBgDIAEoCRIuCgZzdGF0dXMYBCABKA4yHi5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFN0YXR1cxI7CgZsYWJlbHMYBSADKAsyKy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QuTGFiZWxzRW50cnkSLwoLY3JlYXRlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHJlc29sdmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLcmVzb2x2ZWRfYnkYCCABKAkaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJSCh1FbnJvbGxtZW50UmVxdWVzdExpc3RSZXNwb25zZRIxCghyZXF1ZXN0cxgBIAMoCzIfLmFnZW50bWVzaC52MS5FbnJvbGxtZW50UmVxdWVzdCJwCgRVc2VyEgoKAmlkGAEgASgJEg4KBmlzc3VlchgCIAEoCRINCgVlbWFpbBgDIAEoCRIMCgRyb2xlGAQgASgJEi8KC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCL2AgoMRW5yb2xsZWROb2RlEg8KB3BlZXJfaWQYASABKAkSDAoEcm9sZRgCIAEoCRIXCg9lbnJvbGxtZW50X3R5cGUYAyABKAkSEwoLY2xhaW1zX2pzb24YBCABKAkSEAoIb3duZXJfaWQYBSABKAkSNgoGbGFiZWxzGAYgAygLMiYuYWdlbnRtZXNoLnYxLkVucm9sbGVkTm9kZS5MYWJlbHNFbnRyeRIvCgtlbnJvbGxfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg4KBmJhbm5lZBgJIAEoCBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIEhEKCWVwaGVtZXJhbBgLIAEoCBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIsQBCgtSb3V0ZXJMZWFzZRIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRI1ChFsYXN0X3JlbmV3YWxfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhcKD2Nvbm5lY3RlZF9wZWVycxgFIAMoCRIQCghkaHRfc2l6ZRgGIAEoBSJsCgxOb2RlU2VydmljZXMSKwoIc2VydmljZXMYASADKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SLwoLcmVwb3J0X3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wItsDChNBZG1pblN0YXR1c1Jlc3BvbnNlEiEKBXVzZXJzGAEgAygLMhIuYWdlbnRtZXNoLnYxLlVzZXISMQoOYWN0aXZlX3JvdXRlcnMYAiADKAsyGS5hZ2VudG1lc2gudjEuUm91dGVyTGVhc2USMgoOZW5yb2xsZWRfbm9kZXMYAyADKAsyGi5hZ2VudG1lc2gudjEuRW5yb2xsZWROb2RlEjwKE2Vucm9sbG1lbnRfcmVxdWVzdHMYBCADKAsyHy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QSNgoQYm9vdHN0cmFwX3Rva2VucxgFIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIqCgZwb2xpY3kYBiABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnEkgKDG5vZGVfY2F0YWxvZxgHIAMoCzIyLmFnZW50bWVzaC52MS5BZG1pblN0YXR1c1Jlc3BvbnNlLk5vZGVDYXRhbG9nRW50cnkaTgoQTm9kZUNhdGFsb2dFbnRyeRILCgNrZXkYASABKAkSKQoFdmFsdWUYAiABKAsyGi5hZ2VudG1lc2gudjEuTm9kZVNlcnZpY2VzOgI4ASKBAgoSVXNlclN0YXR1c1Jlc3BvbnNlEiAKBHVzZXIYASABKAsyEi5hZ2VudG1lc2gudjEuVXNlchIyCg5lbnJvbGxlZF9ub2RlcxgCIAMoCzIaLmFnZW50bWVzaC52MS5FbnJvbGxlZE5vZGUSNgoQYm9vdHN0cmFwX3Rva2VucxgDIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIxCg5hY3RpdmVfcm91dGVycxgEIAMoCzIZLmFnZW50bWVzaC52MS5Sb3V0ZXJMZWFzZRIqCgZwb2xpY3kYBSABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnIlYKEE5vZGVMaXN0UmVzcG9uc2USKQoFbm9kZXMYASADKAsyGi5hZ2VudG1lc2gudjEuRW5yb2xsZWROb2RlEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLkAQoYSWRlbnRpdHlFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBI3ChNiaXNjdWl0X2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIZChFjb250cm9sX3BsYW5lX3VybBgEIAEoCRIiChp0cnVzdGVkX2NvbnRyb2xfcGxhbmVfa2V5cxgFIAMoDBIuCgpjaGVja190aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLGAgoUUGVlckV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEhUKDXZlcmlmeWluZ19rZXkYAyABKAwSDQoFcm9sZXMYBCADKAkSPgoGbGFiZWxzGAUgAygLMi4uYWdlbnRtZXNoLnYxLlBlZXJFdmlkZW5jZVJlc3BvbnNlLkxhYmVsc0VudHJ5Ei8KC2V4cGlyZV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIWCg5yZXZvY2F0aW9uX2lkcxgHIAMoCRIuCgpjaGVja190aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIowCChBNZW1iZXJDcmVkZW50aWFsEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSLwoLZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjUKDHRydXN0ZWRfa2V5cxgEIAMoCzIfLmFnZW50bWVzaC52MS5UcnVzdGVkU2lnbmluZ0tleRIZChFpc3N1ZWRfdW5kZXJfa2V5cxgFIAMoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAYgAygJEi8KDG9pZGNfc2Vzc2lvbhgHIAEoCzIZLmFnZW50bWVzaC52MS5PSURDU2Vzc2lvbiJZChFUcnVzdGVkU2lnbmluZ0tleRISCgpwdWJsaWNfa2V5GAEgASgMEjAKDHJlY2VpdmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLT0lEQ1Nlc3Npb24SDgoGaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJIpMBChVUYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEbmFtZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSJQoFcnVsZXMYAyADKAsyFi5hZ2VudG1lc2gudjEuVGFza1J1bGUSLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoQBCghUYXNrUnVsZRITCgtkZXNjcmlwdGlvbhgBIAEoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAIgAygJEi4KCW9wZXJhdGlvbhgDIAEoCzIbLmFnZW50bWVzaC52MS5UYXNrT3BlcmF0aW9uEhkKEWFsbG93ZWRfcmVzb3VyY2VzGAQgAygJInMKDVRhc2tPcGVyYXRpb24SFQoNYWxsb3dlZF90b29scxgBIAMoCRIXCg9hbGxvd2VkX21ldGhvZHMYAiADKAkSFQoNYWxsb3dlZF9wYXRocxgDIAMoCRIbChNhbGxvd2VkX3Blcm1pc3Npb25zGAQgAygJIqsBChRUb2tlbkV4Y2hhbmdlUmVxdWVzdBIVCg1zdWJqZWN0X3Rva2VuGAEgASgJEjYKCXRhc2tfcnVsZRgCIAEoCzIjLmFnZW50bWVzaC52MS5UYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEc2VhbBgDIAEoCBIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIn8KFVRva2VuRXhjaGFuZ2VSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgVyb2xlcxgDIAMoCRIPCgdzdWJqZWN0GAQgASgJIoEBCg9TVFNUb2tlblJlcXVlc3QSDwoHYmlzY3VpdBgBIAEoDBITCgtkZXN0aW5hdGlvbhgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIoMBChBTVFNUb2tlblJlc3BvbnNlEgsKA2p3dBgBIAEoCRIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHc3ViamVjdBgDIAEoCRINCgVyb2xlcxgEIAMoCRIRCgl0YXNrX25hbWUYBSABKAkiRgoTUmV2b2NhdGlvbnNSZXNwb25zZRIWCg5yZXZvY2F0aW9uX2lkcxgBIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYAiADKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKowBCgtTZXJ2aWNlVHlwZRIcChhTRVJWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABIUChBTRVJWSUNFX1RZUEVfTUNQEAESGgoWU0VSVklDRV9UWVBFX0lORkVSRU5DRRACEhQKEFNFUlZJQ0VfVFlQRV9BMkEQAxIXChNTRVJWSUNFX1RZUEVfRUdSRVNTEAQqNwoKRWdyZXNzTW9kZRIUChBFR1JFU1NfTU9ERV9IVFRQEAASEwoPRUdSRVNTX01PREVfVENQEAEqXAoSUmVzcG9uc2VJbnNwZWN0aW9uEiAKHFJFU1BPTlNFX0lOU1BFQ1RJT05fQlVGRkVSRUQQABIkCiBSRVNQT05TRV9JTlNQRUNUSU9OX1JFUVVFU1RfT05MWRABQiFaH2dpdGh1Yi5jb20vZ29vZ2xlL2FnZW50bWVzaC9hcGliBnByb3RvMw", [file_google_protobuf_duration, file_google_protobuf_timestamp]); + fileDesc("Cg9hZ2VudG1lc2gucHJvdG8SDGFnZW50bWVzaC52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMIrEBCgxSb3V0ZXJHb0F3YXkSMQoGcmVhc29uGAEgASgOMiEuYWdlbnRtZXNoLnYxLlJvdXRlckdvQXdheS5SZWFzb24SLgoLcmV0cnlfYWZ0ZXIYAiABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24iPgoGUmVhc29uEhYKElJFQVNPTl9VTlNQRUNJRklFRBAAEgwKCERSQUlOSU5HEAESDgoKT1ZFUkxPQURFRBACItwBCglNZXNoRXZlbnQSKgoEdHlwZRgBIAEoDjIcLmFnZW50bWVzaC52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiL5AQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjcKBmxhYmVscxgFIAMoCzInLmFnZW50bWVzaC52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKXAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEkAKBmxhYmVscxgFIAMoCzIwLmFnZW50bWVzaC52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKDAgoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USLgoGc3RhdHVzGAEgASgOMh4uYWdlbnRtZXNoLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUQoOU2VydmljZUluZ3Jlc3MSMAoJYXV0aF9tb2RlGAEgASgOMh0uYWdlbnRtZXNoLnYxLkluZ3Jlc3NBdXRoTW9kZRINCgVhbGxvdxgCIAMoCSKIAQoLU2VydmljZUluZm8SJwoEdHlwZRgBIAEoDjIZLmFnZW50bWVzaC52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEi0KB2luZ3Jlc3MYBCABKAsyHC5hZ2VudG1lc2gudjEuU2VydmljZUluZ3Jlc3MigQEKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSMgoDZW52GAIgAygLMiUuYWdlbnRtZXNoLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEilgEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSKgoHc2VydmljZRgBIAEoCzIZLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASLwoHY29tbWFuZBgDIAEoCzIcLmFnZW50bWVzaC52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSLoAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCRIpCgdyb3V0ZXJzGAYgAygLMhguYWdlbnRtZXNoLnYxLlJvdXRlckluZm8SNAoJZGlyZWN0b3J5GAcgASgLMiEuYWdlbnRtZXNoLnYxLkRpcmVjdG9yeVBhcmFtZXRlcnMikAEKE0RpcmVjdG9yeVBhcmFtZXRlcnMSEwoLcmVwbGljYXRpb24YASABKAUSNQoScmVwcm92aWRlX2ludGVydmFsGAIgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEi0KCnJlY29yZF90dGwYAyABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb24i+wEKEERpcmVjdG9yeVJlcXVlc3QSMQoHcHJvdmlkZRgBIAEoCzIeLmFnZW50bWVzaC52MS5EaXJlY3RvcnlQcm92aWRlSAASPgoOZmluZF9wcm92aWRlcnMYAiABKAsyJC5hZ2VudG1lc2gudjEuRGlyZWN0b3J5RmluZFByb3ZpZGVyc0gAEjQKCWZpbmRfcGVlchgDIAEoCzIfLmFnZW50bWVzaC52MS5EaXJlY3RvcnlGaW5kUGVlckgAEjMKCHdpdGhkcmF3GAQgASgLMh8uYWdlbnRtZXNoLnYxLkRpcmVjdG9yeVdpdGhkcmF3SABCCQoHcmVxdWVzdCKAAQoQRGlyZWN0b3J5UHJvdmlkZRILCgNrZXkYASABKAwSDQoFYWRkcnMYAiADKAkSEAoIcHJvdmlkZXIYAyABKAkSDwoHc2VydmljZRgEIAEoCRItCgdpbmdyZXNzGAUgASgLMhwuYWdlbnRtZXNoLnYxLlNlcnZpY2VJbmdyZXNzIjIKEURpcmVjdG9yeVdpdGhkcmF3EgsKA2tleRgBIAEoDBIQCghwcm92aWRlchgCIAEoCSI0ChZEaXJlY3RvcnlGaW5kUHJvdmlkZXJzEgsKA2tleRgBIAEoDBINCgVsaW1pdBgCIAEoDSIkChFEaXJlY3RvcnlGaW5kUGVlchIPCgdwZWVyX2lkGAEgASgJIncKEURpcmVjdG9yeVJlc3BvbnNlEisKCXByb3ZpZGVycxgBIAMoCzIYLmFnZW50bWVzaC52MS5QZWVyUmVjb3JkEiYKBHBlZXIYAiABKAsyGC5hZ2VudG1lc2gudjEuUGVlclJlY29yZBINCgVlcnJvchgDIAEoCSJbCgpQZWVyUmVjb3JkEg8KB3BlZXJfaWQYASABKAkSDQoFYWRkcnMYAiADKAkSLQoHaW5ncmVzcxgDIAEoCzIcLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5ncmVzcyLZAQoKUm91dGVySW5mbxIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRI0CgZsYWJlbHMYAyADKAsyJC5hZ2VudG1lc2gudjEuUm91dGVySW5mby5MYWJlbHNFbnRyeRITCgtjb25uZWN0aW9ucxgEIAEoBRIYChBjb25uZWN0aW9uX2xpbWl0GAUgASgFEhMKC2luZ3Jlc3NfdXJsGAYgASgJGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEimAIKElJvdXRlckxlYXNlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRIPCgdiaXNjdWl0GAMgASgMEhcKD2Nvbm5lY3RlZF9wZWVycxgEIAMoCRIQCghkaHRfc2l6ZRgFIAEoBRIZChFjaGFsbGVuZ2VfdW5peF9tcxgGIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAcgASgMEiYKA3R0bBgIIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhITCgtjb25uZWN0aW9ucxgJIAEoBRIYChBjb25uZWN0aW9uX2xpbWl0GAogASgFEhMKC2luZ3Jlc3NfdXJsGAsgASgJImYKE1JvdXRlckxlYXNlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi8gEKClBvbGljeVJvbGUSDAoEbmFtZRgBIAEoCRIXCg9hbGxvd2VkX3RhcmdldHMYAiADKAkSGAoQYWxsb3dlZF9zZXJ2aWNlcxgDIAMoCRIWCg5jdXN0b21fZGF0YWxvZxgEIAMoCRIWCg5hbGxvd2VkX2xhYmVscxgFIAMoCRIlCgRodHRwGAYgAygLMhcuYWdlbnRtZXNoLnYxLkhUVFBHcmFudBIXCg9hbGxvd2VkX3B1Ymxpc2gYByADKAkSMwoPYWxsb3dlZF9pbmdyZXNzGAggAygLMhouYWdlbnRtZXNoLnYxLkluZ3Jlc3NHcmFudCJbCgxJbmdyZXNzR3JhbnQSEAoIc2VydmljZXMYASADKAkSOQoSYWxsb3dlZF9hdXRoX21vZGVzGAIgAygOMh0uYWdlbnRtZXNoLnYxLkluZ3Jlc3NBdXRoTW9kZSI8CglIVFRQR3JhbnQSDwoHc2VydmljZRgBIAEoCRIPCgdtZXRob2RzGAIgAygJEg0KBXBhdGhzGAMgAygJIqECChFFZ3Jlc3NEZXN0aW5hdGlvbhIMCgRuYW1lGAEgASgJEhIKCnRhcmdldF91cmwYAiABKAkSEgoKY3JlZGVudGlhbBgDIAEoCRIRCglzZXJ2ZWRfYnkYBCADKAkSLgoGYnJva2VyGAUgASgLMh4uYWdlbnRtZXNoLnYxLkNyZWRlbnRpYWxCcm9rZXISLAoKaW5zcGVjdGlvbhgGIAEoCzIYLmFnZW50bWVzaC52MS5JbnNwZWN0aW9uEiYKBG1vZGUYByABKA4yGC5hZ2VudG1lc2gudjEuRWdyZXNzTW9kZRINCgVwb3J0cxgIIAMoDRIVCg1wcmVzZXJ2ZV9ob3N0GAkgASgIEhcKD2ZvcndhcmRfY29udGV4dBgKIAEoCCI5CgpJbnNwZWN0aW9uEisKCmluc3BlY3RvcnMYASADKAsyFy5hZ2VudG1lc2gudjEuSW5zcGVjdG9yIm8KCUluc3BlY3RvchIvCgttb2RlbF9hcm1vchgBIAEoCzIYLmFnZW50bWVzaC52MS5Nb2RlbEFybW9ySAASKQoIZXh0X3Byb2MYAiABKAsyFS5hZ2VudG1lc2gudjEuRXh0UHJvY0gAQgYKBGtpbmQikQEKCk1vZGVsQXJtb3ISEAoIdGVtcGxhdGUYASABKAkSMgoIcmVzcG9uc2UYAiABKA4yIC5hZ2VudG1lc2gudjEuUmVzcG9uc2VJbnNwZWN0aW9uEhEKCWZhaWxfb3BlbhgDIAEoCBIqCgd0aW1lb3V0GAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIogCCgdFeHRQcm9jEg4KBnRhcmdldBgBIAEoCRIKCgJjYRgCIAEoCRIaChJjbGllbnRfY2VydGlmaWNhdGUYAyABKAkSPAoPcHJvY2Vzc2luZ19tb2RlGAQgASgLMiMuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZRIbChNhbGxvd19tb2RlX292ZXJyaWRlGAUgASgIEjIKD21lc3NhZ2VfdGltZW91dBgGIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbhIaChJmYWlsdXJlX21vZGVfYWxsb3cYByABKAgSGgoSbWF4X2J1ZmZlcmVkX2J5dGVzGAggASgNIv8EChVFeHRQcm9jUHJvY2Vzc2luZ01vZGUSSwoTcmVxdWVzdF9oZWFkZXJfbW9kZRgBIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJMChRyZXNwb25zZV9oZWFkZXJfbW9kZRgCIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJHChFyZXF1ZXN0X2JvZHlfbW9kZRgDIAEoDjIsLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuQm9keU1vZGUSSAoScmVzcG9uc2VfYm9keV9tb2RlGAQgASgOMiwuYWdlbnRtZXNoLnYxLkV4dFByb2NQcm9jZXNzaW5nTW9kZS5Cb2R5TW9kZRJMChRyZXF1ZXN0X3RyYWlsZXJfbW9kZRgFIAEoDjIuLmFnZW50bWVzaC52MS5FeHRQcm9jUHJvY2Vzc2luZ01vZGUuSGVhZGVyTW9kZRJNChVyZXNwb25zZV90cmFpbGVyX21vZGUYBiABKA4yLi5hZ2VudG1lc2gudjEuRXh0UHJvY1Byb2Nlc3NpbmdNb2RlLkhlYWRlck1vZGUiOQoKSGVhZGVyTW9kZRIXChNIRUFERVJfTU9ERV9ERUZBVUxUEAASCAoEU0VORBABEggKBFNLSVAQAiJgCghCb2R5TW9kZRIICgROT05FEAASDAoIU1RSRUFNRUQQARIMCghCVUZGRVJFRBACEhQKEEJVRkZFUkVEX1BBUlRJQUwQAxIYChRGVUxMX0RVUExFWF9TVFJFQU1FRBAEIuEBChBDcmVkZW50aWFsQnJva2VyEhcKDXN0YXRpY19zZWNyZXQYASABKAlIABI3Cg9vaWRjX2ZlZGVyYXRpb24YAiABKAsyHC5hZ2VudG1lc2gudjEuT0lEQ0ZlZGVyYXRpb25IABI2Cg9hd3NfYXNzdW1lX3JvbGUYAyABKAsyGy5hZ2VudG1lc2gudjEuQVdTQXNzdW1lUm9sZUgAEjsKEXBsYXRmb3JtX2lkZW50aXR5GAQgASgLMh4uYWdlbnRtZXNoLnYxLlBsYXRmb3JtSWRlbnRpdHlIAEIGCgRraW5kIl8KDk9JRENGZWRlcmF0aW9uEhYKDnRva2VuX2VuZHBvaW50GAEgASgJEhAKCGF1ZGllbmNlGAIgASgJEhMKC2ltcGVyc29uYXRlGAMgASgJEg4KBnNjb3BlcxgEIAMoCSI5Cg1BV1NBc3N1bWVSb2xlEhAKCHJvbGVfYXJuGAEgASgJEhYKDnNlc3Npb25fcG9saWN5GAIgASgJIiIKEFBsYXRmb3JtSWRlbnRpdHkSDgoGc2NvcGVzGAEgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIpcBCgxQb2xpY3lDb25maWcSJwoFcm9sZXMYASADKAsyGC5hZ2VudG1lc2gudjEuUG9saWN5Um9sZRItCghiaW5kaW5ncxgCIAMoCzIbLmFnZW50bWVzaC52MS5Qb2xpY3lCaW5kaW5nEi8KBmVncmVzcxgDIAMoCzIfLmFnZW50bWVzaC52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0IjAKF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYASADKAkiPAoaUG9saWN5Q29uZmlnVXBkYXRlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSIaChhFZ3Jlc3NBc3NpZ25tZW50c1JlcXVlc3QiTAoZRWdyZXNzQXNzaWdubWVudHNSZXNwb25zZRIvCgZlZ3Jlc3MYASADKAsyHy5hZ2VudG1lc2gudjEuRWdyZXNzRGVzdGluYXRpb24iZgoMS2V5c1Jlc3BvbnNlEhMKC3B1YmxpY19rZXlzGAEgAygMEi0KCXNpZ25fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKc2lnbmF0dXJlcxgDIAMoDCJrChNUb2tlblJlZnJlc2hSZXF1ZXN0EhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYASABKAwSGQoRY2hhbGxlbmdlX3VuaXhfbXMYAiABKAMSDwoHcGVlcl9pZBgDIAEoCRILCgNqd3QYBCABKAkidQoUVG9rZW5SZWZyZXNoUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNZXJyb3JfbWVzc2FnZRgDIAEoCSJAChFOb2RlQ2F0YWxvZ1JlcG9ydBIrCghzZXJ2aWNlcxgBIAMoCzIZLmFnZW50bWVzaC52MS5TZXJ2aWNlSW5mbyIlChJUb2tlblJldm9rZVJlcXVlc3QSDwoHcGVlcl9pZBgBIAEoCSI1ChNUb2tlblJldm9rZVJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgSDQoFZXJyb3IYAiABKAkiqQEKG0Jvb3RzdHJhcFRva2VuQ3JlYXRlUmVxdWVzdBIMCgRyb2xlGAEgASgJEhAKCG93bmVyX2lkGAIgASgJEhEKCXR0bF9ob3VycxgDIAEoBRISCgptYXhfdXNhZ2VzGAQgASgFEhMKC2Rlc2NyaXB0aW9uGAUgASgJEhsKE2F1dG9ub21vdXNfcmVjb3ZlcnkYBiABKAgSEQoJZXBoZW1lcmFsGAcgASgIIooBChxCb290c3RyYXBUb2tlbkNyZWF0ZVJlc3BvbnNlEgoKAmlkGAEgASgJEg0KBXRva2VuGAIgASgJEgwKBHJvbGUYAyABKAkSEAoIb3duZXJfaWQYBCABKAkSLwoLZXhwaXJlX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIr4CCg5Cb290c3RyYXBUb2tlbhIKCgJpZBgBIAEoCRIMCgRyb2xlGAIgASgJEhAKCG93bmVyX2lkGAMgASgJEhIKCm1heF91c2FnZXMYBCABKAUSFAoMdXNhZ2VzX2NvdW50GAUgASgFEhMKC2Rlc2NyaXB0aW9uGAYgASgJEi8KC2NyZWF0ZV90aW1lGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtleHBpcmVfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLcmV2b2tlX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKE2F1dG9ub21vdXNfcmVjb3ZlcnkYCiABKAgSEQoJZXBoZW1lcmFsGAsgASgIIkoKGkJvb3RzdHJhcFRva2VuTGlzdFJlc3BvbnNlEiwKBnRva2VucxgBIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbiLWAgoRRW5yb2xsbWVudFJlcXVlc3QSCgoCaWQYASABKAkSDwoHcGVlcl9pZBgCIAEoCRIQCgh0b2tlbl9pZBgDIAEoCRIuCgZzdGF0dXMYBCABKA4yHi5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFN0YXR1cxI7CgZsYWJlbHMYBSADKAsyKy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QuTGFiZWxzRW50cnkSLwoLY3JlYXRlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHJlc29sdmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEwoLcmVzb2x2ZWRfYnkYCCABKAkaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJSCh1FbnJvbGxtZW50UmVxdWVzdExpc3RSZXNwb25zZRIxCghyZXF1ZXN0cxgBIAMoCzIfLmFnZW50bWVzaC52MS5FbnJvbGxtZW50UmVxdWVzdCJwCgRVc2VyEgoKAmlkGAEgASgJEg4KBmlzc3VlchgCIAEoCRINCgVlbWFpbBgDIAEoCRIMCgRyb2xlGAQgASgJEi8KC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCL2AgoMRW5yb2xsZWROb2RlEg8KB3BlZXJfaWQYASABKAkSDAoEcm9sZRgCIAEoCRIXCg9lbnJvbGxtZW50X3R5cGUYAyABKAkSEwoLY2xhaW1zX2pzb24YBCABKAkSEAoIb3duZXJfaWQYBSABKAkSNgoGbGFiZWxzGAYgAygLMiYuYWdlbnRtZXNoLnYxLkVucm9sbGVkTm9kZS5MYWJlbHNFbnRyeRIvCgtlbnJvbGxfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg4KBmJhbm5lZBgJIAEoCBIbChNhdXRvbm9tb3VzX3JlY292ZXJ5GAogASgIEhEKCWVwaGVtZXJhbBgLIAEoCBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBItkBCgtSb3V0ZXJMZWFzZRIPCgdwZWVyX2lkGAEgASgJEhEKCWFkZHJlc3NlcxgCIAMoCRI1ChFsYXN0X3JlbmV3YWxfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhcKD2Nvbm5lY3RlZF9wZWVycxgFIAMoCRIQCghkaHRfc2l6ZRgGIAEoBRITCgtpbmdyZXNzX3VybBgHIAEoCSJsCgxOb2RlU2VydmljZXMSKwoIc2VydmljZXMYASADKAsyGS5hZ2VudG1lc2gudjEuU2VydmljZUluZm8SLwoLcmVwb3J0X3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wItsDChNBZG1pblN0YXR1c1Jlc3BvbnNlEiEKBXVzZXJzGAEgAygLMhIuYWdlbnRtZXNoLnYxLlVzZXISMQoOYWN0aXZlX3JvdXRlcnMYAiADKAsyGS5hZ2VudG1lc2gudjEuUm91dGVyTGVhc2USMgoOZW5yb2xsZWRfbm9kZXMYAyADKAsyGi5hZ2VudG1lc2gudjEuRW5yb2xsZWROb2RlEjwKE2Vucm9sbG1lbnRfcmVxdWVzdHMYBCADKAsyHy5hZ2VudG1lc2gudjEuRW5yb2xsbWVudFJlcXVlc3QSNgoQYm9vdHN0cmFwX3Rva2VucxgFIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIqCgZwb2xpY3kYBiABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnEkgKDG5vZGVfY2F0YWxvZxgHIAMoCzIyLmFnZW50bWVzaC52MS5BZG1pblN0YXR1c1Jlc3BvbnNlLk5vZGVDYXRhbG9nRW50cnkaTgoQTm9kZUNhdGFsb2dFbnRyeRILCgNrZXkYASABKAkSKQoFdmFsdWUYAiABKAsyGi5hZ2VudG1lc2gudjEuTm9kZVNlcnZpY2VzOgI4ASKBAgoSVXNlclN0YXR1c1Jlc3BvbnNlEiAKBHVzZXIYASABKAsyEi5hZ2VudG1lc2gudjEuVXNlchIyCg5lbnJvbGxlZF9ub2RlcxgCIAMoCzIaLmFnZW50bWVzaC52MS5FbnJvbGxlZE5vZGUSNgoQYm9vdHN0cmFwX3Rva2VucxgDIAMoCzIcLmFnZW50bWVzaC52MS5Cb290c3RyYXBUb2tlbhIxCg5hY3RpdmVfcm91dGVycxgEIAMoCzIZLmFnZW50bWVzaC52MS5Sb3V0ZXJMZWFzZRIqCgZwb2xpY3kYBSABKAsyGi5hZ2VudG1lc2gudjEuUG9saWN5Q29uZmlnIlYKEE5vZGVMaXN0UmVzcG9uc2USKQoFbm9kZXMYASADKAsyGi5hZ2VudG1lc2gudjEuRW5yb2xsZWROb2RlEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSLkAQoYSWRlbnRpdHlFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBI3ChNiaXNjdWl0X2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIZChFjb250cm9sX3BsYW5lX3VybBgEIAEoCRIiChp0cnVzdGVkX2NvbnRyb2xfcGxhbmVfa2V5cxgFIAMoDBIuCgpjaGVja190aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLGAgoUUGVlckV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEhUKDXZlcmlmeWluZ19rZXkYAyABKAwSDQoFcm9sZXMYBCADKAkSPgoGbGFiZWxzGAUgAygLMi4uYWdlbnRtZXNoLnYxLlBlZXJFdmlkZW5jZVJlc3BvbnNlLkxhYmVsc0VudHJ5Ei8KC2V4cGlyZV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIWCg5yZXZvY2F0aW9uX2lkcxgHIAMoCRIuCgpjaGVja190aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIowCChBNZW1iZXJDcmVkZW50aWFsEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSLwoLZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjUKDHRydXN0ZWRfa2V5cxgEIAMoCzIfLmFnZW50bWVzaC52MS5UcnVzdGVkU2lnbmluZ0tleRIZChFpc3N1ZWRfdW5kZXJfa2V5cxgFIAMoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAYgAygJEi8KDG9pZGNfc2Vzc2lvbhgHIAEoCzIZLmFnZW50bWVzaC52MS5PSURDU2Vzc2lvbiJZChFUcnVzdGVkU2lnbmluZ0tleRISCgpwdWJsaWNfa2V5GAEgASgMEjAKDHJlY2VpdmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLT0lEQ1Nlc3Npb24SDgoGaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJIpMBChVUYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEbmFtZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSJQoFcnVsZXMYAyADKAsyFi5hZ2VudG1lc2gudjEuVGFza1J1bGUSLwoLZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoQBCghUYXNrUnVsZRITCgtkZXNjcmlwdGlvbhgBIAEoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAIgAygJEi4KCW9wZXJhdGlvbhgDIAEoCzIbLmFnZW50bWVzaC52MS5UYXNrT3BlcmF0aW9uEhkKEWFsbG93ZWRfcmVzb3VyY2VzGAQgAygJInMKDVRhc2tPcGVyYXRpb24SFQoNYWxsb3dlZF90b29scxgBIAMoCRIXCg9hbGxvd2VkX21ldGhvZHMYAiADKAkSFQoNYWxsb3dlZF9wYXRocxgDIAMoCRIbChNhbGxvd2VkX3Blcm1pc3Npb25zGAQgAygJIqsBChRUb2tlbkV4Y2hhbmdlUmVxdWVzdBIVCg1zdWJqZWN0X3Rva2VuGAEgASgJEjYKCXRhc2tfcnVsZRgCIAEoCzIjLmFnZW50bWVzaC52MS5UYXNrQXV0aG9yaXphdGlvblJ1bGUSDAoEc2VhbBgDIAEoCBIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIn8KFVRva2VuRXhjaGFuZ2VSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgVyb2xlcxgDIAMoCRIPCgdzdWJqZWN0GAQgASgJIoEBCg9TVFNUb2tlblJlcXVlc3QSDwoHYmlzY3VpdBgBIAEoDBITCgtkZXN0aW5hdGlvbhgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIZChFjaGFsbGVuZ2VfdW5peF9tcxgEIAEoAxIbChNjaGFsbGVuZ2Vfc2lnbmF0dXJlGAUgASgMIoMBChBTVFNUb2tlblJlc3BvbnNlEgsKA2p3dBgBIAEoCRIvCgtleHBpcmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHc3ViamVjdBgDIAEoCRINCgVyb2xlcxgEIAMoCRIRCgl0YXNrX25hbWUYBSABKAkiRgoTUmV2b2NhdGlvbnNSZXNwb25zZRIWCg5yZXZvY2F0aW9uX2lkcxgBIAMoCRIXCg9iYW5uZWRfcGVlcl9pZHMYAiADKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKowBCgtTZXJ2aWNlVHlwZRIcChhTRVJWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABIUChBTRVJWSUNFX1RZUEVfTUNQEAESGgoWU0VSVklDRV9UWVBFX0lORkVSRU5DRRACEhQKEFNFUlZJQ0VfVFlQRV9BMkEQAxIXChNTRVJWSUNFX1RZUEVfRUdSRVNTEAQqcwoPSW5ncmVzc0F1dGhNb2RlEiEKHUlOR1JFU1NfQVVUSF9NT0RFX1VOU1BFQ0lGSUVEEAASGgoWSU5HUkVTU19BVVRIX01PREVfRURHRRABEiEKHUlOR1JFU1NfQVVUSF9NT0RFX1BBU1NUSFJPVUdIEAIqNwoKRWdyZXNzTW9kZRIUChBFR1JFU1NfTU9ERV9IVFRQEAASEwoPRUdSRVNTX01PREVfVENQEAEqXAoSUmVzcG9uc2VJbnNwZWN0aW9uEiAKHFJFU1BPTlNFX0lOU1BFQ1RJT05fQlVGRkVSRUQQABIkCiBSRVNQT05TRV9JTlNQRUNUSU9OX1JFUVVFU1RfT05MWRABQiFaH2dpdGh1Yi5jb20vZ29vZ2xlL2FnZW50bWVzaC9hcGliBnByb3RvMw", [file_google_protobuf_duration, file_google_protobuf_timestamp]); /** * @generated from message agentmesh.v1.AuthFrame @@ -427,6 +427,41 @@ export type BootstrapEnrollResponse = Message<"agentmesh.v1.BootstrapEnrollRespo export const BootstrapEnrollResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_agentmesh, 7); +/** + * ServiceIngress configures external router HTTP ingress for an inbound service + * (a2a, mcp, or inference). Absent means the service is reachable only inside + * the mesh. + * + * @generated from message agentmesh.v1.ServiceIngress + */ +export type ServiceIngress = Message<"agentmesh.v1.ServiceIngress"> & { + /** + * Defaults to INGRESS_AUTH_MODE_EDGE when UNSPECIFIED. + * + * @generated from field: agentmesh.v1.IngressAuthMode auth_mode = 1; + */ + authMode: IngressAuthMode; + + /** + * Optional caller narrowing in PolicyBinding.members syntax + * (e.g. "email:alice@example.com", "email:*@example.com", "group:eng"). + * Intersected (AND) with mesh Datalog policy: when empty, any caller + * authorized by mesh policy for this service may invoke it; when non-empty, + * the verified caller must also match at least one entry here. Only allowed + * in INGRESS_AUTH_MODE_EDGE. + * + * @generated from field: repeated string allow = 2; + */ + allow: string[]; +}; + +/** + * Describes the message agentmesh.v1.ServiceIngress. + * Use `create(ServiceIngressSchema)` to create a new message. + */ +export const ServiceIngressSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 8); + /** * @generated from message agentmesh.v1.ServiceInfo */ @@ -445,6 +480,13 @@ export type ServiceInfo = Message<"agentmesh.v1.ServiceInfo"> & { * @generated from field: string description = 3; */ description: string; + + /** + * Optional external ingress exposure for this service. + * + * @generated from field: agentmesh.v1.ServiceIngress ingress = 4; + */ + ingress?: ServiceIngress | undefined; }; /** @@ -452,7 +494,7 @@ export type ServiceInfo = Message<"agentmesh.v1.ServiceInfo"> & { * Use `create(ServiceInfoSchema)` to create a new message. */ export const ServiceInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 8); + messageDesc(file_agentmesh, 9); /** * @generated from message agentmesh.v1.CommandBackend @@ -474,7 +516,7 @@ export type CommandBackend = Message<"agentmesh.v1.CommandBackend"> & { * Use `create(CommandBackendSchema)` to create a new message. */ export const CommandBackendSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 9); + messageDesc(file_agentmesh, 10); /** * @generated from message agentmesh.v1.RegisterServiceRequest @@ -508,7 +550,7 @@ export type RegisterServiceRequest = Message<"agentmesh.v1.RegisterServiceReques * Use `create(RegisterServiceRequestSchema)` to create a new message. */ export const RegisterServiceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 10); + messageDesc(file_agentmesh, 11); /** * @generated from message agentmesh.v1.DiscoveredProvider @@ -540,7 +582,7 @@ export type DiscoveredProvider = Message<"agentmesh.v1.DiscoveredProvider"> & { * Use `create(DiscoveredProviderSchema)` to create a new message. */ export const DiscoveredProviderSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 11); + messageDesc(file_agentmesh, 12); /** * @generated from message agentmesh.v1.ControlPlaneInfoResponse @@ -608,7 +650,7 @@ export type ControlPlaneInfoResponse = Message<"agentmesh.v1.ControlPlaneInfoRes * Use `create(ControlPlaneInfoResponseSchema)` to create a new message. */ export const ControlPlaneInfoResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 12); + messageDesc(file_agentmesh, 13); /** * DirectoryParameters are the settings of the DHT the routers serve, set @@ -650,7 +692,7 @@ export type DirectoryParameters = Message<"agentmesh.v1.DirectoryParameters"> & * Use `create(DirectoryParametersSchema)` to create a new message. */ export const DirectoryParametersSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 13); + messageDesc(file_agentmesh, 14); /** * DirectoryRequest is what a member asks a router it is attached to over @@ -699,7 +741,7 @@ export type DirectoryRequest = Message<"agentmesh.v1.DirectoryRequest"> & { * Use `create(DirectoryRequestSchema)` to create a new message. */ export const DirectoryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 14); + messageDesc(file_agentmesh, 15); /** * DirectoryProvide announces the sender as a provider of key, reached at @@ -735,6 +777,15 @@ export type DirectoryProvide = Message<"agentmesh.v1.DirectoryProvide"> & { * @generated from field: string service = 4; */ service: string; + + /** + * Optional external ingress metadata for a named service record. When set, + * the router verifies that the provider's credential grants exposing this + * service with the requested auth_mode before storing or replicating it. + * + * @generated from field: agentmesh.v1.ServiceIngress ingress = 5; + */ + ingress?: ServiceIngress | undefined; }; /** @@ -742,7 +793,7 @@ export type DirectoryProvide = Message<"agentmesh.v1.DirectoryProvide"> & { * Use `create(DirectoryProvideSchema)` to create a new message. */ export const DirectoryProvideSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 15); + messageDesc(file_agentmesh, 16); /** * DirectoryWithdraw removes the sender's record under key, from the router @@ -769,7 +820,7 @@ export type DirectoryWithdraw = Message<"agentmesh.v1.DirectoryWithdraw"> & { * Use `create(DirectoryWithdrawSchema)` to create a new message. */ export const DirectoryWithdrawSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 16); + messageDesc(file_agentmesh, 17); /** * DirectoryFindProviders asks for up to limit providers of key. @@ -793,7 +844,7 @@ export type DirectoryFindProviders = Message<"agentmesh.v1.DirectoryFindProvider * Use `create(DirectoryFindProvidersSchema)` to create a new message. */ export const DirectoryFindProvidersSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 17); + messageDesc(file_agentmesh, 18); /** * DirectoryFindPeer asks where peer_id is reached. @@ -812,7 +863,7 @@ export type DirectoryFindPeer = Message<"agentmesh.v1.DirectoryFindPeer"> & { * Use `create(DirectoryFindPeerSchema)` to create a new message. */ export const DirectoryFindPeerSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 18); + messageDesc(file_agentmesh, 19); /** * DirectoryResponse answers one DirectoryRequest. providers answers @@ -843,7 +894,7 @@ export type DirectoryResponse = Message<"agentmesh.v1.DirectoryResponse"> & { * Use `create(DirectoryResponseSchema)` to create a new message. */ export const DirectoryResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 19); + messageDesc(file_agentmesh, 20); /** * PeerRecord is a peer and the addresses it is reached at, relayed ones @@ -861,6 +912,13 @@ export type PeerRecord = Message<"agentmesh.v1.PeerRecord"> & { * @generated from field: repeated string addrs = 2; */ addrs: string[]; + + /** + * Present on service provider records that opted into external ingress. + * + * @generated from field: agentmesh.v1.ServiceIngress ingress = 3; + */ + ingress?: ServiceIngress | undefined; }; /** @@ -868,7 +926,7 @@ export type PeerRecord = Message<"agentmesh.v1.PeerRecord"> & { * Use `create(PeerRecordSchema)` to create a new message. */ export const PeerRecordSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 20); + messageDesc(file_agentmesh, 21); /** * RouterInfo is one router as the control plane lists it on /info. @@ -914,6 +972,15 @@ export type RouterInfo = Message<"agentmesh.v1.RouterInfo"> & { * @generated from field: int32 connection_limit = 5; */ connectionLimit: number; + + /** + * Base HTTPS/HTTP URL where this router serves external ingress, without a + * trailing slash (e.g. "https://bananas.sam-mesh.dev/ingress"). Empty when + * external ingress is disabled on this router. + * + * @generated from field: string ingress_url = 6; + */ + ingressUrl: string; }; /** @@ -921,7 +988,7 @@ export type RouterInfo = Message<"agentmesh.v1.RouterInfo"> & { * Use `create(RouterInfoSchema)` to create a new message. */ export const RouterInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 21); + messageDesc(file_agentmesh, 22); /** * @generated from message agentmesh.v1.RouterLeaseRequest @@ -991,6 +1058,13 @@ export type RouterLeaseRequest = Message<"agentmesh.v1.RouterLeaseRequest"> & { * @generated from field: int32 connection_limit = 10; */ connectionLimit: number; + + /** + * Optional base URL where this router serves external HTTP ingress. + * + * @generated from field: string ingress_url = 11; + */ + ingressUrl: string; }; /** @@ -998,7 +1072,7 @@ export type RouterLeaseRequest = Message<"agentmesh.v1.RouterLeaseRequest"> & { * Use `create(RouterLeaseRequestSchema)` to create a new message. */ export const RouterLeaseRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 22); + messageDesc(file_agentmesh, 23); /** * @generated from message agentmesh.v1.RouterLeaseResponse @@ -1025,7 +1099,7 @@ export type RouterLeaseResponse = Message<"agentmesh.v1.RouterLeaseResponse"> & * Use `create(RouterLeaseResponseSchema)` to create a new message. */ export const RouterLeaseResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 23); + messageDesc(file_agentmesh, 24); /** * @generated from message agentmesh.v1.PolicyRole @@ -1079,6 +1153,15 @@ export type PolicyRole = Message<"agentmesh.v1.PolicyRole"> & { * @generated from field: repeated string allowed_publish = 7; */ allowedPublish: string[]; + + /** + * Inbound services a member with this role may expose on router HTTP + * ingress, together with the permitted IngressAuthModes. Empty denies all + * external ingress exposure for this role. + * + * @generated from field: repeated agentmesh.v1.IngressGrant allowed_ingress = 8; + */ + allowedIngress: IngressGrant[]; }; /** @@ -1086,7 +1169,37 @@ export type PolicyRole = Message<"agentmesh.v1.PolicyRole"> & { * Use `create(PolicyRoleSchema)` to create a new message. */ export const PolicyRoleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 24); + messageDesc(file_agentmesh, 25); + +/** + * IngressGrant permits a role to expose matching inbound services on router + * HTTP ingress under the listed authentication modes. + * + * @generated from message agentmesh.v1.IngressGrant + */ +export type IngressGrant = Message<"agentmesh.v1.IngressGrant"> & { + /** + * Inbound service patterns ("a2a://*", "mcp://calc", "*"). Egress + * destinations ("egress://...") cannot be exposed as ingress. + * + * @generated from field: repeated string services = 1; + */ + services: string[]; + + /** + * Permitted auth modes. Empty grants ONLY INGRESS_AUTH_MODE_EDGE. + * + * @generated from field: repeated agentmesh.v1.IngressAuthMode allowed_auth_modes = 2; + */ + allowedAuthModes: IngressAuthMode[]; +}; + +/** + * Describes the message agentmesh.v1.IngressGrant. + * Use `create(IngressGrantSchema)` to create a new message. + */ +export const IngressGrantSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_agentmesh, 26); /** * HTTPGrant narrows one allowed_services entry to HTTP methods and paths. @@ -1128,7 +1241,7 @@ export type HTTPGrant = Message<"agentmesh.v1.HTTPGrant"> & { * Use `create(HTTPGrantSchema)` to create a new message. */ export const HTTPGrantSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 25); + messageDesc(file_agentmesh, 27); /** * EgressDestination is a destination outside the mesh that selected nodes @@ -1227,7 +1340,7 @@ export type EgressDestination = Message<"agentmesh.v1.EgressDestination"> & { * Use `create(EgressDestinationSchema)` to create a new message. */ export const EgressDestinationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 26); + messageDesc(file_agentmesh, 28); /** * Inspection lists the inspectors the egress node runs, in order; the first @@ -1248,7 +1361,7 @@ export type Inspection = Message<"agentmesh.v1.Inspection"> & { * Use `create(InspectionSchema)` to create a new message. */ export const InspectionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 27); + messageDesc(file_agentmesh, 29); /** * @generated from message agentmesh.v1.Inspector @@ -1277,7 +1390,7 @@ export type Inspector = Message<"agentmesh.v1.Inspector"> & { * Use `create(InspectorSchema)` to create a new message. */ export const InspectorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 28); + messageDesc(file_agentmesh, 30); /** * ModelArmor calls sanitizeUserPrompt / sanitizeModelResponse directly over @@ -1322,7 +1435,7 @@ export type ModelArmor = Message<"agentmesh.v1.ModelArmor"> & { * Use `create(ModelArmorSchema)` to create a new message. */ export const ModelArmorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 29); + messageDesc(file_agentmesh, 31); /** * ExtProc runs an Envoy external processor (envoy.service.ext_proc.v3 @@ -1392,7 +1505,7 @@ export type ExtProc = Message<"agentmesh.v1.ExtProc"> & { * Use `create(ExtProcSchema)` to create a new message. */ export const ExtProcSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 30); + messageDesc(file_agentmesh, 32); /** * @generated from message agentmesh.v1.ExtProcProcessingMode @@ -1434,7 +1547,7 @@ export type ExtProcProcessingMode = Message<"agentmesh.v1.ExtProcProcessingMode" * Use `create(ExtProcProcessingModeSchema)` to create a new message. */ export const ExtProcProcessingModeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 31); + messageDesc(file_agentmesh, 33); /** * @generated from enum agentmesh.v1.ExtProcProcessingMode.HeaderMode @@ -1460,7 +1573,7 @@ export enum ExtProcProcessingMode_HeaderMode { * Describes the enum agentmesh.v1.ExtProcProcessingMode.HeaderMode. */ export const ExtProcProcessingMode_HeaderModeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 31, 0); + enumDesc(file_agentmesh, 33, 0); /** * @generated from enum agentmesh.v1.ExtProcProcessingMode.BodyMode @@ -1496,7 +1609,7 @@ export enum ExtProcProcessingMode_BodyMode { * Describes the enum agentmesh.v1.ExtProcProcessingMode.BodyMode. */ export const ExtProcProcessingMode_BodyModeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 31, 1); + enumDesc(file_agentmesh, 33, 1); /** * @generated from message agentmesh.v1.CredentialBroker @@ -1542,7 +1655,7 @@ export type CredentialBroker = Message<"agentmesh.v1.CredentialBroker"> & { * Use `create(CredentialBrokerSchema)` to create a new message. */ export const CredentialBrokerSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 32); + messageDesc(file_agentmesh, 34); /** * OIDCFederation exchanges the control plane's JWT at a provider STS. @@ -1587,7 +1700,7 @@ export type OIDCFederation = Message<"agentmesh.v1.OIDCFederation"> & { * Use `create(OIDCFederationSchema)` to create a new message. */ export const OIDCFederationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 33); + messageDesc(file_agentmesh, 35); /** * @generated from message agentmesh.v1.AWSAssumeRole @@ -1611,7 +1724,7 @@ export type AWSAssumeRole = Message<"agentmesh.v1.AWSAssumeRole"> & { * Use `create(AWSAssumeRoleSchema)` to create a new message. */ export const AWSAssumeRoleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 34); + messageDesc(file_agentmesh, 36); /** * @generated from message agentmesh.v1.PlatformIdentity @@ -1628,7 +1741,7 @@ export type PlatformIdentity = Message<"agentmesh.v1.PlatformIdentity"> & { * Use `create(PlatformIdentitySchema)` to create a new message. */ export const PlatformIdentitySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 35); + messageDesc(file_agentmesh, 37); /** * @generated from message agentmesh.v1.PolicyBinding @@ -1650,7 +1763,7 @@ export type PolicyBinding = Message<"agentmesh.v1.PolicyBinding"> & { * Use `create(PolicyBindingSchema)` to create a new message. */ export const PolicyBindingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 36); + messageDesc(file_agentmesh, 38); /** * PolicyConfig is the mesh policy as the operator writes it: roles and @@ -1682,7 +1795,7 @@ export type PolicyConfig = Message<"agentmesh.v1.PolicyConfig"> & { * Use `create(PolicyConfigSchema)` to create a new message. */ export const PolicyConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 37); + messageDesc(file_agentmesh, 39); /** * @generated from message agentmesh.v1.PolicyConfigGetRequest @@ -1695,7 +1808,7 @@ export type PolicyConfigGetRequest = Message<"agentmesh.v1.PolicyConfigGetReques * Use `create(PolicyConfigGetRequestSchema)` to create a new message. */ export const PolicyConfigGetRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 38); + messageDesc(file_agentmesh, 40); /** * PolicyConfigGetResponse answers GET /policies for a mesh member holding a @@ -1718,7 +1831,7 @@ export type PolicyConfigGetResponse = Message<"agentmesh.v1.PolicyConfigGetRespo * Use `create(PolicyConfigGetResponseSchema)` to create a new message. */ export const PolicyConfigGetResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 39); + messageDesc(file_agentmesh, 41); /** * @generated from message agentmesh.v1.PolicyConfigUpdateResponse @@ -1740,7 +1853,7 @@ export type PolicyConfigUpdateResponse = Message<"agentmesh.v1.PolicyConfigUpdat * Use `create(PolicyConfigUpdateResponseSchema)` to create a new message. */ export const PolicyConfigUpdateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 40); + messageDesc(file_agentmesh, 42); /** * @generated from message agentmesh.v1.EgressAssignmentsRequest @@ -1753,7 +1866,7 @@ export type EgressAssignmentsRequest = Message<"agentmesh.v1.EgressAssignmentsRe * Use `create(EgressAssignmentsRequestSchema)` to create a new message. */ export const EgressAssignmentsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 41); + messageDesc(file_agentmesh, 43); /** * EgressAssignmentsResponse answers GET /egress for a mesh member holding a @@ -1775,7 +1888,7 @@ export type EgressAssignmentsResponse = Message<"agentmesh.v1.EgressAssignmentsR * Use `create(EgressAssignmentsResponseSchema)` to create a new message. */ export const EgressAssignmentsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 42); + messageDesc(file_agentmesh, 44); /** * @generated from message agentmesh.v1.KeysResponse @@ -1810,7 +1923,7 @@ export type KeysResponse = Message<"agentmesh.v1.KeysResponse"> & { * Use `create(KeysResponseSchema)` to create a new message. */ export const KeysResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 43); + messageDesc(file_agentmesh, 45); /** * @generated from message agentmesh.v1.TokenRefreshRequest @@ -1863,7 +1976,7 @@ export type TokenRefreshRequest = Message<"agentmesh.v1.TokenRefreshRequest"> & * Use `create(TokenRefreshRequestSchema)` to create a new message. */ export const TokenRefreshRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 44); + messageDesc(file_agentmesh, 46); /** * @generated from message agentmesh.v1.TokenRefreshResponse @@ -1890,7 +2003,7 @@ export type TokenRefreshResponse = Message<"agentmesh.v1.TokenRefreshResponse"> * Use `create(TokenRefreshResponseSchema)` to create a new message. */ export const TokenRefreshResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 45); + messageDesc(file_agentmesh, 47); /** * NodeCatalogReport is the body of POST /nodes/catalog: a node's @@ -1912,7 +2025,7 @@ export type NodeCatalogReport = Message<"agentmesh.v1.NodeCatalogReport"> & { * Use `create(NodeCatalogReportSchema)` to create a new message. */ export const NodeCatalogReportSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 46); + messageDesc(file_agentmesh, 48); /** * @generated from message agentmesh.v1.TokenRevokeRequest @@ -1929,7 +2042,7 @@ export type TokenRevokeRequest = Message<"agentmesh.v1.TokenRevokeRequest"> & { * Use `create(TokenRevokeRequestSchema)` to create a new message. */ export const TokenRevokeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 47); + messageDesc(file_agentmesh, 49); /** * @generated from message agentmesh.v1.TokenRevokeResponse @@ -1951,7 +2064,7 @@ export type TokenRevokeResponse = Message<"agentmesh.v1.TokenRevokeResponse"> & * Use `create(TokenRevokeResponseSchema)` to create a new message. */ export const TokenRevokeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 48); + messageDesc(file_agentmesh, 50); /** * BootstrapTokenCreateRequest is the body of POST /admin/bootstrap-tokens @@ -2025,7 +2138,7 @@ export type BootstrapTokenCreateRequest = Message<"agentmesh.v1.BootstrapTokenCr * Use `create(BootstrapTokenCreateRequestSchema)` to create a new message. */ export const BootstrapTokenCreateRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 49); + messageDesc(file_agentmesh, 51); /** * BootstrapTokenCreateResponse is returned (201) when a token is minted. @@ -2066,7 +2179,7 @@ export type BootstrapTokenCreateResponse = Message<"agentmesh.v1.BootstrapTokenC * Use `create(BootstrapTokenCreateResponseSchema)` to create a new message. */ export const BootstrapTokenCreateResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 50); + messageDesc(file_agentmesh, 52); /** * BootstrapToken is a minted token as operators list it. @@ -2138,7 +2251,7 @@ export type BootstrapToken = Message<"agentmesh.v1.BootstrapToken"> & { * Use `create(BootstrapTokenSchema)` to create a new message. */ export const BootstrapTokenSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 51); + messageDesc(file_agentmesh, 53); /** * @generated from message agentmesh.v1.BootstrapTokenListResponse @@ -2155,7 +2268,7 @@ export type BootstrapTokenListResponse = Message<"agentmesh.v1.BootstrapTokenLis * Use `create(BootstrapTokenListResponseSchema)` to create a new message. */ export const BootstrapTokenListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 52); + messageDesc(file_agentmesh, 54); /** * EnrollmentRequest is a bootstrap enrollment awaiting or past an operator @@ -2216,7 +2329,7 @@ export type EnrollmentRequest = Message<"agentmesh.v1.EnrollmentRequest"> & { * Use `create(EnrollmentRequestSchema)` to create a new message. */ export const EnrollmentRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 53); + messageDesc(file_agentmesh, 55); /** * @generated from message agentmesh.v1.EnrollmentRequestListResponse @@ -2233,7 +2346,7 @@ export type EnrollmentRequestListResponse = Message<"agentmesh.v1.EnrollmentRequ * Use `create(EnrollmentRequestListResponseSchema)` to create a new message. */ export const EnrollmentRequestListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 54); + messageDesc(file_agentmesh, 56); /** * User is a human identity known to the mesh. @@ -2276,7 +2389,7 @@ export type User = Message<"agentmesh.v1.User"> & { * Use `create(UserSchema)` to create a new message. */ export const UserSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 55); + messageDesc(file_agentmesh, 57); /** * EnrolledNode is a member's enrollment record without its credential. @@ -2354,7 +2467,7 @@ export type EnrolledNode = Message<"agentmesh.v1.EnrolledNode"> & { * Use `create(EnrolledNodeSchema)` to create a new message. */ export const EnrolledNodeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 56); + messageDesc(file_agentmesh, 58); /** * RouterLease is a router's current registration with the control plane. @@ -2393,6 +2506,11 @@ export type RouterLease = Message<"agentmesh.v1.RouterLease"> & { * @generated from field: int32 dht_size = 6; */ dhtSize: number; + + /** + * @generated from field: string ingress_url = 7; + */ + ingressUrl: string; }; /** @@ -2400,7 +2518,7 @@ export type RouterLease = Message<"agentmesh.v1.RouterLease"> & { * Use `create(RouterLeaseSchema)` to create a new message. */ export const RouterLeaseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 57); + messageDesc(file_agentmesh, 59); /** * NodeServices is the services one node last reported (see @@ -2425,7 +2543,7 @@ export type NodeServices = Message<"agentmesh.v1.NodeServices"> & { * Use `create(NodeServicesSchema)` to create a new message. */ export const NodeServicesSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 58); + messageDesc(file_agentmesh, 60); /** * AdminStatusResponse answers GET /admin/status: everything the console @@ -2477,7 +2595,7 @@ export type AdminStatusResponse = Message<"agentmesh.v1.AdminStatusResponse"> & * Use `create(AdminStatusResponseSchema)` to create a new message. */ export const AdminStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 59); + messageDesc(file_agentmesh, 61); /** * UserStatusResponse answers GET /user/status: the caller and what it owns, @@ -2518,7 +2636,7 @@ export type UserStatusResponse = Message<"agentmesh.v1.UserStatusResponse"> & { * Use `create(UserStatusResponseSchema)` to create a new message. */ export const UserStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 60); + messageDesc(file_agentmesh, 62); /** * NodeListResponse answers GET /user/nodes: enrolled nodes, newest enrollment @@ -2547,7 +2665,7 @@ export type NodeListResponse = Message<"agentmesh.v1.NodeListResponse"> & { * Use `create(NodeListResponseSchema)` to create a new message. */ export const NodeListResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 61); + messageDesc(file_agentmesh, 63); /** * @generated from message agentmesh.v1.IdentityEvidenceResponse @@ -2591,7 +2709,7 @@ export type IdentityEvidenceResponse = Message<"agentmesh.v1.IdentityEvidenceRes * Use `create(IdentityEvidenceResponseSchema)` to create a new message. */ export const IdentityEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 62); + messageDesc(file_agentmesh, 64); /** * @generated from message agentmesh.v1.PeerEvidenceResponse @@ -2647,7 +2765,7 @@ export type PeerEvidenceResponse = Message<"agentmesh.v1.PeerEvidenceResponse"> * Use `create(PeerEvidenceResponseSchema)` to create a new message. */ export const PeerEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 63); + messageDesc(file_agentmesh, 65); /** * @generated from message agentmesh.v1.MemberCredential @@ -2712,7 +2830,7 @@ export type MemberCredential = Message<"agentmesh.v1.MemberCredential"> & { * Use `create(MemberCredentialSchema)` to create a new message. */ export const MemberCredentialSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 64); + messageDesc(file_agentmesh, 66); /** * @generated from message agentmesh.v1.TrustedSigningKey @@ -2739,7 +2857,7 @@ export type TrustedSigningKey = Message<"agentmesh.v1.TrustedSigningKey"> & { * Use `create(TrustedSigningKeySchema)` to create a new message. */ export const TrustedSigningKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 65); + messageDesc(file_agentmesh, 67); /** * @generated from message agentmesh.v1.OIDCSession @@ -2771,7 +2889,7 @@ export type OIDCSession = Message<"agentmesh.v1.OIDCSession"> & { * Use `create(OIDCSessionSchema)` to create a new message. */ export const OIDCSessionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 66); + messageDesc(file_agentmesh, 68); /** * TaskAuthorizationRule narrows a credential's authority for a specific task or @@ -2815,7 +2933,7 @@ export type TaskAuthorizationRule = Message<"agentmesh.v1.TaskAuthorizationRule" * Use `create(TaskAuthorizationRuleSchema)` to create a new message. */ export const TaskAuthorizationRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 67); + messageDesc(file_agentmesh, 69); /** * @generated from message agentmesh.v1.TaskRule @@ -2859,7 +2977,7 @@ export type TaskRule = Message<"agentmesh.v1.TaskRule"> & { * Use `create(TaskRuleSchema)` to create a new message. */ export const TaskRuleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 68); + messageDesc(file_agentmesh, 70); /** * @generated from message agentmesh.v1.TaskOperation @@ -2901,7 +3019,7 @@ export type TaskOperation = Message<"agentmesh.v1.TaskOperation"> & { * Use `create(TaskOperationSchema)` to create a new message. */ export const TaskOperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 69); + messageDesc(file_agentmesh, 71); /** * TokenExchangeRequest is the body of POST /token/exchange on the control @@ -2959,7 +3077,7 @@ export type TokenExchangeRequest = Message<"agentmesh.v1.TokenExchangeRequest"> * Use `create(TokenExchangeRequestSchema)` to create a new message. */ export const TokenExchangeRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 70); + messageDesc(file_agentmesh, 72); /** * @generated from message agentmesh.v1.TokenExchangeResponse @@ -2991,7 +3109,7 @@ export type TokenExchangeResponse = Message<"agentmesh.v1.TokenExchangeResponse" * Use `create(TokenExchangeResponseSchema)` to create a new message. */ export const TokenExchangeResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 71); + messageDesc(file_agentmesh, 73); /** * STSTokenRequest is the body of POST /sts/token on the control plane: an @@ -3045,7 +3163,7 @@ export type STSTokenRequest = Message<"agentmesh.v1.STSTokenRequest"> & { * Use `create(STSTokenRequestSchema)` to create a new message. */ export const STSTokenRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 72); + messageDesc(file_agentmesh, 74); /** * @generated from message agentmesh.v1.STSTokenResponse @@ -3082,7 +3200,7 @@ export type STSTokenResponse = Message<"agentmesh.v1.STSTokenResponse"> & { * Use `create(STSTokenResponseSchema)` to create a new message. */ export const STSTokenResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 73); + messageDesc(file_agentmesh, 75); /** * RevocationsResponse answers GET /revocations on the control plane: the set of @@ -3108,7 +3226,7 @@ export type RevocationsResponse = Message<"agentmesh.v1.RevocationsResponse"> & * Use `create(RevocationsResponseSchema)` to create a new message. */ export const RevocationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_agentmesh, 74); + messageDesc(file_agentmesh, 76); /** * @generated from enum agentmesh.v1.EnrollmentStatus @@ -3182,6 +3300,39 @@ export enum ServiceType { export const ServiceTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_agentmesh, 1); +/** + * @generated from enum agentmesh.v1.IngressAuthMode + */ +export enum IngressAuthMode { + /** + * Defaults to INGRESS_AUTH_MODE_EDGE. + * + * @generated from enum value: INGRESS_AUTH_MODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Router and provider node verify a delegated Mesh Biscuit + mesh policy. + * + * @generated from enum value: INGRESS_AUTH_MODE_EDGE = 1; + */ + EDGE = 1, + + /** + * Backend authenticates its own requests; requires an explicit PASSTHROUGH + * grant on the serving node's PolicyRole.allowed_ingress. + * + * @generated from enum value: INGRESS_AUTH_MODE_PASSTHROUGH = 2; + */ + PASSTHROUGH = 2, +} + +/** + * Describes the enum agentmesh.v1.IngressAuthMode. + */ +export const IngressAuthModeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_agentmesh, 2); + /** * @generated from enum agentmesh.v1.EgressMode */ @@ -3201,7 +3352,7 @@ export enum EgressMode { * Describes the enum agentmesh.v1.EgressMode. */ export const EgressModeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 2); + enumDesc(file_agentmesh, 3); /** * @generated from enum agentmesh.v1.ResponseInspection @@ -3222,5 +3373,5 @@ export enum ResponseInspection { * Describes the enum agentmesh.v1.ResponseInspection. */ export const ResponseInspectionSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_agentmesh, 3); + enumDesc(file_agentmesh, 4); diff --git a/sdk/js/src/gen/datalog.ts b/sdk/js/src/gen/datalog.ts index f9fbb0c9..be399954 100644 --- a/sdk/js/src/gen/datalog.ts +++ b/sdk/js/src/gen/datalog.ts @@ -19,6 +19,14 @@ export const BASELINE_DATALOG = { "allow if publish($type, $name), granted_publish_all($type)", "allow if publish($type, $name), granted_publish_all_types(true)" ], + "ingress_policies": [ + "allow if ingress($type, $name, $mode), granted_ingress_exact($type, $name, $mode)", + "allow if ingress($type, $name, $mode), granted_ingress_set($type, $set, $mode), $set.contains($name)", + "allow if ingress($type, $name, $mode), granted_ingress_prefix($type, $prefix, $mode), $name.starts_with($prefix)", + "allow if ingress($type, $name, $mode), granted_ingress_suffix($type, $suffix, $mode), $name.ends_with($suffix)", + "allow if ingress($type, $name, $mode), granted_ingress_all($type, $mode)", + "allow if ingress($type, $name, $mode), granted_ingress_all_types($mode)" + ], "rules": [ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_exact($fact, $val)", "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_set($fact, $set), $set.contains($val)", @@ -61,6 +69,7 @@ export const BASELINE_DATALOG = { "fact_role": "role", "fact_target_fact": "target_fact", "fact_publish": "publish", + "fact_ingress": "ingress", "catalog_target": "mesh.catalog", "fact_tar_block": "tar_block", "tar_block_source_pattern": "^tar_block\\(\"([A-Za-z0-9_-]+)\"\\);?\\s*$", diff --git a/sdk/python/src/agent_mesh/_gen/datalog.json b/sdk/python/src/agent_mesh/_gen/datalog.json index 7060bdae..19674c00 100644 --- a/sdk/python/src/agent_mesh/_gen/datalog.json +++ b/sdk/python/src/agent_mesh/_gen/datalog.json @@ -16,6 +16,14 @@ "allow if publish($type, $name), granted_publish_all($type)", "allow if publish($type, $name), granted_publish_all_types(true)" ], + "ingress_policies": [ + "allow if ingress($type, $name, $mode), granted_ingress_exact($type, $name, $mode)", + "allow if ingress($type, $name, $mode), granted_ingress_set($type, $set, $mode), $set.contains($name)", + "allow if ingress($type, $name, $mode), granted_ingress_prefix($type, $prefix, $mode), $name.starts_with($prefix)", + "allow if ingress($type, $name, $mode), granted_ingress_suffix($type, $suffix, $mode), $name.ends_with($suffix)", + "allow if ingress($type, $name, $mode), granted_ingress_all($type, $mode)", + "allow if ingress($type, $name, $mode), granted_ingress_all_types($mode)" + ], "rules": [ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_exact($fact, $val)", "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_set($fact, $set), $set.contains($val)", @@ -58,6 +66,7 @@ "fact_role": "role", "fact_target_fact": "target_fact", "fact_publish": "publish", + "fact_ingress": "ingress", "catalog_target": "mesh.catalog", "fact_tar_block": "tar_block", "tar_block_source_pattern": "^tar_block\\(\"([A-Za-z0-9_-]+)\"\\);?\\s*$", diff --git a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py index 34b120d1..2d918739 100644 --- a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py +++ b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x61gentmesh.proto\x12\x0c\x61gentmesh.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xb1\x01\n\x0cRouterGoAway\x12\x31\n\x06reason\x18\x01 \x01(\x0e\x32!.agentmesh.v1.RouterGoAway.Reason\x12.\n\x0bretry_after\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\">\n\x06Reason\x12\x16\n\x12REASON_UNSPECIFIED\x10\x00\x12\x0c\n\x08\x44RAINING\x10\x01\x12\x0e\n\nOVERLOADED\x10\x02\"\xdc\x01\n\tMeshEvent\x12*\n\x04type\x18\x01 \x01(\x0e\x32\x1c.agentmesh.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf9\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x37\n\x06labels\x18\x05 \x03(\x0b\x32\'.agentmesh.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x97\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12@\n\x06labels\x18\x05 \x03(\x0b\x32\x30.agentmesh.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x17\x42ootstrapEnrollResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bServiceInfo\x12\'\n\x04type\x18\x01 \x01(\x0e\x32\x19.agentmesh.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"\x81\x01\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x32\n\x03\x65nv\x18\x02 \x03(\x0b\x32%.agentmesh.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x96\x01\n\x16RegisterServiceRequest\x12*\n\x07service\x18\x01 \x01(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1c.agentmesh.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xe8\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\x12)\n\x07routers\x18\x06 \x03(\x0b\x32\x18.agentmesh.v1.RouterInfo\x12\x34\n\tdirectory\x18\x07 \x01(\x0b\x32!.agentmesh.v1.DirectoryParameters\"\x90\x01\n\x13\x44irectoryParameters\x12\x13\n\x0breplication\x18\x01 \x01(\x05\x12\x35\n\x12reprovide_interval\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nrecord_ttl\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xfb\x01\n\x10\x44irectoryRequest\x12\x31\n\x07provide\x18\x01 \x01(\x0b\x32\x1e.agentmesh.v1.DirectoryProvideH\x00\x12>\n\x0e\x66ind_providers\x18\x02 \x01(\x0b\x32$.agentmesh.v1.DirectoryFindProvidersH\x00\x12\x34\n\tfind_peer\x18\x03 \x01(\x0b\x32\x1f.agentmesh.v1.DirectoryFindPeerH\x00\x12\x33\n\x08withdraw\x18\x04 \x01(\x0b\x32\x1f.agentmesh.v1.DirectoryWithdrawH\x00\x42\t\n\x07request\"Q\n\x10\x44irectoryProvide\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x0f\n\x07service\x18\x04 \x01(\t\"2\n\x11\x44irectoryWithdraw\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x10\n\x08provider\x18\x02 \x01(\t\"4\n\x16\x44irectoryFindProviders\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05limit\x18\x02 \x01(\r\"$\n\x11\x44irectoryFindPeer\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"w\n\x11\x44irectoryResponse\x12+\n\tproviders\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12&\n\x04peer\x18\x02 \x01(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12\r\n\x05\x65rror\x18\x03 \x01(\t\",\n\nPeerRecord\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\"\xc4\x01\n\nRouterInfo\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x34\n\x06labels\x18\x03 \x03(\x0b\x32$.agentmesh.v1.RouterInfo.LabelsEntry\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\x05 \x01(\x05\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12&\n\x03ttl\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0b\x63onnections\x18\t \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\n \x01(\x05\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xbd\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12%\n\x04http\x18\x06 \x03(\x0b\x32\x17.agentmesh.v1.HTTPGrant\x12\x17\n\x0f\x61llowed_publish\x18\x07 \x03(\t\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\xa1\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12.\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x1e.agentmesh.v1.CredentialBroker\x12,\n\ninspection\x18\x06 \x01(\x0b\x32\x18.agentmesh.v1.Inspection\x12&\n\x04mode\x18\x07 \x01(\x0e\x32\x18.agentmesh.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"9\n\nInspection\x12+\n\ninspectors\x18\x01 \x03(\x0b\x32\x17.agentmesh.v1.Inspector\"o\n\tInspector\x12/\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x18.agentmesh.v1.ModelArmorH\x00\x12)\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x15.agentmesh.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x91\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x32\n\x08response\x18\x02 \x01(\x0e\x32 .agentmesh.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x88\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12<\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32#.agentmesh.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xff\x04\n\x15\x45xtProcProcessingMode\x12K\n\x13request_header_mode\x18\x01 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12L\n\x14response_header_mode\x18\x02 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x11request_body_mode\x18\x03 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12H\n\x12response_body_mode\x18\x04 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12L\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12M\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xe1\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x37\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x1c.agentmesh.v1.OIDCFederationH\x00\x12\x36\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.AWSAssumeRoleH\x00\x12;\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x1e.agentmesh.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x97\x01\n\x0cPolicyConfig\x12\'\n\x05roles\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PolicyRole\x12-\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x1b.agentmesh.v1.PolicyBinding\x12/\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"L\n\x19\x45gressAssignmentsResponse\x12/\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\"@\n\x11NodeCatalogReport\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xa9\x01\n\x1b\x42ootstrapTokenCreateRequest\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x10\n\x08owner_id\x18\x02 \x01(\t\x12\x11\n\tttl_hours\x18\x03 \x01(\x05\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\x1b\n\x13\x61utonomous_recovery\x18\x06 \x01(\x08\x12\x11\n\tephemeral\x18\x07 \x01(\x08\"\x8a\x01\n\x1c\x42ootstrapTokenCreateResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0c\n\x04role\x18\x03 \x01(\t\x12\x10\n\x08owner_id\x18\x04 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xbe\x02\n\x0e\x42ootstrapToken\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x10\n\x08owner_id\x18\x03 \x01(\t\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x14\n\x0cusages_count\x18\x05 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12/\n\x0b\x63reate_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0brevoke_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x12\x11\n\tephemeral\x18\x0b \x01(\x08\"J\n\x1a\x42ootstrapTokenListResponse\x12,\n\x06tokens\x18\x01 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\"\xd6\x02\n\x11\x45nrollmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12.\n\x06status\x18\x04 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12;\n\x06labels\x18\x05 \x03(\x0b\x32+.agentmesh.v1.EnrollmentRequest.LabelsEntry\x12/\n\x0b\x63reate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cresolve_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x13\n\x0bresolved_by\x18\x08 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"R\n\x1d\x45nrollmentRequestListResponse\x12\x31\n\x08requests\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\"p\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06issuer\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x0c\n\x04role\x18\x04 \x01(\t\x12/\n\x0b\x63reate_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf6\x02\n\x0c\x45nrolledNode\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x17\n\x0f\x65nrollment_type\x18\x03 \x01(\t\x12\x13\n\x0b\x63laims_json\x18\x04 \x01(\t\x12\x10\n\x08owner_id\x18\x05 \x01(\t\x12\x36\n\x06labels\x18\x06 \x03(\x0b\x32&.agentmesh.v1.EnrolledNode.LabelsEntry\x12/\n\x0b\x65nroll_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x62\x61nned\x18\t \x01(\x08\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x12\x11\n\tephemeral\x18\x0b \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xc4\x01\n\x0bRouterLease\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x35\n\x11last_renewal_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x17\n\x0f\x63onnected_peers\x18\x05 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x06 \x01(\x05\"l\n\x0cNodeServices\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12/\n\x0breport_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdb\x03\n\x13\x41\x64minStatusResponse\x12!\n\x05users\x18\x01 \x03(\x0b\x32\x12.agentmesh.v1.User\x12\x31\n\x0e\x61\x63tive_routers\x18\x02 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12\x32\n\x0e\x65nrolled_nodes\x18\x03 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12<\n\x13\x65nrollment_requests\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\x12\x36\n\x10\x62ootstrap_tokens\x18\x05 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12*\n\x06policy\x18\x06 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\x12H\n\x0cnode_catalog\x18\x07 \x03(\x0b\x32\x32.agentmesh.v1.AdminStatusResponse.NodeCatalogEntry\x1aN\n\x10NodeCatalogEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.agentmesh.v1.NodeServices:\x02\x38\x01\"\x81\x02\n\x12UserStatusResponse\x12 \n\x04user\x18\x01 \x01(\x0b\x32\x12.agentmesh.v1.User\x12\x32\n\x0e\x65nrolled_nodes\x18\x02 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x36\n\x10\x62ootstrap_tokens\x18\x03 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12\x31\n\x0e\x61\x63tive_routers\x18\x04 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12*\n\x06policy\x18\x05 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\"V\n\x10NodeListResponse\x12)\n\x05nodes\x18\x01 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc6\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12>\n\x06labels\x18\x05 \x03(\x0b\x32..agentmesh.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x19.agentmesh.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x93\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12%\n\x05rules\x18\x03 \x03(\x0b\x32\x16.agentmesh.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x84\x01\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12.\n\toperation\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xab\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x36\n\ttask_rule\x18\x02 \x01(\x0b\x32#.agentmesh.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42!Z\x1fgithub.com/google/agentmesh/apib\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x61gentmesh.proto\x12\x0c\x61gentmesh.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xb1\x01\n\x0cRouterGoAway\x12\x31\n\x06reason\x18\x01 \x01(\x0e\x32!.agentmesh.v1.RouterGoAway.Reason\x12.\n\x0bretry_after\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\">\n\x06Reason\x12\x16\n\x12REASON_UNSPECIFIED\x10\x00\x12\x0c\n\x08\x44RAINING\x10\x01\x12\x0e\n\nOVERLOADED\x10\x02\"\xdc\x01\n\tMeshEvent\x12*\n\x04type\x18\x01 \x01(\x0e\x32\x1c.agentmesh.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf9\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x37\n\x06labels\x18\x05 \x03(\x0b\x32\'.agentmesh.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x97\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12@\n\x06labels\x18\x05 \x03(\x0b\x32\x30.agentmesh.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x83\x02\n\x17\x42ootstrapEnrollResponse\x12.\n\x06status\x18\x01 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Q\n\x0eServiceIngress\x12\x30\n\tauth_mode\x18\x01 \x01(\x0e\x32\x1d.agentmesh.v1.IngressAuthMode\x12\r\n\x05\x61llow\x18\x02 \x03(\t\"\x88\x01\n\x0bServiceInfo\x12\'\n\x04type\x18\x01 \x01(\x0e\x32\x19.agentmesh.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12-\n\x07ingress\x18\x04 \x01(\x0b\x32\x1c.agentmesh.v1.ServiceIngress\"\x81\x01\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x32\n\x03\x65nv\x18\x02 \x03(\x0b\x32%.agentmesh.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x96\x01\n\x16RegisterServiceRequest\x12*\n\x07service\x18\x01 \x01(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12/\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x1c.agentmesh.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xe8\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\x12)\n\x07routers\x18\x06 \x03(\x0b\x32\x18.agentmesh.v1.RouterInfo\x12\x34\n\tdirectory\x18\x07 \x01(\x0b\x32!.agentmesh.v1.DirectoryParameters\"\x90\x01\n\x13\x44irectoryParameters\x12\x13\n\x0breplication\x18\x01 \x01(\x05\x12\x35\n\x12reprovide_interval\x18\x02 \x01(\x0b\x32\x19.google.protobuf.Duration\x12-\n\nrecord_ttl\x18\x03 \x01(\x0b\x32\x19.google.protobuf.Duration\"\xfb\x01\n\x10\x44irectoryRequest\x12\x31\n\x07provide\x18\x01 \x01(\x0b\x32\x1e.agentmesh.v1.DirectoryProvideH\x00\x12>\n\x0e\x66ind_providers\x18\x02 \x01(\x0b\x32$.agentmesh.v1.DirectoryFindProvidersH\x00\x12\x34\n\tfind_peer\x18\x03 \x01(\x0b\x32\x1f.agentmesh.v1.DirectoryFindPeerH\x00\x12\x33\n\x08withdraw\x18\x04 \x01(\x0b\x32\x1f.agentmesh.v1.DirectoryWithdrawH\x00\x42\t\n\x07request\"\x80\x01\n\x10\x44irectoryProvide\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\x12\x10\n\x08provider\x18\x03 \x01(\t\x12\x0f\n\x07service\x18\x04 \x01(\t\x12-\n\x07ingress\x18\x05 \x01(\x0b\x32\x1c.agentmesh.v1.ServiceIngress\"2\n\x11\x44irectoryWithdraw\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x10\n\x08provider\x18\x02 \x01(\t\"4\n\x16\x44irectoryFindProviders\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05limit\x18\x02 \x01(\r\"$\n\x11\x44irectoryFindPeer\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"w\n\x11\x44irectoryResponse\x12+\n\tproviders\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12&\n\x04peer\x18\x02 \x01(\x0b\x32\x18.agentmesh.v1.PeerRecord\x12\r\n\x05\x65rror\x18\x03 \x01(\t\"[\n\nPeerRecord\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\r\n\x05\x61\x64\x64rs\x18\x02 \x03(\t\x12-\n\x07ingress\x18\x03 \x01(\x0b\x32\x1c.agentmesh.v1.ServiceIngress\"\xd9\x01\n\nRouterInfo\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x34\n\x06labels\x18\x03 \x03(\x0b\x32$.agentmesh.v1.RouterInfo.LabelsEntry\x12\x13\n\x0b\x63onnections\x18\x04 \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\x05 \x01(\x05\x12\x13\n\x0bingress_url\x18\x06 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x98\x02\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x12&\n\x03ttl\x18\x08 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x13\n\x0b\x63onnections\x18\t \x01(\x05\x12\x18\n\x10\x63onnection_limit\x18\n \x01(\x05\x12\x13\n\x0bingress_url\x18\x0b \x01(\t\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf2\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12%\n\x04http\x18\x06 \x03(\x0b\x32\x17.agentmesh.v1.HTTPGrant\x12\x17\n\x0f\x61llowed_publish\x18\x07 \x03(\t\x12\x33\n\x0f\x61llowed_ingress\x18\x08 \x03(\x0b\x32\x1a.agentmesh.v1.IngressGrant\"[\n\x0cIngressGrant\x12\x10\n\x08services\x18\x01 \x03(\t\x12\x39\n\x12\x61llowed_auth_modes\x18\x02 \x03(\x0e\x32\x1d.agentmesh.v1.IngressAuthMode\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\xa1\x02\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\x12.\n\x06\x62roker\x18\x05 \x01(\x0b\x32\x1e.agentmesh.v1.CredentialBroker\x12,\n\ninspection\x18\x06 \x01(\x0b\x32\x18.agentmesh.v1.Inspection\x12&\n\x04mode\x18\x07 \x01(\x0e\x32\x18.agentmesh.v1.EgressMode\x12\r\n\x05ports\x18\x08 \x03(\r\x12\x15\n\rpreserve_host\x18\t \x01(\x08\x12\x17\n\x0f\x66orward_context\x18\n \x01(\x08\"9\n\nInspection\x12+\n\ninspectors\x18\x01 \x03(\x0b\x32\x17.agentmesh.v1.Inspector\"o\n\tInspector\x12/\n\x0bmodel_armor\x18\x01 \x01(\x0b\x32\x18.agentmesh.v1.ModelArmorH\x00\x12)\n\x08\x65xt_proc\x18\x02 \x01(\x0b\x32\x15.agentmesh.v1.ExtProcH\x00\x42\x06\n\x04kind\"\x91\x01\n\nModelArmor\x12\x10\n\x08template\x18\x01 \x01(\t\x12\x32\n\x08response\x18\x02 \x01(\x0e\x32 .agentmesh.v1.ResponseInspection\x12\x11\n\tfail_open\x18\x03 \x01(\x08\x12*\n\x07timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.Duration\"\x88\x02\n\x07\x45xtProc\x12\x0e\n\x06target\x18\x01 \x01(\t\x12\n\n\x02\x63\x61\x18\x02 \x01(\t\x12\x1a\n\x12\x63lient_certificate\x18\x03 \x01(\t\x12<\n\x0fprocessing_mode\x18\x04 \x01(\x0b\x32#.agentmesh.v1.ExtProcProcessingMode\x12\x1b\n\x13\x61llow_mode_override\x18\x05 \x01(\x08\x12\x32\n\x0fmessage_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.Duration\x12\x1a\n\x12\x66\x61ilure_mode_allow\x18\x07 \x01(\x08\x12\x1a\n\x12max_buffered_bytes\x18\x08 \x01(\r\"\xff\x04\n\x15\x45xtProcProcessingMode\x12K\n\x13request_header_mode\x18\x01 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12L\n\x14response_header_mode\x18\x02 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12G\n\x11request_body_mode\x18\x03 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12H\n\x12response_body_mode\x18\x04 \x01(\x0e\x32,.agentmesh.v1.ExtProcProcessingMode.BodyMode\x12L\n\x14request_trailer_mode\x18\x05 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\x12M\n\x15response_trailer_mode\x18\x06 \x01(\x0e\x32..agentmesh.v1.ExtProcProcessingMode.HeaderMode\"9\n\nHeaderMode\x12\x17\n\x13HEADER_MODE_DEFAULT\x10\x00\x12\x08\n\x04SEND\x10\x01\x12\x08\n\x04SKIP\x10\x02\"`\n\x08\x42odyMode\x12\x08\n\x04NONE\x10\x00\x12\x0c\n\x08STREAMED\x10\x01\x12\x0c\n\x08\x42UFFERED\x10\x02\x12\x14\n\x10\x42UFFERED_PARTIAL\x10\x03\x12\x18\n\x14\x46ULL_DUPLEX_STREAMED\x10\x04\"\xe1\x01\n\x10\x43redentialBroker\x12\x17\n\rstatic_secret\x18\x01 \x01(\tH\x00\x12\x37\n\x0foidc_federation\x18\x02 \x01(\x0b\x32\x1c.agentmesh.v1.OIDCFederationH\x00\x12\x36\n\x0f\x61ws_assume_role\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.AWSAssumeRoleH\x00\x12;\n\x11platform_identity\x18\x04 \x01(\x0b\x32\x1e.agentmesh.v1.PlatformIdentityH\x00\x42\x06\n\x04kind\"_\n\x0eOIDCFederation\x12\x16\n\x0etoken_endpoint\x18\x01 \x01(\t\x12\x10\n\x08\x61udience\x18\x02 \x01(\t\x12\x13\n\x0bimpersonate\x18\x03 \x01(\t\x12\x0e\n\x06scopes\x18\x04 \x03(\t\"9\n\rAWSAssumeRole\x12\x10\n\x08role_arn\x18\x01 \x01(\t\x12\x16\n\x0esession_policy\x18\x02 \x01(\t\"\"\n\x10PlatformIdentity\x12\x0e\n\x06scopes\x18\x01 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x97\x01\n\x0cPolicyConfig\x12\'\n\x05roles\x18\x01 \x03(\x0b\x32\x18.agentmesh.v1.PolicyRole\x12-\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x1b.agentmesh.v1.PolicyBinding\x12/\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"L\n\x19\x45gressAssignmentsResponse\x12/\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"k\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\x12\x0b\n\x03jwt\x18\x04 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\"@\n\x11NodeCatalogReport\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xa9\x01\n\x1b\x42ootstrapTokenCreateRequest\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x10\n\x08owner_id\x18\x02 \x01(\t\x12\x11\n\tttl_hours\x18\x03 \x01(\x05\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x05 \x01(\t\x12\x1b\n\x13\x61utonomous_recovery\x18\x06 \x01(\x08\x12\x11\n\tephemeral\x18\x07 \x01(\x08\"\x8a\x01\n\x1c\x42ootstrapTokenCreateResponse\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05token\x18\x02 \x01(\t\x12\x0c\n\x04role\x18\x03 \x01(\t\x12\x10\n\x08owner_id\x18\x04 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xbe\x02\n\x0e\x42ootstrapToken\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x10\n\x08owner_id\x18\x03 \x01(\t\x12\x12\n\nmax_usages\x18\x04 \x01(\x05\x12\x14\n\x0cusages_count\x18\x05 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x06 \x01(\t\x12/\n\x0b\x63reate_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0brevoke_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x12\x11\n\tephemeral\x18\x0b \x01(\x08\"J\n\x1a\x42ootstrapTokenListResponse\x12,\n\x06tokens\x18\x01 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\"\xd6\x02\n\x11\x45nrollmentRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x10\n\x08token_id\x18\x03 \x01(\t\x12.\n\x06status\x18\x04 \x01(\x0e\x32\x1e.agentmesh.v1.EnrollmentStatus\x12;\n\x06labels\x18\x05 \x03(\x0b\x32+.agentmesh.v1.EnrollmentRequest.LabelsEntry\x12/\n\x0b\x63reate_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x30\n\x0cresolve_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x13\n\x0bresolved_by\x18\x08 \x01(\t\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"R\n\x1d\x45nrollmentRequestListResponse\x12\x31\n\x08requests\x18\x01 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\"p\n\x04User\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0e\n\x06issuer\x18\x02 \x01(\t\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12\x0c\n\x04role\x18\x04 \x01(\t\x12/\n\x0b\x63reate_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xf6\x02\n\x0c\x45nrolledNode\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x17\n\x0f\x65nrollment_type\x18\x03 \x01(\t\x12\x13\n\x0b\x63laims_json\x18\x04 \x01(\t\x12\x10\n\x08owner_id\x18\x05 \x01(\t\x12\x36\n\x06labels\x18\x06 \x03(\x0b\x32&.agentmesh.v1.EnrolledNode.LabelsEntry\x12/\n\x0b\x65nroll_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0e\n\x06\x62\x61nned\x18\t \x01(\x08\x12\x1b\n\x13\x61utonomous_recovery\x18\n \x01(\x08\x12\x11\n\tephemeral\x18\x0b \x01(\x08\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xd9\x01\n\x0bRouterLease\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x35\n\x11last_renewal_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x17\n\x0f\x63onnected_peers\x18\x05 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x06 \x01(\x05\x12\x13\n\x0bingress_url\x18\x07 \x01(\t\"l\n\x0cNodeServices\x12+\n\x08services\x18\x01 \x03(\x0b\x32\x19.agentmesh.v1.ServiceInfo\x12/\n\x0breport_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdb\x03\n\x13\x41\x64minStatusResponse\x12!\n\x05users\x18\x01 \x03(\x0b\x32\x12.agentmesh.v1.User\x12\x31\n\x0e\x61\x63tive_routers\x18\x02 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12\x32\n\x0e\x65nrolled_nodes\x18\x03 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12<\n\x13\x65nrollment_requests\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.EnrollmentRequest\x12\x36\n\x10\x62ootstrap_tokens\x18\x05 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12*\n\x06policy\x18\x06 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\x12H\n\x0cnode_catalog\x18\x07 \x03(\x0b\x32\x32.agentmesh.v1.AdminStatusResponse.NodeCatalogEntry\x1aN\n\x10NodeCatalogEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12)\n\x05value\x18\x02 \x01(\x0b\x32\x1a.agentmesh.v1.NodeServices:\x02\x38\x01\"\x81\x02\n\x12UserStatusResponse\x12 \n\x04user\x18\x01 \x01(\x0b\x32\x12.agentmesh.v1.User\x12\x32\n\x0e\x65nrolled_nodes\x18\x02 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x36\n\x10\x62ootstrap_tokens\x18\x03 \x03(\x0b\x32\x1c.agentmesh.v1.BootstrapToken\x12\x31\n\x0e\x61\x63tive_routers\x18\x04 \x03(\x0b\x32\x19.agentmesh.v1.RouterLease\x12*\n\x06policy\x18\x05 \x01(\x0b\x32\x1a.agentmesh.v1.PolicyConfig\"V\n\x10NodeListResponse\x12)\n\x05nodes\x18\x01 \x03(\x0b\x32\x1a.agentmesh.v1.EnrolledNode\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc6\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12>\n\x06labels\x18\x05 \x03(\x0b\x32..agentmesh.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8c\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x35\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x1f.agentmesh.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x19.agentmesh.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t\"\x93\x01\n\x15TaskAuthorizationRule\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0c\x64isplay_name\x18\x02 \x01(\t\x12%\n\x05rules\x18\x03 \x03(\x0b\x32\x16.agentmesh.v1.TaskRule\x12/\n\x0b\x65xpire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x84\x01\n\x08TaskRule\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x18\n\x10\x61llowed_services\x18\x02 \x03(\t\x12.\n\toperation\x18\x03 \x01(\x0b\x32\x1b.agentmesh.v1.TaskOperation\x12\x19\n\x11\x61llowed_resources\x18\x04 \x03(\t\"s\n\rTaskOperation\x12\x15\n\rallowed_tools\x18\x01 \x03(\t\x12\x17\n\x0f\x61llowed_methods\x18\x02 \x03(\t\x12\x15\n\rallowed_paths\x18\x03 \x03(\t\x12\x1b\n\x13\x61llowed_permissions\x18\x04 \x03(\t\"\xab\x01\n\x14TokenExchangeRequest\x12\x15\n\rsubject_token\x18\x01 \x01(\t\x12\x36\n\ttask_rule\x18\x02 \x01(\x0b\x32#.agentmesh.v1.TaskAuthorizationRule\x12\x0c\n\x04seal\x18\x03 \x01(\x08\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x7f\n\x15TokenExchangeResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x0f\n\x07subject\x18\x04 \x01(\t\"\x81\x01\n\x0fSTSTokenRequest\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x19\n\x11\x63hallenge_unix_ms\x18\x04 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x05 \x01(\x0c\"\x83\x01\n\x10STSTokenResponse\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07subject\x18\x03 \x01(\t\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x11\n\ttask_name\x18\x05 \x01(\t\"F\n\x13RevocationsResponse\x12\x16\n\x0erevocation_ids\x18\x01 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x02 \x03(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04*s\n\x0fIngressAuthMode\x12!\n\x1dINGRESS_AUTH_MODE_UNSPECIFIED\x10\x00\x12\x1a\n\x16INGRESS_AUTH_MODE_EDGE\x10\x01\x12!\n\x1dINGRESS_AUTH_MODE_PASSTHROUGH\x10\x02*7\n\nEgressMode\x12\x14\n\x10\x45GRESS_MODE_HTTP\x10\x00\x12\x13\n\x0f\x45GRESS_MODE_TCP\x10\x01*\\\n\x12ResponseInspection\x12 \n\x1cRESPONSE_INSPECTION_BUFFERED\x10\x00\x12$\n RESPONSE_INSPECTION_REQUEST_ONLY\x10\x01\x42!Z\x1fgithub.com/google/agentmesh/apib\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'agentmesh_pb2', globals()) @@ -39,14 +39,16 @@ _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_options = b'8\001' _PEEREVIDENCERESPONSE_LABELSENTRY._options = None _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_options = b'8\001' - _ENROLLMENTSTATUS._serialized_start=11623 - _ENROLLMENTSTATUS._serialized_end=11771 - _SERVICETYPE._serialized_start=11774 - _SERVICETYPE._serialized_end=11914 - _EGRESSMODE._serialized_start=11916 - _EGRESSMODE._serialized_end=11971 - _RESPONSEINSPECTION._serialized_start=11973 - _RESPONSEINSPECTION._serialized_end=12065 + _ENROLLMENTSTATUS._serialized_start=12058 + _ENROLLMENTSTATUS._serialized_end=12206 + _SERVICETYPE._serialized_start=12209 + _SERVICETYPE._serialized_end=12349 + _INGRESSAUTHMODE._serialized_start=12351 + _INGRESSAUTHMODE._serialized_end=12466 + _EGRESSMODE._serialized_start=12468 + _EGRESSMODE._serialized_end=12523 + _RESPONSEINSPECTION._serialized_start=12525 + _RESPONSEINSPECTION._serialized_end=12617 _AUTHFRAME._serialized_start=98 _AUTHFRAME._serialized_end=150 _AUTHRESPONSE._serialized_start=152 @@ -71,154 +73,158 @@ _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_end=870 _BOOTSTRAPENROLLRESPONSE._serialized_start=1329 _BOOTSTRAPENROLLRESPONSE._serialized_end=1588 - _SERVICEINFO._serialized_start=1590 - _SERVICEINFO._serialized_end=1679 - _COMMANDBACKEND._serialized_start=1682 - _COMMANDBACKEND._serialized_end=1811 - _COMMANDBACKEND_ENVENTRY._serialized_start=1769 - _COMMANDBACKEND_ENVENTRY._serialized_end=1811 - _REGISTERSERVICEREQUEST._serialized_start=1814 - _REGISTERSERVICEREQUEST._serialized_end=1964 - _DISCOVEREDPROVIDER._serialized_start=1966 - _DISCOVEREDPROVIDER._serialized_end=2071 - _CONTROLPLANEINFORESPONSE._serialized_start=2074 - _CONTROLPLANEINFORESPONSE._serialized_end=2306 - _DIRECTORYPARAMETERS._serialized_start=2309 - _DIRECTORYPARAMETERS._serialized_end=2453 - _DIRECTORYREQUEST._serialized_start=2456 - _DIRECTORYREQUEST._serialized_end=2707 - _DIRECTORYPROVIDE._serialized_start=2709 - _DIRECTORYPROVIDE._serialized_end=2790 - _DIRECTORYWITHDRAW._serialized_start=2792 - _DIRECTORYWITHDRAW._serialized_end=2842 - _DIRECTORYFINDPROVIDERS._serialized_start=2844 - _DIRECTORYFINDPROVIDERS._serialized_end=2896 - _DIRECTORYFINDPEER._serialized_start=2898 - _DIRECTORYFINDPEER._serialized_end=2934 - _DIRECTORYRESPONSE._serialized_start=2936 - _DIRECTORYRESPONSE._serialized_end=3055 - _PEERRECORD._serialized_start=3057 - _PEERRECORD._serialized_end=3101 - _ROUTERINFO._serialized_start=3104 - _ROUTERINFO._serialized_end=3300 + _SERVICEINGRESS._serialized_start=1590 + _SERVICEINGRESS._serialized_end=1671 + _SERVICEINFO._serialized_start=1674 + _SERVICEINFO._serialized_end=1810 + _COMMANDBACKEND._serialized_start=1813 + _COMMANDBACKEND._serialized_end=1942 + _COMMANDBACKEND_ENVENTRY._serialized_start=1900 + _COMMANDBACKEND_ENVENTRY._serialized_end=1942 + _REGISTERSERVICEREQUEST._serialized_start=1945 + _REGISTERSERVICEREQUEST._serialized_end=2095 + _DISCOVEREDPROVIDER._serialized_start=2097 + _DISCOVEREDPROVIDER._serialized_end=2202 + _CONTROLPLANEINFORESPONSE._serialized_start=2205 + _CONTROLPLANEINFORESPONSE._serialized_end=2437 + _DIRECTORYPARAMETERS._serialized_start=2440 + _DIRECTORYPARAMETERS._serialized_end=2584 + _DIRECTORYREQUEST._serialized_start=2587 + _DIRECTORYREQUEST._serialized_end=2838 + _DIRECTORYPROVIDE._serialized_start=2841 + _DIRECTORYPROVIDE._serialized_end=2969 + _DIRECTORYWITHDRAW._serialized_start=2971 + _DIRECTORYWITHDRAW._serialized_end=3021 + _DIRECTORYFINDPROVIDERS._serialized_start=3023 + _DIRECTORYFINDPROVIDERS._serialized_end=3075 + _DIRECTORYFINDPEER._serialized_start=3077 + _DIRECTORYFINDPEER._serialized_end=3113 + _DIRECTORYRESPONSE._serialized_start=3115 + _DIRECTORYRESPONSE._serialized_end=3234 + _PEERRECORD._serialized_start=3236 + _PEERRECORD._serialized_end=3327 + _ROUTERINFO._serialized_start=3330 + _ROUTERINFO._serialized_end=3547 _ROUTERINFO_LABELSENTRY._serialized_start=825 _ROUTERINFO_LABELSENTRY._serialized_end=870 - _ROUTERLEASEREQUEST._serialized_start=3303 - _ROUTERLEASEREQUEST._serialized_end=3562 - _ROUTERLEASERESPONSE._serialized_start=3564 - _ROUTERLEASERESPONSE._serialized_end=3666 - _POLICYROLE._serialized_start=3669 - _POLICYROLE._serialized_end=3858 - _HTTPGRANT._serialized_start=3860 - _HTTPGRANT._serialized_end=3920 - _EGRESSDESTINATION._serialized_start=3923 - _EGRESSDESTINATION._serialized_end=4212 - _INSPECTION._serialized_start=4214 - _INSPECTION._serialized_end=4271 - _INSPECTOR._serialized_start=4273 - _INSPECTOR._serialized_end=4384 - _MODELARMOR._serialized_start=4387 - _MODELARMOR._serialized_end=4532 - _EXTPROC._serialized_start=4535 - _EXTPROC._serialized_end=4799 - _EXTPROCPROCESSINGMODE._serialized_start=4802 - _EXTPROCPROCESSINGMODE._serialized_end=5441 - _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_start=5286 - _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_end=5343 - _EXTPROCPROCESSINGMODE_BODYMODE._serialized_start=5345 - _EXTPROCPROCESSINGMODE_BODYMODE._serialized_end=5441 - _CREDENTIALBROKER._serialized_start=5444 - _CREDENTIALBROKER._serialized_end=5669 - _OIDCFEDERATION._serialized_start=5671 - _OIDCFEDERATION._serialized_end=5766 - _AWSASSUMEROLE._serialized_start=5768 - _AWSASSUMEROLE._serialized_end=5825 - _PLATFORMIDENTITY._serialized_start=5827 - _PLATFORMIDENTITY._serialized_end=5861 - _POLICYBINDING._serialized_start=5863 - _POLICYBINDING._serialized_end=5909 - _POLICYCONFIG._serialized_start=5912 - _POLICYCONFIG._serialized_end=6063 - _POLICYCONFIGGETREQUEST._serialized_start=6065 - _POLICYCONFIGGETREQUEST._serialized_end=6089 - _POLICYCONFIGGETRESPONSE._serialized_start=6091 - _POLICYCONFIGGETRESPONSE._serialized_end=6139 - _POLICYCONFIGUPDATERESPONSE._serialized_start=6141 - _POLICYCONFIGUPDATERESPONSE._serialized_end=6201 - _EGRESSASSIGNMENTSREQUEST._serialized_start=6203 - _EGRESSASSIGNMENTSREQUEST._serialized_end=6229 - _EGRESSASSIGNMENTSRESPONSE._serialized_start=6231 - _EGRESSASSIGNMENTSRESPONSE._serialized_end=6307 - _KEYSRESPONSE._serialized_start=6309 - _KEYSRESPONSE._serialized_end=6411 - _TOKENREFRESHREQUEST._serialized_start=6413 - _TOKENREFRESHREQUEST._serialized_end=6520 - _TOKENREFRESHRESPONSE._serialized_start=6522 - _TOKENREFRESHRESPONSE._serialized_end=6639 - _NODECATALOGREPORT._serialized_start=6641 - _NODECATALOGREPORT._serialized_end=6705 - _TOKENREVOKEREQUEST._serialized_start=6707 - _TOKENREVOKEREQUEST._serialized_end=6744 - _TOKENREVOKERESPONSE._serialized_start=6746 - _TOKENREVOKERESPONSE._serialized_end=6799 - _BOOTSTRAPTOKENCREATEREQUEST._serialized_start=6802 - _BOOTSTRAPTOKENCREATEREQUEST._serialized_end=6971 - _BOOTSTRAPTOKENCREATERESPONSE._serialized_start=6974 - _BOOTSTRAPTOKENCREATERESPONSE._serialized_end=7112 - _BOOTSTRAPTOKEN._serialized_start=7115 - _BOOTSTRAPTOKEN._serialized_end=7433 - _BOOTSTRAPTOKENLISTRESPONSE._serialized_start=7435 - _BOOTSTRAPTOKENLISTRESPONSE._serialized_end=7509 - _ENROLLMENTREQUEST._serialized_start=7512 - _ENROLLMENTREQUEST._serialized_end=7854 + _ROUTERLEASEREQUEST._serialized_start=3550 + _ROUTERLEASEREQUEST._serialized_end=3830 + _ROUTERLEASERESPONSE._serialized_start=3832 + _ROUTERLEASERESPONSE._serialized_end=3934 + _POLICYROLE._serialized_start=3937 + _POLICYROLE._serialized_end=4179 + _INGRESSGRANT._serialized_start=4181 + _INGRESSGRANT._serialized_end=4272 + _HTTPGRANT._serialized_start=4274 + _HTTPGRANT._serialized_end=4334 + _EGRESSDESTINATION._serialized_start=4337 + _EGRESSDESTINATION._serialized_end=4626 + _INSPECTION._serialized_start=4628 + _INSPECTION._serialized_end=4685 + _INSPECTOR._serialized_start=4687 + _INSPECTOR._serialized_end=4798 + _MODELARMOR._serialized_start=4801 + _MODELARMOR._serialized_end=4946 + _EXTPROC._serialized_start=4949 + _EXTPROC._serialized_end=5213 + _EXTPROCPROCESSINGMODE._serialized_start=5216 + _EXTPROCPROCESSINGMODE._serialized_end=5855 + _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_start=5700 + _EXTPROCPROCESSINGMODE_HEADERMODE._serialized_end=5757 + _EXTPROCPROCESSINGMODE_BODYMODE._serialized_start=5759 + _EXTPROCPROCESSINGMODE_BODYMODE._serialized_end=5855 + _CREDENTIALBROKER._serialized_start=5858 + _CREDENTIALBROKER._serialized_end=6083 + _OIDCFEDERATION._serialized_start=6085 + _OIDCFEDERATION._serialized_end=6180 + _AWSASSUMEROLE._serialized_start=6182 + _AWSASSUMEROLE._serialized_end=6239 + _PLATFORMIDENTITY._serialized_start=6241 + _PLATFORMIDENTITY._serialized_end=6275 + _POLICYBINDING._serialized_start=6277 + _POLICYBINDING._serialized_end=6323 + _POLICYCONFIG._serialized_start=6326 + _POLICYCONFIG._serialized_end=6477 + _POLICYCONFIGGETREQUEST._serialized_start=6479 + _POLICYCONFIGGETREQUEST._serialized_end=6503 + _POLICYCONFIGGETRESPONSE._serialized_start=6505 + _POLICYCONFIGGETRESPONSE._serialized_end=6553 + _POLICYCONFIGUPDATERESPONSE._serialized_start=6555 + _POLICYCONFIGUPDATERESPONSE._serialized_end=6615 + _EGRESSASSIGNMENTSREQUEST._serialized_start=6617 + _EGRESSASSIGNMENTSREQUEST._serialized_end=6643 + _EGRESSASSIGNMENTSRESPONSE._serialized_start=6645 + _EGRESSASSIGNMENTSRESPONSE._serialized_end=6721 + _KEYSRESPONSE._serialized_start=6723 + _KEYSRESPONSE._serialized_end=6825 + _TOKENREFRESHREQUEST._serialized_start=6827 + _TOKENREFRESHREQUEST._serialized_end=6934 + _TOKENREFRESHRESPONSE._serialized_start=6936 + _TOKENREFRESHRESPONSE._serialized_end=7053 + _NODECATALOGREPORT._serialized_start=7055 + _NODECATALOGREPORT._serialized_end=7119 + _TOKENREVOKEREQUEST._serialized_start=7121 + _TOKENREVOKEREQUEST._serialized_end=7158 + _TOKENREVOKERESPONSE._serialized_start=7160 + _TOKENREVOKERESPONSE._serialized_end=7213 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_start=7216 + _BOOTSTRAPTOKENCREATEREQUEST._serialized_end=7385 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_start=7388 + _BOOTSTRAPTOKENCREATERESPONSE._serialized_end=7526 + _BOOTSTRAPTOKEN._serialized_start=7529 + _BOOTSTRAPTOKEN._serialized_end=7847 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_start=7849 + _BOOTSTRAPTOKENLISTRESPONSE._serialized_end=7923 + _ENROLLMENTREQUEST._serialized_start=7926 + _ENROLLMENTREQUEST._serialized_end=8268 _ENROLLMENTREQUEST_LABELSENTRY._serialized_start=825 _ENROLLMENTREQUEST_LABELSENTRY._serialized_end=870 - _ENROLLMENTREQUESTLISTRESPONSE._serialized_start=7856 - _ENROLLMENTREQUESTLISTRESPONSE._serialized_end=7938 - _USER._serialized_start=7940 - _USER._serialized_end=8052 - _ENROLLEDNODE._serialized_start=8055 - _ENROLLEDNODE._serialized_end=8429 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_start=8270 + _ENROLLMENTREQUESTLISTRESPONSE._serialized_end=8352 + _USER._serialized_start=8354 + _USER._serialized_end=8466 + _ENROLLEDNODE._serialized_start=8469 + _ENROLLEDNODE._serialized_end=8843 _ENROLLEDNODE_LABELSENTRY._serialized_start=825 _ENROLLEDNODE_LABELSENTRY._serialized_end=870 - _ROUTERLEASE._serialized_start=8432 - _ROUTERLEASE._serialized_end=8628 - _NODESERVICES._serialized_start=8630 - _NODESERVICES._serialized_end=8738 - _ADMINSTATUSRESPONSE._serialized_start=8741 - _ADMINSTATUSRESPONSE._serialized_end=9216 - _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_start=9138 - _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_end=9216 - _USERSTATUSRESPONSE._serialized_start=9219 - _USERSTATUSRESPONSE._serialized_end=9476 - _NODELISTRESPONSE._serialized_start=9478 - _NODELISTRESPONSE._serialized_end=9564 - _IDENTITYEVIDENCERESPONSE._serialized_start=9567 - _IDENTITYEVIDENCERESPONSE._serialized_end=9795 - _PEEREVIDENCERESPONSE._serialized_start=9798 - _PEEREVIDENCERESPONSE._serialized_end=10124 + _ROUTERLEASE._serialized_start=8846 + _ROUTERLEASE._serialized_end=9063 + _NODESERVICES._serialized_start=9065 + _NODESERVICES._serialized_end=9173 + _ADMINSTATUSRESPONSE._serialized_start=9176 + _ADMINSTATUSRESPONSE._serialized_end=9651 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_start=9573 + _ADMINSTATUSRESPONSE_NODECATALOGENTRY._serialized_end=9651 + _USERSTATUSRESPONSE._serialized_start=9654 + _USERSTATUSRESPONSE._serialized_end=9911 + _NODELISTRESPONSE._serialized_start=9913 + _NODELISTRESPONSE._serialized_end=9999 + _IDENTITYEVIDENCERESPONSE._serialized_start=10002 + _IDENTITYEVIDENCERESPONSE._serialized_end=10230 + _PEEREVIDENCERESPONSE._serialized_start=10233 + _PEEREVIDENCERESPONSE._serialized_end=10559 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=825 _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=870 - _MEMBERCREDENTIAL._serialized_start=10127 - _MEMBERCREDENTIAL._serialized_end=10395 - _TRUSTEDSIGNINGKEY._serialized_start=10397 - _TRUSTEDSIGNINGKEY._serialized_end=10486 - _OIDCSESSION._serialized_start=10488 - _OIDCSESSION._serialized_end=10577 - _TASKAUTHORIZATIONRULE._serialized_start=10580 - _TASKAUTHORIZATIONRULE._serialized_end=10727 - _TASKRULE._serialized_start=10730 - _TASKRULE._serialized_end=10862 - _TASKOPERATION._serialized_start=10864 - _TASKOPERATION._serialized_end=10979 - _TOKENEXCHANGEREQUEST._serialized_start=10982 - _TOKENEXCHANGEREQUEST._serialized_end=11153 - _TOKENEXCHANGERESPONSE._serialized_start=11155 - _TOKENEXCHANGERESPONSE._serialized_end=11282 - _STSTOKENREQUEST._serialized_start=11285 - _STSTOKENREQUEST._serialized_end=11414 - _STSTOKENRESPONSE._serialized_start=11417 - _STSTOKENRESPONSE._serialized_end=11548 - _REVOCATIONSRESPONSE._serialized_start=11550 - _REVOCATIONSRESPONSE._serialized_end=11620 + _MEMBERCREDENTIAL._serialized_start=10562 + _MEMBERCREDENTIAL._serialized_end=10830 + _TRUSTEDSIGNINGKEY._serialized_start=10832 + _TRUSTEDSIGNINGKEY._serialized_end=10921 + _OIDCSESSION._serialized_start=10923 + _OIDCSESSION._serialized_end=11012 + _TASKAUTHORIZATIONRULE._serialized_start=11015 + _TASKAUTHORIZATIONRULE._serialized_end=11162 + _TASKRULE._serialized_start=11165 + _TASKRULE._serialized_end=11297 + _TASKOPERATION._serialized_start=11299 + _TASKOPERATION._serialized_end=11414 + _TOKENEXCHANGEREQUEST._serialized_start=11417 + _TOKENEXCHANGEREQUEST._serialized_end=11588 + _TOKENEXCHANGERESPONSE._serialized_start=11590 + _TOKENEXCHANGERESPONSE._serialized_end=11717 + _STSTOKENREQUEST._serialized_start=11720 + _STSTOKENREQUEST._serialized_end=11849 + _STSTOKENRESPONSE._serialized_start=11852 + _STSTOKENRESPONSE._serialized_end=11983 + _REVOCATIONSRESPONSE._serialized_start=11985 + _REVOCATIONSRESPONSE._serialized_end=12055 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi index 865d7aaf..2cc8fbfe 100644 --- a/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi +++ b/sdk/python/src/agent_mesh/_proto/agentmesh_pb2.pyi @@ -13,6 +13,9 @@ ENROLLMENT_STATUS_APPROVED: EnrollmentStatus ENROLLMENT_STATUS_PENDING: EnrollmentStatus ENROLLMENT_STATUS_REJECTED: EnrollmentStatus ENROLLMENT_STATUS_UNSPECIFIED: EnrollmentStatus +INGRESS_AUTH_MODE_EDGE: IngressAuthMode +INGRESS_AUTH_MODE_PASSTHROUGH: IngressAuthMode +INGRESS_AUTH_MODE_UNSPECIFIED: IngressAuthMode RESPONSE_INSPECTION_BUFFERED: ResponseInspection RESPONSE_INSPECTION_REQUEST_ONLY: ResponseInspection SERVICE_TYPE_A2A: ServiceType @@ -249,16 +252,18 @@ class DirectoryParameters(_message.Message): def __init__(self, replication: _Optional[int] = ..., reprovide_interval: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., record_ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ... class DirectoryProvide(_message.Message): - __slots__ = ["addrs", "key", "provider", "service"] + __slots__ = ["addrs", "ingress", "key", "provider", "service"] ADDRS_FIELD_NUMBER: _ClassVar[int] + INGRESS_FIELD_NUMBER: _ClassVar[int] KEY_FIELD_NUMBER: _ClassVar[int] PROVIDER_FIELD_NUMBER: _ClassVar[int] SERVICE_FIELD_NUMBER: _ClassVar[int] addrs: _containers.RepeatedScalarFieldContainer[str] + ingress: ServiceIngress key: bytes provider: str service: str - def __init__(self, key: _Optional[bytes] = ..., addrs: _Optional[_Iterable[str]] = ..., provider: _Optional[str] = ..., service: _Optional[str] = ...) -> None: ... + def __init__(self, key: _Optional[bytes] = ..., addrs: _Optional[_Iterable[str]] = ..., provider: _Optional[str] = ..., service: _Optional[str] = ..., ingress: _Optional[_Union[ServiceIngress, _Mapping]] = ...) -> None: ... class DirectoryRequest(_message.Message): __slots__ = ["find_peer", "find_providers", "provide", "withdraw"] @@ -515,6 +520,14 @@ class IdentityEvidenceResponse(_message.Message): trusted_control_plane_keys: _containers.RepeatedScalarFieldContainer[bytes] def __init__(self, peer_id: _Optional[str] = ..., biscuit: _Optional[bytes] = ..., biscuit_expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., control_plane_url: _Optional[str] = ..., trusted_control_plane_keys: _Optional[_Iterable[bytes]] = ..., check_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... +class IngressGrant(_message.Message): + __slots__ = ["allowed_auth_modes", "services"] + ALLOWED_AUTH_MODES_FIELD_NUMBER: _ClassVar[int] + SERVICES_FIELD_NUMBER: _ClassVar[int] + allowed_auth_modes: _containers.RepeatedScalarFieldContainer[IngressAuthMode] + services: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, services: _Optional[_Iterable[str]] = ..., allowed_auth_modes: _Optional[_Iterable[_Union[IngressAuthMode, str]]] = ...) -> None: ... + class Inspection(_message.Message): __slots__ = ["inspectors"] INSPECTORS_FIELD_NUMBER: _ClassVar[int] @@ -662,12 +675,14 @@ class PeerEvidenceResponse(_message.Message): def __init__(self, peer_id: _Optional[str] = ..., biscuit: _Optional[bytes] = ..., verifying_key: _Optional[bytes] = ..., roles: _Optional[_Iterable[str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., revocation_ids: _Optional[_Iterable[str]] = ..., check_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... class PeerRecord(_message.Message): - __slots__ = ["addrs", "peer_id"] + __slots__ = ["addrs", "ingress", "peer_id"] ADDRS_FIELD_NUMBER: _ClassVar[int] + INGRESS_FIELD_NUMBER: _ClassVar[int] PEER_ID_FIELD_NUMBER: _ClassVar[int] addrs: _containers.RepeatedScalarFieldContainer[str] + ingress: ServiceIngress peer_id: str - def __init__(self, peer_id: _Optional[str] = ..., addrs: _Optional[_Iterable[str]] = ...) -> None: ... + def __init__(self, peer_id: _Optional[str] = ..., addrs: _Optional[_Iterable[str]] = ..., ingress: _Optional[_Union[ServiceIngress, _Mapping]] = ...) -> None: ... class PlatformIdentity(_message.Message): __slots__ = ["scopes"] @@ -712,7 +727,8 @@ class PolicyConfigUpdateResponse(_message.Message): def __init__(self, success: bool = ..., error: _Optional[str] = ...) -> None: ... class PolicyRole(_message.Message): - __slots__ = ["allowed_labels", "allowed_publish", "allowed_services", "allowed_targets", "custom_datalog", "http", "name"] + __slots__ = ["allowed_ingress", "allowed_labels", "allowed_publish", "allowed_services", "allowed_targets", "custom_datalog", "http", "name"] + ALLOWED_INGRESS_FIELD_NUMBER: _ClassVar[int] ALLOWED_LABELS_FIELD_NUMBER: _ClassVar[int] ALLOWED_PUBLISH_FIELD_NUMBER: _ClassVar[int] ALLOWED_SERVICES_FIELD_NUMBER: _ClassVar[int] @@ -720,6 +736,7 @@ class PolicyRole(_message.Message): CUSTOM_DATALOG_FIELD_NUMBER: _ClassVar[int] HTTP_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] + allowed_ingress: _containers.RepeatedCompositeFieldContainer[IngressGrant] allowed_labels: _containers.RepeatedScalarFieldContainer[str] allowed_publish: _containers.RepeatedScalarFieldContainer[str] allowed_services: _containers.RepeatedScalarFieldContainer[str] @@ -727,7 +744,7 @@ class PolicyRole(_message.Message): custom_datalog: _containers.RepeatedScalarFieldContainer[str] http: _containers.RepeatedCompositeFieldContainer[HTTPGrant] name: str - def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ..., http: _Optional[_Iterable[_Union[HTTPGrant, _Mapping]]] = ..., allowed_publish: _Optional[_Iterable[str]] = ...) -> None: ... + def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ..., http: _Optional[_Iterable[_Union[HTTPGrant, _Mapping]]] = ..., allowed_publish: _Optional[_Iterable[str]] = ..., allowed_ingress: _Optional[_Iterable[_Union[IngressGrant, _Mapping]]] = ...) -> None: ... class RegisterServiceRequest(_message.Message): __slots__ = ["command", "service", "target_url"] @@ -761,7 +778,7 @@ class RouterGoAway(_message.Message): def __init__(self, reason: _Optional[_Union[RouterGoAway.Reason, str]] = ..., retry_after: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ... class RouterInfo(_message.Message): - __slots__ = ["addresses", "connection_limit", "connections", "labels", "peer_id"] + __slots__ = ["addresses", "connection_limit", "connections", "ingress_url", "labels", "peer_id"] class LabelsEntry(_message.Message): __slots__ = ["key", "value"] KEY_FIELD_NUMBER: _ClassVar[int] @@ -772,33 +789,37 @@ class RouterInfo(_message.Message): ADDRESSES_FIELD_NUMBER: _ClassVar[int] CONNECTIONS_FIELD_NUMBER: _ClassVar[int] CONNECTION_LIMIT_FIELD_NUMBER: _ClassVar[int] + INGRESS_URL_FIELD_NUMBER: _ClassVar[int] LABELS_FIELD_NUMBER: _ClassVar[int] PEER_ID_FIELD_NUMBER: _ClassVar[int] addresses: _containers.RepeatedScalarFieldContainer[str] connection_limit: int connections: int + ingress_url: str labels: _containers.ScalarMap[str, str] peer_id: str - def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., connections: _Optional[int] = ..., connection_limit: _Optional[int] = ...) -> None: ... + def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., labels: _Optional[_Mapping[str, str]] = ..., connections: _Optional[int] = ..., connection_limit: _Optional[int] = ..., ingress_url: _Optional[str] = ...) -> None: ... class RouterLease(_message.Message): - __slots__ = ["addresses", "connected_peers", "dht_size", "expire_time", "last_renewal_time", "peer_id"] + __slots__ = ["addresses", "connected_peers", "dht_size", "expire_time", "ingress_url", "last_renewal_time", "peer_id"] ADDRESSES_FIELD_NUMBER: _ClassVar[int] CONNECTED_PEERS_FIELD_NUMBER: _ClassVar[int] DHT_SIZE_FIELD_NUMBER: _ClassVar[int] EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] + INGRESS_URL_FIELD_NUMBER: _ClassVar[int] LAST_RENEWAL_TIME_FIELD_NUMBER: _ClassVar[int] PEER_ID_FIELD_NUMBER: _ClassVar[int] addresses: _containers.RepeatedScalarFieldContainer[str] connected_peers: _containers.RepeatedScalarFieldContainer[str] dht_size: int expire_time: _timestamp_pb2.Timestamp + ingress_url: str last_renewal_time: _timestamp_pb2.Timestamp peer_id: str - def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., last_renewal_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., connected_peers: _Optional[_Iterable[str]] = ..., dht_size: _Optional[int] = ...) -> None: ... + def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., last_renewal_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., connected_peers: _Optional[_Iterable[str]] = ..., dht_size: _Optional[int] = ..., ingress_url: _Optional[str] = ...) -> None: ... class RouterLeaseRequest(_message.Message): - __slots__ = ["addresses", "biscuit", "challenge_signature", "challenge_unix_ms", "connected_peers", "connection_limit", "connections", "dht_size", "peer_id", "ttl"] + __slots__ = ["addresses", "biscuit", "challenge_signature", "challenge_unix_ms", "connected_peers", "connection_limit", "connections", "dht_size", "ingress_url", "peer_id", "ttl"] ADDRESSES_FIELD_NUMBER: _ClassVar[int] BISCUIT_FIELD_NUMBER: _ClassVar[int] CHALLENGE_SIGNATURE_FIELD_NUMBER: _ClassVar[int] @@ -807,6 +828,7 @@ class RouterLeaseRequest(_message.Message): CONNECTIONS_FIELD_NUMBER: _ClassVar[int] CONNECTION_LIMIT_FIELD_NUMBER: _ClassVar[int] DHT_SIZE_FIELD_NUMBER: _ClassVar[int] + INGRESS_URL_FIELD_NUMBER: _ClassVar[int] PEER_ID_FIELD_NUMBER: _ClassVar[int] TTL_FIELD_NUMBER: _ClassVar[int] addresses: _containers.RepeatedScalarFieldContainer[str] @@ -817,9 +839,10 @@ class RouterLeaseRequest(_message.Message): connection_limit: int connections: int dht_size: int + ingress_url: str peer_id: str ttl: _duration_pb2.Duration - def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., biscuit: _Optional[bytes] = ..., connected_peers: _Optional[_Iterable[str]] = ..., dht_size: _Optional[int] = ..., challenge_unix_ms: _Optional[int] = ..., challenge_signature: _Optional[bytes] = ..., ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., connections: _Optional[int] = ..., connection_limit: _Optional[int] = ...) -> None: ... + def __init__(self, peer_id: _Optional[str] = ..., addresses: _Optional[_Iterable[str]] = ..., biscuit: _Optional[bytes] = ..., connected_peers: _Optional[_Iterable[str]] = ..., dht_size: _Optional[int] = ..., challenge_unix_ms: _Optional[int] = ..., challenge_signature: _Optional[bytes] = ..., ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., connections: _Optional[int] = ..., connection_limit: _Optional[int] = ..., ingress_url: _Optional[str] = ...) -> None: ... class RouterLeaseResponse(_message.Message): __slots__ = ["error", "expire_time", "success"] @@ -860,14 +883,24 @@ class STSTokenResponse(_message.Message): def __init__(self, jwt: _Optional[str] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., subject: _Optional[str] = ..., roles: _Optional[_Iterable[str]] = ..., task_name: _Optional[str] = ...) -> None: ... class ServiceInfo(_message.Message): - __slots__ = ["description", "name", "type"] + __slots__ = ["description", "ingress", "name", "type"] DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + INGRESS_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] TYPE_FIELD_NUMBER: _ClassVar[int] description: str + ingress: ServiceIngress name: str type: ServiceType - def __init__(self, type: _Optional[_Union[ServiceType, str]] = ..., name: _Optional[str] = ..., description: _Optional[str] = ...) -> None: ... + def __init__(self, type: _Optional[_Union[ServiceType, str]] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., ingress: _Optional[_Union[ServiceIngress, _Mapping]] = ...) -> None: ... + +class ServiceIngress(_message.Message): + __slots__ = ["allow", "auth_mode"] + ALLOW_FIELD_NUMBER: _ClassVar[int] + AUTH_MODE_FIELD_NUMBER: _ClassVar[int] + allow: _containers.RepeatedScalarFieldContainer[str] + auth_mode: IngressAuthMode + def __init__(self, auth_mode: _Optional[_Union[IngressAuthMode, str]] = ..., allow: _Optional[_Iterable[str]] = ...) -> None: ... class TaskAuthorizationRule(_message.Message): __slots__ = ["display_name", "expire_time", "name", "rules"] @@ -1009,6 +1042,9 @@ class EnrollmentStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = [] +class IngressAuthMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + class EgressMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = [] diff --git a/site/content/docs/contributing/security-architecture.md b/site/content/docs/contributing/security-architecture.md index 1b5fe9d5..7409c011 100644 --- a/site/content/docs/contributing/security-architecture.md +++ b/site/content/docs/contributing/security-architecture.md @@ -317,7 +317,12 @@ minted statelessly on the control plane with zero database writes: - `user(...)`, `email(...)`, `group(...)`, `idp_role(...)`, and `role(...)` are resolved strictly from the caller's `subject_token`. - No `node()` fact is minted, which prevents `node:` policy bindings on - the origin node from leaking to the delegated caller. + the origin node from leaking to the delegated caller. On router external HTTP + ingress (`/ingress/*` in `edge` mode), `ValidateIngressCallerPreDial` and + `AuthorizeIngressDelegatedBiscuit` reject `mesh:role:router` Biscuits and + unattenuated standing Member Biscuits (`node(...)` with zero `tar_block` + blocks), accepting only Delegated Session Biscuits or task-attenuated + Biscuits. Before handing a task token to an untrusted leaf process or sandbox, the holder calls `Seal()`, discarding the ephemeral next-block key so no further blocks can @@ -326,7 +331,8 @@ be appended. Revocation operates at two levels. Root-prefix revocation (`GET /revocations`) uses `b.RevocationIds()[0]` to identify the authority block, so revoking a root token or banning a peer or user at the control plane invalidates both the root -Biscuit and every offline-attenuated child derived from it across the mesh. +Biscuit and every offline-attenuated child derived from it across the mesh +(enforced by both `agentmesh-node` and `agentmesh-router`). Local task revocation (`POST /oauth/revoke`) lets an orchestrator revoke a specific child Task Biscuit at the local `agentmesh-node` as soon as a task finishes, caching `RevocationIds()[last]` in the node's revocation LRU until the token's @@ -358,7 +364,7 @@ as a standard OIDC issuer: | **libp2p data path** (node-to-node, node-to-router) | Channel binding: Block 0 `client_peer_id` must equal the `connection_peer_id` authenticated by the libp2p Noise or TLS handshake. | The transport private key | None needed; the token is unusable on any other connection. | | **Control plane mesh protocol** (`/enroll`, `/enroll/status`, `/register`, `/refresh`, `/routers/lease`, `/token/exchange`, `/sts/token`) | Ed25519 signature over `mesh:::`, carried in protobuf fields (`challenge_unix_ms`, `challenge_signature`) or `X-Mesh-Challenge-Ts` / `X-Mesh-Challenge-Sig` on `GET /enroll/status`. | Endpoint name, peer ID, timestamp | 5-minute freshness window (`challengeMaxAge`) with one-shot `Date`-header clock-skew recovery on `401`; `/refresh` additionally redeems only the last Biscuit issued to the peer. | | **Control plane read & report endpoints** (`GET /policies`, `GET /egress`, `GET /revocations`, `POST /nodes/catalog`) | Bearer Member Biscuit of an admitted node (`admittedNodeWithChallenge`) plus `X-Mesh-Challenge-Ts` / `X-Mesh-Challenge-Sig` signed over `mesh:::` (`policies`, `egress`, `revocations`, `nodes-catalog`), verified against the node's stored Ed25519 public key via `verifyFreshChallenge`. | Enrolled node credential, endpoint name, peer ID, timestamp | 5-minute freshness window (`challengeMaxAge`) with one-shot `Date`-header clock-skew recovery on `401`, bounded by Biscuit TTL and ban/revocation list. | -| **OAuth 2.1 external client surface** (`/oauth/authorize`, `/oauth/token`, `/mcp` over HTTPS) | Authorization Code + PKCE (`S256`) on the code grant. The `client_peer_id` of the minted Biscuit is the calling node's when a node calls, otherwise the `actor_peer_id` form parameter or the control plane's own peer ID (`resolveDefaultActorPeer`). | A claimed peer ID (the Biscuit is a bearer token on the HTTPS `/mcp` hop unless sender-constrained with DPoP; see §5.2) | Single-use authorization codes with PKCE verifier check on the code exchange; none on the resulting bearer Biscuit. | +| **OAuth 2.1 & external HTTP ingress surface** (`/oauth/authorize`, `/oauth/token`, node `/mcp` and router `/ingress/*` over HTTPS) | Authorization Code + PKCE (`S256`) on the code grant, or RFC 8693 token exchange (`POST /oauth/token`). External HTTPS callers have no libp2p connection, so `BaselineReplayCheck` (`client_peer_id == connection_peer_id`) is omitted on `/mcp` and `/ingress/*`; instead `/ingress/*` (`edge` mode) rejects `mesh:role:router` and unattenuated standing Member Biscuits, verifies signature, revocations, `tar_block`, and `ServiceIngress.allow` at the router before dialing any provider, and re-verifies end-to-end at the serving node over `/mesh/ingress/1.0.0`. | A claimed peer ID (the Biscuit is a bearer token on the HTTPS `/mcp` or `/ingress/*` hop unless sender-constrained with DPoP on `/mcp`; see §5.2) | Single-use authorization codes with PKCE verifier check on the code exchange; authority-block revocation (`GET /revocations`) and short TTL on the resulting bearer Biscuit. | --- @@ -526,10 +532,13 @@ redemption of the last Biscuit issued to the peer. ### 5.2 Optional DPoP (RFC 9449) at the OAuth 2.1 border Inside the mesh, libp2p channel binding (`client_peer_id == connection_peer_id`) -provides per-connection proof of possession between nodes. An external MCP -client, however, reaches a node's `/mcp` ingress over HTTPS with no libp2p -channel, so the Biscuit minted for it at `/oauth/token` carries a claimed -`client_peer_id` and is a bearer token on that HTTPS hop. +provides per-connection proof of possession between nodes. An external HTTP +client, however, reaches a node's `/mcp` ingress or a router's `/ingress/*` +(`edge` mode) over HTTPS with no libp2p channel, so `BaselineReplayCheck` +cannot bind the request to a libp2p stream; the Biscuit minted at +`/oauth/token` carries a claimed `client_peer_id` and acts as a bearer token on +that HTTPS hop (with `mesh:role:router` and unattenuated standing Member +Biscuits explicitly refused on `/ingress/*`). DPoP (RFC 9449) is the standard mechanism to sender-constrain that external OAuth hop: when a client sends a `DPoP` header at `/oauth/token`, the control @@ -538,10 +547,10 @@ as `client_key_thumbprint("")` in the Biscuit authority block, and returns `token_type: DPoP`, and the node's `/mcp` ingress verifies the `DPoP` proof with `ath` over the Biscuit and checks `client_key_thumbprint($t), connection_key_thumbprint($t)`. Because the MCP -authorization specification does not require DPoP and off-the-shelf MCP clients -(Claude Code, Gemini CLI, Cursor, VS Code) only send Authorization Code + PKCE -today, DPoP is optional (`dpop_bound_access_tokens_required: false`) and a -Biscuit minted without a `DPoP` proof remains a bearer token at `/mcp`. +and A2A specifications do not require DPoP and off-the-shelf MCP and A2A clients +(Claude Code, Gemini CLI, Cursor, VS Code, `a2aclient`) only send bearer tokens +over TLS today, DPoP is optional (`dpop_bound_access_tokens_required: false`) on +`/mcp` and omitted on `/ingress/*`. ### 5.3 No built-in Azure or AWS IMDS node enrollment `TokenSource` diff --git a/site/content/docs/guides/external-ingress.md b/site/content/docs/guides/external-ingress.md new file mode 100644 index 00000000..81385402 --- /dev/null +++ b/site/content/docs/guides/external-ingress.md @@ -0,0 +1,219 @@ +--- +title: "Exposing mesh services to external callers" +linkTitle: "External ingress" +weight: 4 +--- + +Sometimes an agent, MCP server, or OpenAI-compatible inference endpoint on an +enrolled `agentmesh-node` needs to be reached by an HTTP client that is not on +the mesh: a hosted assistant, a chat bridge, a browser client, or a paired CLI +such as `a2a-serve`. + +Agent Mesh handles external HTTP ingress at `agentmesh-router` (and on the +single port of `agentmesh-one`), so `agentmesh-control-plane` never carries +data-plane traffic and the serving laptop or sandbox needs no inbound firewall +ports, subdomains, wildcard DNS, or extra TLS certificates. + +Every exposed service is addressed at one deterministic path: + +```text +/ingress///[/] +``` + +For example: + +```text +https://bananas.sam-mesh.dev/ingress/12D3KooW.../a2a/coder +``` + +## Two-layer opt-in + +Nothing on the mesh is reachable from `/ingress/` unless **both** the mesh +administrator and the node owner opt in: + +1. **Admin ceiling (`PolicyRole.allowed_ingress`)**: the node's mesh role must + grant external exposure for that service pattern (`a2a://coder`, `mcp://*`, + or `*`). Omitting `allowed_auth_modes` permits only the zero-trust default + (`INGRESS_AUTH_MODE_EDGE`). `INGRESS_AUTH_MODE_PASSTHROUGH` is refused unless + a role explicitly lists it. +2. **Node service config (`services[].ingress`)**: the node's + `agentmesh-node.yaml` must include an `ingress:` block on that `a2a`, `mcp`, + or `inference` service. Services without `ingress:` always return `404` on + `/ingress/`. + +## What the admin writes + +In `agentmesh-one` development mode (no `--policy-file`), `mesh:role:node` +grants `INGRESS_AUTH_MODE_EDGE` on `*` out of the box so zero-trust external +ingress works with zero configuration. `INGRESS_AUTH_MODE_PASSTHROUGH` is never +granted by default, even in `agentmesh-one`: pass `--policy-file` or update the +policy via `POST /policies` to enable `passthrough` for a role. + +On a production mesh, grant `allowed_ingress` on the serving node's role and +bind external callers to a role whose `allowed_services` and `allowed_targets` +permit calling the service: + +```json +{ + "roles": [ + { + "name": "laptop-agent", + "allowed_publish": ["a2a://coder", "mcp://git"], + "allowed_ingress": [ + { "services": ["a2a://coder", "mcp://git"] } + ] + }, + { + "name": "paired-agent", + "allowed_publish": ["a2a://paired"], + "allowed_ingress": [ + { + "services": ["a2a://paired"], + "allowed_auth_modes": [ + "INGRESS_AUTH_MODE_EDGE", + "INGRESS_AUTH_MODE_PASSTHROUGH" + ] + } + ] + }, + { + "name": "external-caller", + "allowed_services": ["a2a://coder", "mcp://git"], + "allowed_targets": ["*"] + } + ], + "bindings": [ + { "role": "laptop-agent", "members": ["email:alice@example.com"] }, + { "role": "external-caller", "members": ["email:*@example.com"] } + ] +} +``` + +## What the node owner writes + +Add `ingress:` to any `a2a`, `mcp`, or `inference` entry in `agentmesh-node.yaml`: + +```yaml +version: "v1alpha1" +services: + # Zero-trust default (auth_mode: edge): router + node verify a Delegated + # Session Biscuit and evaluate Datalog RBAC, TAR attenuation, and allow list. + - type: a2a + name: coder + target_url: "http://127.0.0.1:9001" + ingress: + allow: + - "email:alice@example.com" + - "group:teammates" + + # Opt-in passthrough: forwards Authorization untouched to a backend that + # enforces its own application-layer pairing token (requires PASSTHROUGH in + # the node's PolicyRole.allowed_ingress). + - type: a2a + name: paired + target_url: "http://127.0.0.1:9002" + ingress: + auth_mode: passthrough +``` + +When `agentmesh-node` starts and announces the service, it logs the public URL +from the mesh's router lease and exposes it in `agentmesh-node debug services`: + +```text +[Ingress] Published a2a://coder at https://bananas.sam-mesh.dev/ingress/12D3KooW.../a2a/coder (auth_mode=edge) +``` + +If the node's mesh role does not grant external exposure for that service or +auth mode, `agentmesh-node` logs an error, increments +`agentmesh_node_service_provide_failures_total{scope="ingress"}`, and falls back +to announcing the service internally so mesh peers can still reach it. + +## How `edge` and `passthrough` behave + +### `auth_mode: edge` (default) + +1. Unauthenticated requests (and requests carrying a raw platform JWT) receive + `401 Unauthorized` with an RFC 9728 header: + `WWW-Authenticate: Bearer resource_metadata="/ingress/.well-known/oauth-protected-resource"`. +2. External callers present `Authorization: Bearer `, where `` + is a **Delegated Session Biscuit** minted by the control plane's OAuth 2.1 + Authorization Server (`/oauth/authorize` + `/oauth/token`) or RFC 8693 token + exchange (`POST /oauth/token` or `POST /token/exchange`), optionally + attenuated with a `TaskAuthorizationRule`. Callers holding an OIDC JWT + exchange it once at `POST /oauth/token` into a Delegated Session Biscuit so + `agentmesh-router` stays purely on Ed25519 Biscuit verification with zero + IdP JWKS network dependencies. `mesh:role:router` Biscuits and unattenuated + standing node Biscuits (`node(...)` without a `tar_block`) are rejected. +3. The router verifies the Biscuit signature, authority revocation ID, banned + peer status, `TaskAuthorizationRule` chain (including MCP `tools/call` tool + names), and `ServiceIngress.allow` locally **before** dialing any provider + node. Once admitted, both the router and the serving node (defense in depth + over `/mesh/ingress/1.0.0`) enforce the full intersection of mesh Datalog + RBAC, `TaskAuthorizationRule`, `ServiceIngress.allow`, and local + `agentmesh-node.yaml` `attenuation`. The node serves `/mesh/ingress/1.0.0` + only to a peer that authenticated as `mesh:role:router`: a stream from any + other peer, authenticated or not, is reset at the libp2p gate before the + HTTP server reads it and counted in + `agentmesh_node_streams_refused_total{scope="not_router"}`. +4. The external `Authorization` header is stripped before reaching the local + backend (replaced only if `target_auth_path` is configured on the node). +5. For `a2a` services, unauthenticated `GET` requests to the service root or + `/.well-known/agent-card.json` return the backend's A2A `AgentCard` with + `supportedInterfaces[].url` rewritten to the public `/ingress/...` URL, + `signatures` cleared, and `securitySchemes` populated with `bearer` + (`bearerFormat: "Biscuit"`) and `agentmesh_oauth2`. + +### `auth_mode: passthrough` (opt-in) + +For backends that run their own pairing or token handshake (for example +`a2a-serve` pair tokens), `passthrough` forwards the caller's `Authorization` +header untouched over `/mesh/ingress/1.0.0` while stripping `Cookie` and +`X-Mesh-*` headers and preserving the backend's own `AgentCard` +`securitySchemes`. + +Combining `auth_mode: passthrough` with `target_auth_path` or inline credentials +in `target_url` (`http://user:pass@host`) is rejected at config validation and +service registration so node-local static backend credentials can never be +injected on unauthenticated external passthrough routes. + +## Operator deployment + +### `agentmesh-one` (local, VM, or Cloud Run) + +No extra flags are needed. `agentmesh-one` mounts `/ingress/` directly onto its +embedded router on the single HTTP/WebSocket port: + +```bash +agentmesh-one --external-url https://my-hub.run.app --data-dir /data +``` + +### Multi-binary Kubernetes (`agentmesh-router` + `Gateway`) + +1. Start `agentmesh-router` with an HTTP ingress listener behind your L7 TLS + `Gateway`: + ```text + --ingress-addr=0.0.0.0:8080 + --ingress-url=https://hub.example.com/ingress + --public-control-plane=https://hub.example.com + --allow-insecure-ingress-http + ``` + (Or terminate TLS directly on the router with `--ingress-tls-cert-path` and + `--ingress-tls-key-path`.) +2. Route `PathPrefix: /ingress/` on your `HTTPRoute` directly to the + `agentmesh-router` HTTP ingress `Service` (health check path: + `/ingress/healthz`), keeping `agentmesh-control-plane` out of the data path. + Because routers federate their directory and circuit relays, any router pod + can serve `/ingress//...` regardless of which router pod holds the + node's relay reservation. +3. Authenticated `edge` requests are rate-limited per verified Biscuit identity + at the router. Because routers behind an L7 `Gateway` see the proxy's + internal source IP on TCP `RemoteAddr`, configure per-client source-IP rate + limiting at your L7 `Gateway` (for example Cloud Armor on GKE) to protect + unauthenticated `AgentCard` discovery and `passthrough` routes. +4. With the Helm chart (`charts/agentmesh`), `router.ingressPort` (`8090` by + default) and `gateway.ingressRoute: true` configure both the router listener + and the `HTTPRoute` automatically when `gateway.enabled: true`. Set + `router.ingressUrl` (e.g. `https://hub.example.com/ingress`), + `router.publicControlPlaneUrl` (e.g. `https://hub.example.com`), and + `bootstrap.nodeIngress` to advertise the public ingress base URL and seed the + initial role ceiling. diff --git a/site/content/docs/reference/node-config.md b/site/content/docs/reference/node-config.md index 1026d58e..112655f3 100644 --- a/site/content/docs/reference/node-config.md +++ b/site/content/docs/reference/node-config.md @@ -85,6 +85,7 @@ A list. Each entry has these keys: | `command` | `mcp` only: this or `target_url` | Argument vector of a subprocess that speaks MCP over stdio. The node starts and supervises it. | | `env` | no | `command` only. Environment for the subprocess. | | `target_auth_path` | no | `target_url` only. File whose contents the node presents to the backend: a bare token as `Authorization: Bearer `, or `user:pass` as HTTP Basic. Read once at start, never logged or advertised. It overrides any `Authorization` header that a caller sent. | +| `ingress` | no | Exposes an `a2a`, `mcp` or `inference` service on router HTTP ingress (`///`), subject to the node's `allowed_ingress` role ceiling. Subkeys: `auth_mode` (`edge`, the default, or `passthrough`) and `allow` (optional list of `PolicyBinding.members` patterns narrowing `edge` callers). See the [external ingress guide](../../guides/external-ingress/). | Rules by type: diff --git a/site/content/docs/reference/policy.md b/site/content/docs/reference/policy.md index 0c7d0c3d..a713cb7e 100644 --- a/site/content/docs/reference/policy.md +++ b/site/content/docs/reference/policy.md @@ -68,6 +68,7 @@ notice. | `name` | string, required, unique | The role name. `mesh:role:node` and `mesh:role:router` are the roles that the two binaries request at enrollment. Any other name is an ordinary role. | | `allowed_services` | list of service patterns | Services that holders may call. | | `allowed_publish` | list of service patterns | Services that holders may serve. A router keeps a member's directory record only for a service its credential grants here, and a caller sends a request to a provider it reached by name only when the provider's credential does. If absent, holders may publish nothing. See [Publishing](#publishing). | +| `allowed_ingress` | list of ingress grants | Inbound services (`a2a`, `mcp`, `inference`, or `*`) that holders may expose on router HTTP ingress. Each entry has `services` (required) and optional `allowed_auth_modes` (`INGRESS_AUTH_MODE_EDGE`, `INGRESS_AUTH_MODE_PASSTHROUGH`; empty grants `EDGE` only). See the [external ingress guide](../../guides/external-ingress/). | | `allowed_targets` | list of target patterns | Nodes that holders may call. `*` means any node. If absent, holders may call no node. | | `allowed_labels` | list of label patterns | Labels that a node holding this role may declare at enrollment. If absent, no labels may be declared. | | `custom_datalog` | list of Datalog statements | Facts are minted into the credentials of holders. Rules are distributed to nodes and applied when a holder is verified. | diff --git a/site/content/docs/reference/router.md b/site/content/docs/reference/router.md index a06adfb4..93034bb2 100644 --- a/site/content/docs/reference/router.md +++ b/site/content/docs/reference/router.md @@ -48,6 +48,11 @@ replica count. | `--shutdown-lease-ttl` | `30s` | Sent in the router's last lease when it stops: how long it expects to be away, which is how long the control plane keeps listing it. The default covers a pod that restarts in place. A router that is being removed for good can send `1s`. `0` sends nothing and the lease expires on the control plane's schedule. | | `--auth-deadline` | `1m` | How long an inbound connection may stay open without its peer passing the handshake. A member handshakes the moment it connects, as does the control plane's event publisher, which presents a credential of its own. What is left at the deadline is closed, so a connection without a session costs the router a minute, and the watermarks mean members. `0` keeps every connection. | | `--metrics-addr` | off | Serve `/metrics`, `/healthz`, `/readyz` and `/debug/peer` without authentication on this address. `/readyz` returns `200` once the router is enrolled and the libp2p host is up. Keep this address separate from the libp2p ports and inside the cluster. | +| `--ingress-addr` | off | Serve external HTTP ingress under `/ingress/` (plus `/ingress/healthz` and `/ingress/.well-known/oauth-protected-resource`) on this address. Non-loopback addresses require TLS (`--ingress-tls-cert-path` / `--ingress-tls-key-path`) or `--allow-insecure-ingress-http` behind a TLS-terminating L7 load balancer. | +| `--ingress-url` | none | Public base URL advertised in the router's lease and rewritten into A2A `AgentCard` and RFC 9728 metadata (for example `https://hub.example.com/ingress`). | +| `--ingress-tls-cert-path`, `--ingress-tls-key-path` | none | TLS certificate and private key for `--ingress-addr`. | +| `--allow-insecure-ingress-http` | `false` | Permit plaintext HTTP on a non-loopback `--ingress-addr` (behind a TLS-terminating L7 proxy) and `http://` in `--ingress-url`. | +| `--public-control-plane` | derived | Optional public control-plane URL advertised in RFC 9728 and A2A OAuth metadata when `--control-plane` is an internal address. | | `--pprof-addr` | off | Serve Go runtime profiles under `/debug/pprof/` on this address, for attributing memory or goroutines the metrics show as held. Unauthenticated, and a heap profile is a dump of process memory, which holds the router's key: bind it to loopback and reach it with `kubectl port-forward`. | | `--log-level` | `info` | `debug`, `info`, `warn`, `error`. | | `--log-format` | `text` | `text` or `json`. | @@ -144,7 +149,7 @@ The metrics address exposes, next to the Go runtime metrics: | `agentmesh_router_connection_watermark{level}` | The `low` and `high` watermarks in force. The gap between `inbound` connections and the `high` watermark is the free capacity. | | `agentmesh_router_conns_per_source_ip_limit` | The per-address cap in force. | | `agentmesh_router_relay_limit{limit}` | The relay budget in force: `reservations`, `reservations_per_ip` and `circuits_per_peer`. | -| `agentmesh_router_connected_peers`, `agentmesh_router_authenticated_peers`, `agentmesh_router_banned_peers` | Peers in each state. | +| `agentmesh_router_connected_peers`, `agentmesh_router_authenticated_peers`, `agentmesh_router_banned_peers`, `agentmesh_router_revoked_tokens` | Peers in each state and non-expired authority revocation IDs currently enforced by the router. | | `agentmesh_router_peer_routers{state}` | Other routers this one knows, from the control plane's list or a handshake that proved the router role: `listed`, and `authenticated` for those it currently holds a mutual authentication with. The two differ while a federation pass is pending or a peer router is down. | | `agentmesh_router_dht_routing_table_size` | Peer routers in the DHT's routing table: the authenticated peer routers, since nothing else enters it. | | `agentmesh_router_directory_requests_total{op,result}`, `agentmesh_router_directory_request_seconds{op}` | Directory requests served, by operation (`provide`, `withdraw`, `find_providers`, `find_peer`; `place` is a record or withdrawal another router put here, `lookup` a find another router ran here) and result (`ok`, `empty`, `invalid`, `denied` for a record naming a service the member's credential does not grant serving, `error`), with the time each took. | @@ -158,6 +163,7 @@ The metrics address exposes, next to the Go runtime metrics: | `agentmesh_router_inbound_connections_refused_total{reason}` | Inbound connections the router refused before the handshake, by reason. `per_ip_limit` is the per-address cap. `rate_limit` is the per-address rate limit on new connections. `scope_limit` is libp2p's connection cap. `scope_limit` also counts a connection refused while more than half that cap are still in TLS or Noise setup. `other` is any other refusal. | | `agentmesh_router_streams_refused_total{scope,protocol}` | Streams refused, by scope and protocol or service. `scope="unauthenticated"` is a stream from a peer that never passed the handshake, reset when its connection closed; a steady rate is a scanner, or a member whose handshake fails. The other scopes are the resource manager's: a count on `/mesh/kad/1.0.0` or a `/meshsub/*` protocol there is members the router keeps out of discovery; see the scalability guide. | | `agentmesh_router_busiest_connection_streams{transport}` | The most streams any one connection carries. The connection to a service the router relays for holds one stream per relayed caller, and QUIC admits 256 per connection; see the scalability guide. | +| `agentmesh_router_ingress_requests_total{auth_mode,outcome}`, `agentmesh_router_ingress_request_duration_seconds{auth_mode}` | External HTTP ingress requests served under `/ingress/` (`auth_mode` in `edge`, `passthrough`, `metadata`, `unknown`; `outcome` in `allow`, `unauthenticated`, `forbidden`, `not_found`, `rate_limited`, `unavailable`, `bad_gateway`) and their duration. | | `libp2p_relaysvc_*` | The relay service's own counters: `reservations_total{type}` and `connections_total{type}` (`opened`, `closed`, `renewed`), requests by response status, rejections and bytes relayed. The difference between `opened` and `closed` is the number in use; read it against `agentmesh_router_relay_limit` to see how close the relay is to its budget. | A fleet is at capacity when `agentmesh_router_connections{direction="inbound"}` diff --git a/tests/e2e/fixtures/fake_openai.py b/tests/e2e/fixtures/fake_openai.py index ab48c57c..5ec5ac70 100644 --- a/tests/e2e/fixtures/fake_openai.py +++ b/tests/e2e/fixtures/fake_openai.py @@ -20,6 +20,23 @@ def do_GET(self): if self.path == "/v1/models": self._json(200, {"object": "list", "data": [{"id": MODEL, "object": "model", "owned_by": "e2e"}]}) return + if self.path == "/a2a/.well-known/agent-card.json": + port = self.server.server_address[1] + self._json(200, { + "name": "coding-agent", + "description": "e2e laptop A2A agent", + "version": "1.0.0", + "supportedInterfaces": [{ + "url": f"http://127.0.0.1:{port}/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + }], + "capabilities": {"streaming": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [{"id": "code", "name": "Code", "description": "Coding harness"}], + }) + return self._json(404, {"error": {"message": "not found"}}) def do_POST(self): @@ -81,6 +98,21 @@ def do_POST(self): }, }) return + if self.path == "/a2a" or self.path == "/a2a/": + ingress_mode = self.headers.get("X-Mesh-Ingress", "") + principal = self.headers.get("X-Mesh-Principal", "") + self._json(200, { + "jsonrpc": "2.0", + "id": req.get("id", 1), + "result": { + "message": { + "messageId": "msg-e2e-1", + "role": "ROLE_AGENT", + "parts": [{"text": f"a2a ingress={ingress_mode} principal={principal}"}], + } + }, + }) + return self._json(404, {"error": {"message": "unknown model", "code": "model_not_found"}}) def log_message(self, *args): diff --git a/tests/e2e/standalone.bats b/tests/e2e/standalone.bats index e8f60f86..9e4b4f4d 100644 --- a/tests/e2e/standalone.bats +++ b/tests/e2e/standalone.bats @@ -148,6 +148,12 @@ services: name: not-an-mcp-server description: "a plain HTTP server mislabelled as mcp" target_url: "http://127.0.0.1:${backend_port}" + - type: a2a + name: coding-agent + description: "e2e a2a agent exposed on external ingress" + target_url: "http://127.0.0.1:${backend_port}/a2a" + ingress: + auth_mode: edge EOF start_node a --config "$TEST_TMPDIR/node-a-services.yaml" @@ -217,6 +223,58 @@ EOF -d '{"model":"e2e-model","messages":[{"role":"user","content":"should fail"}]}')" [[ "$http_code" -ne 200 ]] + # External HTTP ingress CUJ on the single agentmesh-one port: + # 1. Non-exposed internal service (inference://laptop-llm) stays 404 on /ingress/. + internal_ing_code="$(curl -s -o /dev/null -w '%{http_code}' \ + "$AGENTMESH_ONE_URL/ingress/${peer_a}/inference/laptop-llm/v1/models")" + [[ "$internal_ing_code" -eq 404 ]] + + # 2. RFC 9728 Protected Resource Metadata and rewritten public A2A AgentCard + # are served on /ingress/ without caller credentials. + run curl -sf "$AGENTMESH_ONE_URL/ingress/.well-known/oauth-protected-resource" + [[ "$status" -eq 0 ]] + [[ "$output" == *"\"resource\":\"$AGENTMESH_ONE_URL/ingress\""* ]] + + card_json="" + for _ in $(seq 1 30); do + card_json="$(curl -sf "$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent/.well-known/agent-card.json" || true)" + [[ "$card_json" == *"$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent"* ]] && break + sleep 0.5 + done + [[ "$card_json" == *"$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent"* ]] + [[ "$card_json" == *"agentmesh_oauth2"* ]] + + # 3. Unauthenticated POST to edge-protected A2A service returns 401 with RFC 9728 WWW-Authenticate. + run curl -sS -i -X POST "$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{}}' + [[ "$output" == *"401 Unauthorized"* ]] + [[ "$output" == *"resource_metadata=\"$AGENTMESH_ONE_URL/ingress/.well-known/oauth-protected-resource\""* ]] + + # 4. Authenticated external caller with a task-scoped Session Biscuit reaches + # Node A's A2A backend with verified X-Mesh-Ingress=edge attribution. + a2a_biscuit="$(curl -sf --unix-socket "$sock_b" "http://localhost/oauth/token" \ + -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ + -d 'resource=a2a://coding-agent' | + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')" + [[ -n "$a2a_biscuit" ]] + + run curl -sf -X POST "$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent" \ + -H "Authorization: Bearer $a2a_biscuit" \ + -H "X-Mesh-Principal: spoofed-admin" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{}}' + [[ "$status" -eq 0 ]] + [[ "$output" == *"a2a ingress=edge"* ]] + [[ "$output" != *"spoofed-admin"* ]] + + wrong_ing_code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + "$AGENTMESH_ONE_URL/ingress/${peer_a}/a2a/coding-agent" \ + -H "Authorization: Bearer $wrong_biscuit" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"SendMessage","params":{}}')" + [[ "$wrong_ing_code" -eq 403 ]] + # Real Envoy proxy CUJ: when Docker is available, place Envoy in front of node B # with both ext_authz and ext_proc configured over gRPC h2c, and drive real # inference (/v1/chat/completions) and MCP (/mesh//mcp/github) calls diff --git a/tests/integration/ingress_test.go b/tests/integration/ingress_test.go new file mode 100644 index 00000000..faf22f17 --- /dev/null +++ b/tests/integration/ingress_test.go @@ -0,0 +1,892 @@ +// 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 integration_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "iter" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/google/agentmesh/api" + "github.com/google/agentmesh/internal/controlplane" + "github.com/google/agentmesh/internal/node" + "github.com/google/agentmesh/internal/router" + "github.com/google/agentmesh/internal/standalone" + "github.com/google/agentmesh/internal/storage" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/multiformats/go-multiaddr" + "google.golang.org/protobuf/encoding/protojson" +) + +func bearerClient(token string, extra map[string]string) *http.Client { + headers := map[string]string{} + if token != "" { + headers["Authorization"] = "Bearer " + token + } + for k, v := range extra { + headers[k] = v + } + return &http.Client{ + Timeout: 5 * time.Second, + Transport: headerRoundTripper{headers: headers}, + } +} + +func postOperatorPolicy(t *testing.T, ctx context.Context, baseURL, adminToken string, cfg *api.PolicyConfig) { + t.Helper() + payload, err := (protojson.MarshalOptions{UseProtoNames: true}).Marshal(cfg) + if err != nil { + t.Fatalf("marshal PolicyConfig: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/policies", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("new POST /policies request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+adminToken) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + t.Fatalf("POST /policies: %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST /policies = %s: %s", resp.Status, body) + } +} + +func exchangeJWTForBiscuit(t *testing.T, cpURL, jwtStr, optionsJSON string, seal bool) string { + t.Helper() + form := url.Values{ + "grant_type": {api.GrantTypeTokenExchange}, + "subject_token": {jwtStr}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:id_token"}, + } + if optionsJSON != "" { + form.Set("options", optionsJSON) + } + if seal { + form.Set("seal", "true") + } + resp, err := (&http.Client{Timeout: 5 * time.Second}).PostForm(cpURL+"/oauth/token", form) + if err != nil { + t.Fatalf("POST /oauth/token: %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST /oauth/token = %s: %s", resp.Status, body) + } + var out struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &out); err != nil || out.AccessToken == "" { + t.Fatalf("decode /oauth/token response: %v (%s)", err, body) + } + return out.AccessToken +} + +func enrollAndStartRuntimeNode( + t *testing.T, + ctx context.Context, + cpURL string, + bootstrapToken string, + jwtStr string, + selector map[string]string, + reachability node.Reachability, + listenAddrs []string, + services []api.ServiceConfig, +) *node.AgentMeshNode { + t.Helper() + storeDir := t.TempDir() + nodeStore, err := node.NewStore(storeDir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + t.Cleanup(func() { _ = nodeStore.Close() }) + + priv := node.GetOrGenerateKey(nodeStore) + enrollNode, err := node.NewAgentMeshNode(node.Options{ + PrivKey: priv, + Store: nodeStore, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + AllowLoopback: true, + RequiredRole: api.RoleNode, + RouterSelector: selector, + }) + if err != nil { + t.Fatalf("NewAgentMeshNode(enroll): %v", err) + } + enrollCtx, enrollCancel := context.WithCancel(ctx) + if err := enrollNode.Start(enrollCtx); err != nil { + enrollCancel() + t.Fatalf("enrollNode.Start: %v", err) + } + if bootstrapToken != "" { + err = enrollNode.EnrollBootstrap(enrollCtx, cpURL, bootstrapToken) + } else { + err = enrollNode.Enroll(enrollCtx, cpURL, jwtStr) + } + if err != nil { + _ = enrollNode.Teardown() + enrollCancel() + t.Fatalf("enrollment failed: %v", err) + } + if err := nodeStore.SaveControlPlaneURL(cpURL); err != nil { + t.Fatalf("SaveControlPlaneURL: %v", err) + } + _ = enrollNode.Teardown() + enrollCancel() + + cpPub, storedAddrs, err := nodeStore.LoadMeshConfig() + if err != nil { + t.Fatalf("LoadMeshConfig: %v", err) + } + var rAddrs []multiaddr.Multiaddr + for _, s := range storedAddrs { + if ma, mErr := multiaddr.NewMultiaddr(s); mErr == nil { + rAddrs = append(rAddrs, ma) + } + } + + runtimeNode, err := node.NewAgentMeshNode(node.Options{ + PrivKey: priv, + ControlPlanePubKey: cpPub, + RouterAddrs: rAddrs, + Store: nodeStore, + ListenAddrs: listenAddrs, + AllowLoopback: true, + RequiredRole: api.RoleNode, + RouterSelector: selector, + Reachability: reachability, + DiscoveryInterval: "100ms", + AutoRelayBootDelay: 50 * time.Millisecond, + AutoRelayMinInterval: 50 * time.Millisecond, + AutoRelayBackoff: 50 * time.Millisecond, + ControlPlaneSyncInterval: 250 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewAgentMeshNode(runtime): %v", err) + } + if err := runtimeNode.SyncControlPlane(ctx); err != nil { + t.Fatalf("runtimeNode.SyncControlPlane: %v", err) + } + if err := runtimeNode.Start(ctx); err != nil { + t.Fatalf("runtimeNode.Start: %v", err) + } + t.Cleanup(func() { + _ = runtimeNode.Teardown() + }) + if len(services) > 0 { + if err := runtimeNode.RegisterStaticServices(ctx, services); err != nil { + t.Fatalf("RegisterStaticServices: %v", err) + } + } + return runtimeNode +} + +// TestStandaloneExternalIngressCUJ verifies the end-to-end External HTTP +// Ingress journey on agentmesh-one's single port: +// - RFC 9728 OAuth Protected Resource metadata at /ingress/.well-known/oauth-protected-resource +// - Internal-only services remain 404 on /ingress/... +// - Default EDGE mode: unauthenticated A2A AgentCard discovery with public URL & SecuritySchemes rewrite, +// raw JWT rejection (401 + RFC 9728 WWW-Authenticate), RFC 8693 /oauth/token Delegated Session Biscuit +// - ServiceIngress.allow ACL enforcement, stock A2A SendMessage & SSE SendStreamingMessage, +// X-Mesh-Principal delivery to the backend, and TAR MCP tool attenuation +// - Opt-in PASSTHROUGH mode: upstream Bearer pair token preserved end-to-end while spoofed X-Mesh-* +// headers are stripped. +func TestStandaloneExternalIngressCUJ(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + oidcURL, mintJWT := startCustomMockOIDC(t) + + srv, err := standalone.New(standalone.Options{ + BindAddress: "127.0.0.1:0", + DataDir: t.TempDir(), + OIDCIssuer: oidcURL, + AllowedAudiences: []string{"agentmesh-audience"}, + }) + if err != nil { + t.Fatalf("standalone.New: %v", err) + } + if err := srv.Start(ctx); err != nil { + t.Fatalf("standalone.Start: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + postOperatorPolicy(t, ctx, srv.PublicURL(), srv.AdminToken(), &api.PolicyConfig{ + Roles: []*api.PolicyRole{ + { + Name: api.RoleRouter, + AllowedTargets: []string{"*"}, + AllowedServices: []string{"*"}, + }, + { + Name: api.RoleNode, + AllowedTargets: []string{"*"}, + AllowedServices: []string{"*"}, + AllowedPublish: []string{"*"}, + AllowedIngress: []*api.IngressGrant{ + { + Services: []string{"*"}, + AllowedAuthModes: []api.IngressAuthMode{ + api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }, + }, + }, + }, + { + Name: "external-caller", + AllowedTargets: []string{"*"}, + AllowedServices: []string{"a2a://coder", "mcp://tools"}, + }, + }, + Bindings: []*api.PolicyBinding{ + { + Role: "external-caller", + Members: []string{"email:yaroslav@example.com", "email:other@example.com"}, + }, + }, + }) + + // 1. Stock A2A backend for a2a://coder (EDGE mode). + var lastPrincipal atomic.Value + firstStreamSeen := make(chan struct{}) + coderExec := a2asrv.AgentExecutorFunc(func(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + if len(ec.Message.Parts) > 0 && ec.Message.Parts[0].Text() == "stream" { + if !yield(a2a.NewSubmittedTask(ec, ec.Message), nil) { + return + } + select { + case <-firstStreamSeen: + case <-ctx.Done(): + return + } + yield(a2a.NewStatusUpdateEvent(ec, a2a.TaskStateCompleted, a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("streamed from laptop"))), nil) + return + } + yield(a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("hello from laptop")), nil) + } + }) + coderCard := &a2a.AgentCard{ + Name: "coder", + Description: "a2a-serve laptop coding agent", + Version: "1.0.0", + Capabilities: a2a.AgentCapabilities{Streaming: true}, + SupportedInterfaces: []*a2a.AgentInterface{ + {URL: "http://127.0.0.1:9999/invoke", ProtocolBinding: a2a.TransportProtocolJSONRPC, ProtocolVersion: "1.0"}, + {URL: "127.0.0.1:50051", ProtocolBinding: a2a.TransportProtocolGRPC, ProtocolVersion: "1.0"}, + }, + Signatures: []a2a.AgentCardSignature{{Protected: "eyJhbGciOiJFUzI1NiJ9", Signature: "c3RhbGU"}}, + } + coderMux := http.NewServeMux() + coderMux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(coderCard)) + coderMux.Handle("/invoke", a2asrv.NewJSONRPCHandler(a2asrv.NewHandler(coderExec))) + coderBackend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p := r.Header.Get(api.HeaderMeshPrincipal); p != "" { + lastPrincipal.Store(p) + } + coderMux.ServeHTTP(w, r) + })) + t.Cleanup(coderBackend.Close) + + // 2. Stock A2A backend for a2a://paired (PASSTHROUGH mode with app-level pair token). + var passthroughSpoofStripped atomic.Bool + pairedExec := a2asrv.AgentExecutorFunc(func(_ context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("paired ok")), nil) + } + }) + pairedCard := &a2a.AgentCard{ + Name: "paired", + Description: "a2a-serve pair-token agent", + Version: "1.0.0", + SupportedInterfaces: []*a2a.AgentInterface{ + {URL: "http://127.0.0.1:9998/invoke", ProtocolBinding: a2a.TransportProtocolJSONRPC, ProtocolVersion: "1.0"}, + }, + SecuritySchemes: a2a.NamedSecuritySchemes{ + "pair_bearer": a2a.HTTPAuthSecurityScheme{Scheme: "bearer", Description: "a2a-serve pair token"}, + }, + } + pairedMux := http.NewServeMux() + pairedMux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(pairedCard)) + pairedMux.Handle("/invoke", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(api.HeaderMeshPrincipal) == "" && r.Header.Get(api.HeaderMeshRoles) == "" && r.Header.Get(api.HeaderMeshIngress) == api.IngressAuthModeStringPassthrough { + passthroughSpoofStripped.Store(true) + } + if r.Header.Get("Authorization") != "Bearer a2p_pair_123" { + http.Error(w, "invalid pair token", http.StatusUnauthorized) + return + } + a2asrv.NewJSONRPCHandler(a2asrv.NewHandler(pairedExec)).ServeHTTP(w, r) + })) + pairedBackend := httptest.NewServer(pairedMux) + t.Cleanup(pairedBackend.Close) + + // 3. Stock MCP Streamable HTTP backend for mcp://tools (EDGE mode + TAR tool check). + type emptyArgs struct{} + mcpSrv := mcp.NewServer(&mcp.Implementation{Name: "tools", Version: "1.0.0"}, nil) + mcp.AddTool(mcpSrv, &mcp.Tool{Name: "read_file", Description: "Read file"}, func(_ context.Context, _ *mcp.CallToolRequest, _ emptyArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "file-data"}}}, nil, nil + }) + mcp.AddTool(mcpSrv, &mcp.Tool{Name: "exec_shell", Description: "Exec shell"}, func(_ context.Context, _ *mcp.CallToolRequest, _ emptyArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "shell-ran"}}}, nil, nil + }) + mcpBackend := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpSrv }, nil)) + t.Cleanup(mcpBackend.Close) + + provider := enrollAndStartRuntimeNode( + t, + ctx, + srv.PublicURL(), + srv.JoinToken(), + "", + nil, + node.ReachabilityAuto, + []string{"/ip4/127.0.0.1/tcp/0"}, + []api.ServiceConfig{ + { + Type: "a2a", + Name: "coder", + TargetURL: coderBackend.URL, + Ingress: &api.IngressConfig{AuthMode: "edge", Allow: []string{"email:yaroslav@example.com"}}, + }, + { + Type: "a2a", + Name: "paired", + TargetURL: pairedBackend.URL, + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }, + { + Type: "mcp", + Name: "tools", + TargetURL: mcpBackend.URL, + Ingress: &api.IngressConfig{AuthMode: "edge"}, + }, + { + Type: "a2a", + Name: "internal", + TargetURL: coderBackend.URL, + }, + }, + ) + peerID := provider.Host.ID().String() + httpClient := &http.Client{Timeout: 5 * time.Second} + + // RFC 9728 Protected Resource Metadata is served on the public ingress root. + metaResp, err := httpClient.Get(srv.IngressURL() + "/.well-known/oauth-protected-resource") + if err != nil { + t.Fatalf("GET oauth-protected-resource: %v", err) + } + metaBody, _ := io.ReadAll(metaResp.Body) + _ = metaResp.Body.Close() + if metaResp.StatusCode != http.StatusOK { + t.Fatalf("oauth-protected-resource = %s: %s", metaResp.Status, metaBody) + } + var prm api.OAuthProtectedResourceMetadata + if err := json.Unmarshal(metaBody, &prm); err != nil { + t.Fatalf("unmarshal PRM: %v", err) + } + if prm.Resource != srv.IngressURL() { + t.Errorf("PRM resource = %q, want %q", prm.Resource, srv.IngressURL()) + } + if len(prm.AuthorizationServers) != 1 || prm.AuthorizationServers[0] != srv.PublicURL() { + t.Errorf("PRM authorization_servers = %v, want [%s]", prm.AuthorizationServers, srv.PublicURL()) + } + + // Internal-only service (ingress == nil) is never exposed on /ingress/... + coderPublicBase := fmt.Sprintf("%s/%s/a2a/coder", srv.IngressURL(), peerID) + resolver := agentcard.NewResolver(httpClient) + var publicCoderCard *a2a.AgentCard + deadline := time.Now().Add(5 * time.Second) + for { + publicCoderCard, err = resolver.Resolve(ctx, coderPublicBase) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("resolve public coder AgentCard failed: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + + internalResp, err := httpClient.Get(fmt.Sprintf("%s/%s/a2a/internal/.well-known/agent-card.json", srv.IngressURL(), peerID)) + if err != nil { + t.Fatalf("GET internal service over /ingress: %v", err) + } + _ = internalResp.Body.Close() + if internalResp.StatusCode != http.StatusNotFound { + t.Fatalf("internal service over /ingress = %d, want 404", internalResp.StatusCode) + } + + // Verify public AgentCard URL, protocol filtering, signature stripping, and SecuritySchemes. + wantInvokeURL := coderPublicBase + "/invoke" + if len(publicCoderCard.SupportedInterfaces) != 1 || publicCoderCard.SupportedInterfaces[0].URL != wantInvokeURL { + t.Fatalf("SupportedInterfaces = %+v, want [%s]", publicCoderCard.SupportedInterfaces, wantInvokeURL) + } + if !publicCoderCard.Capabilities.Streaming { + t.Errorf("Capabilities.Streaming = false, want true") + } + if len(publicCoderCard.Signatures) != 0 { + t.Errorf("Signatures = %+v, want empty", publicCoderCard.Signatures) + } + if _, ok := publicCoderCard.SecuritySchemes["agentmesh_oauth2"]; !ok { + t.Errorf("SecuritySchemes missing agentmesh_oauth2: %+v", publicCoderCard.SecuritySchemes) + } + + // Unauthenticated invocation receives 401 with RFC 9728 WWW-Authenticate challenge. + unauthResp, err := httpClient.Post(wantInvokeURL, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"message/send"}`)) + if err != nil { + t.Fatalf("unauthenticated POST: %v", err) + } + _ = unauthResp.Body.Close() + if unauthResp.StatusCode != http.StatusUnauthorized || !strings.Contains(unauthResp.Header.Get("WWW-Authenticate"), "/.well-known/oauth-protected-resource") { + t.Fatalf("unauthenticated POST = %d WWW-Authenticate=%q, want 401 with resource_metadata", unauthResp.StatusCode, unauthResp.Header.Get("WWW-Authenticate")) + } + + // Raw OIDC JWT presented directly to /ingress/ is rejected with 401 + RFC 9728 WWW-Authenticate + // (external callers exchange JWTs at POST /oauth/token into Delegated Session Biscuits). + yaroslavJWT := mintJWT(map[string]any{"sub": "yaroslav-sub", "email": "yaroslav@example.com"}) + rawJWTReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, wantInvokeURL, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"message/send"}`)) + rawJWTReq.Header.Set("Content-Type", "application/json") + rawJWTReq.Header.Set("Authorization", "Bearer "+yaroslavJWT) + rawJWTResp, err := httpClient.Do(rawJWTReq) + if err != nil { + t.Fatalf("raw JWT POST: %v", err) + } + _ = rawJWTResp.Body.Close() + if rawJWTResp.StatusCode != http.StatusUnauthorized || !strings.Contains(rawJWTResp.Header.Get("WWW-Authenticate"), "/.well-known/oauth-protected-resource") { + t.Fatalf("raw JWT POST = %d WWW-Authenticate=%q, want 401 with resource_metadata", rawJWTResp.StatusCode, rawJWTResp.Header.Get("WWW-Authenticate")) + } + + // Delegated Session Biscuit permitted by mesh role ("external-caller") but excluded by ServiceIngress.allow gets 403. + otherJWT := mintJWT(map[string]any{"sub": "other-sub", "email": "other@example.com"}) + otherBiscuit := exchangeJWTForBiscuit(t, srv.PublicURL(), otherJWT, "", false) + deniedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(otherBiscuit, nil))) + if err != nil { + t.Fatalf("NewFromCard(other): %v", err) + } + if _, err := deniedClient.SendMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hi"))}); err == nil { + t.Fatal("SendMessage with caller excluded by ServiceIngress.allow succeeded, want error") + } + + // Authorized Delegated Session Biscuit succeeds on both SendMessage and SSE SendStreamingMessage. + yaroslavBiscuit := exchangeJWTForBiscuit(t, srv.PublicURL(), yaroslavJWT, "", false) + allowedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(yaroslavBiscuit, nil))) + if err != nil { + t.Fatalf("NewFromCard(yaroslav): %v", err) + } + sendRes, err := allowedClient.SendMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hi"))}) + if err != nil { + t.Fatalf("SendMessage(yaroslav): %v", err) + } + replyMsg, ok := sendRes.(*a2a.Message) + if !ok || len(replyMsg.Parts) == 0 || replyMsg.Parts[0].Text() != "hello from laptop" { + t.Fatalf("SendMessage reply = %#v, want 'hello from laptop'", sendRes) + } + evPrincipal, _ := lastPrincipal.Load().(string) + if !strings.Contains(evPrincipal, "yaroslav") { + t.Fatalf("backend X-Mesh-Principal = %q, want yaroslav identity", evPrincipal) + } + + var streamed []a2a.Event + for ev, sErr := range allowedClient.SendStreamingMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("stream"))}) { + if sErr != nil { + t.Fatalf("SendStreamingMessage: %v", sErr) + } + streamed = append(streamed, ev) + if len(streamed) == 1 { + close(firstStreamSeen) + } + } + if len(streamed) != 2 { + t.Fatalf("streamed %d events, want 2", len(streamed)) + } + + // Exchange OIDC JWT at POST /oauth/token into a TAR-attenuated, sealed Session Biscuit + // scoped exclusively to mcp://tools tool "read_file". + tarJSON := `{"name":"read-only","rules":[{"allowed_services":["mcp://tools"],"operation":{"allowed_tools":["read_file"]}}]}` + tarBiscuit := exchangeJWTForBiscuit(t, srv.PublicURL(), yaroslavJWT, tarJSON, true) + + mcpURL := fmt.Sprintf("%s/%s/mcp/tools", srv.IngressURL(), peerID) + mcpCli := mcp.NewClient(&mcp.Implementation{Name: "external-mcp-caller", Version: "1.0.0"}, nil) + var mcpSess *mcp.ClientSession + deadline = time.Now().Add(5 * time.Second) + for { + mcpSess, err = mcpCli.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: mcpURL, + HTTPClient: bearerClient(tarBiscuit, nil), + }, nil) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("MCP Connect over /ingress failed: %v", err) + } + time.Sleep(50 * time.Millisecond) + } + t.Cleanup(func() { _ = mcpSess.Close() }) + + if _, err := mcpSess.CallTool(ctx, &mcp.CallToolParams{Name: "read_file"}); err != nil { + t.Fatalf("TAR-permitted MCP tool read_file failed: %v", err) + } + if _, err := mcpSess.CallTool(ctx, &mcp.CallToolParams{Name: "exec_shell"}); err == nil { + t.Fatal("TAR-excluded MCP tool exec_shell succeeded, want 403 rejection") + } + + // Opt-in PASSTHROUGH mode preserves upstream pair token and strips spoofed X-AgentMesh-* headers. + pairedPublicBase := fmt.Sprintf("%s/%s/a2a/paired", srv.IngressURL(), peerID) + pairedPublicCard, err := resolver.Resolve(ctx, pairedPublicBase) + if err != nil { + t.Fatalf("resolve paired AgentCard: %v", err) + } + if _, hasOAuth := pairedPublicCard.SecuritySchemes["agentmesh_oauth2"]; hasOAuth { + t.Errorf("PASSTHROUGH card unexpectedly injected agentmesh_oauth2: %+v", pairedPublicCard.SecuritySchemes) + } + if _, hasPair := pairedPublicCard.SecuritySchemes["pair_bearer"]; !hasPair { + t.Errorf("PASSTHROUGH card lost upstream pair_bearer scheme: %+v", pairedPublicCard.SecuritySchemes) + } + + badPairClient, err := a2aclient.NewFromCard(ctx, pairedPublicCard, a2aclient.WithJSONRPCTransport(bearerClient("wrong-token", map[string]string{ + api.HeaderMeshPrincipal: "spoofed-admin", + api.HeaderMeshRoles: "admin", + api.HeaderMeshIngress: "edge", + }))) + if err != nil { + t.Fatalf("NewFromCard(badPair): %v", err) + } + if _, err := badPairClient.SendMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hi"))}); err == nil { + t.Fatal("PASSTHROUGH SendMessage with wrong pair token succeeded, want error") + } + + goodPairClient, err := a2aclient.NewFromCard(ctx, pairedPublicCard, a2aclient.WithJSONRPCTransport(bearerClient("a2p_pair_123", map[string]string{ + api.HeaderMeshPrincipal: "spoofed-admin", + api.HeaderMeshRoles: "admin", + api.HeaderMeshIngress: "edge", + }))) + if err != nil { + t.Fatalf("NewFromCard(goodPair): %v", err) + } + pairRes, err := goodPairClient.SendMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hi"))}) + if err != nil { + t.Fatalf("PASSTHROUGH SendMessage with valid pair token: %v", err) + } + pairReply, ok := pairRes.(*a2a.Message) + if !ok || len(pairReply.Parts) == 0 || pairReply.Parts[0].Text() != "paired ok" { + t.Fatalf("PASSTHROUGH reply = %#v, want 'paired ok'", pairRes) + } + if !passthroughSpoofStripped.Load() { + t.Fatal("PASSTHROUGH backend saw spoofed X-AgentMesh-* headers") + } +} + +// TestFederatedMultiRouterIngressRoleCeilingAndBan verifies: +// - A NAT-ed laptop node with zero open listen ports (ReachabilityPrivate), attached ONLY to +// Router A (tier=relay), is reachable through Router B's standalone --ingress-addr HTTP gateway +// over Router A's circuit relay (/p2p//p2p-circuit/p2p/). +// - PolicyRole.allowed_ingress ceiling: when mesh:role:node grants EDGE only on a2a://coder, +// attempting to expose a2a://ungranted (PASSTHROUGH) is refused on external ingress (404 on Router B) +// while automatically falling back to plain internal directory announcement so mesh peers can still +// discover a2a://ungranted internally. +// - Banning the provider node via POST /admin/revoke immediately causes Router B to return 404. +func TestFederatedMultiRouterIngressRoleCeilingAndBan(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + oidcURL, mintJWT := startCustomMockOIDC(t) + tmpDir := t.TempDir() + + store, err := storage.NewSQLStore("sqlite", filepath.Join(tmpDir, "cp.db")) + if err != nil { + t.Fatalf("NewSQLStore: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + const adminToken = "mesh_adm_integration_test" + cp, err := controlplane.NewServer(controlplane.Options{ + ListenAddr: "127.0.0.1:0", + OIDCIssuer: oidcURL, + AllowedAudiences: []string{"agentmesh-audience"}, + BiscuitTimeout: 10 * time.Second, + AdminToken: adminToken, + AutoApproveEnrollment: true, + }, store) + if err != nil { + t.Fatalf("controlplane.NewServer: %v", err) + } + if err := cp.Start(); err != nil { + t.Fatalf("cp.Start: %v", err) + } + t.Cleanup(func() { _ = cp.Close() }) + cpURL := "http://" + cp.Addr() + + postOperatorPolicy(t, ctx, cpURL, adminToken, &api.PolicyConfig{ + Roles: []*api.PolicyRole{ + { + Name: api.RoleRouter, + AllowedTargets: []string{"*"}, + AllowedServices: []string{"*"}, + AllowedLabels: []string{"tier=*"}, + }, + { + Name: api.RoleNode, + AllowedTargets: []string{"*"}, + AllowedServices: []string{"*"}, + AllowedPublish: []string{"*"}, + // Empty allowed_auth_modes grants EDGE only on a2a://coder. + AllowedIngress: []*api.IngressGrant{ + {Services: []string{"a2a://coder"}}, + }, + }, + { + Name: "external-caller", + AllowedTargets: []string{"*"}, + AllowedServices: []string{"a2a://coder"}, + }, + }, + Bindings: []*api.PolicyBinding{ + {Role: api.RoleRouter, Members: []string{"group:routers"}}, + {Role: api.RoleNode, Members: []string{"email:node-owner@example.com"}}, + {Role: "external-caller", Members: []string{"email:alan@example.com"}}, + }, + }) + + routerJWT := mintJWT(map[string]any{ + "sub": "router-svc", + "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, + }) + + rtrA, err := router.NewRouter(ctx, router.Options{ + ControlPlaneURL: cpURL, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + AllowLoopback: true, + KeysDBPath: filepath.Join(tmpDir, "routerA.key"), + OIDCToken: routerJWT, + Labels: map[string]string{"tier": "relay"}, + ControlPlaneSyncInterval: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRouter(A): %v", err) + } + if err := rtrA.Start(); err != nil { + t.Fatalf("rtrA.Start: %v", err) + } + t.Cleanup(func() { _ = rtrA.Close() }) + + meshAdapter, err := controlplane.NewP2PMeshAdapter(rtrA.Host, rtrA.EventTopic, store) + if err != nil { + t.Fatalf("NewP2PMeshAdapter: %v", err) + } + t.Cleanup(func() { _ = meshAdapter.Close() }) + cp.SetMeshAdapter(meshAdapter) + + rtrB, err := router.NewRouter(ctx, router.Options{ + ControlPlaneURL: cpURL, + PublicControlPlaneURL: cpURL, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + AllowLoopback: true, + KeysDBPath: filepath.Join(tmpDir, "routerB.key"), + OIDCToken: routerJWT, + Labels: map[string]string{"tier": "edge"}, + IngressAddr: "127.0.0.1:0", + AllowInsecureIngressHTTP: true, + ControlPlaneSyncInterval: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("NewRouter(B): %v", err) + } + if err := rtrB.Start(); err != nil { + t.Fatalf("rtrB.Start: %v", err) + } + t.Cleanup(func() { _ = rtrB.Close() }) + + if rtrB.IngressAddr() == nil { + t.Fatal("rtrB.IngressAddr() is nil") + } + ingressBaseB := "http://" + rtrB.IngressAddr().String() + "/ingress" + + // Wait until both routers have leased and authenticated with each other. + deadline := time.Now().Add(5 * time.Second) + for len(rtrB.Host.Network().ConnsToPeer(rtrA.Host.ID())) == 0 { + if time.Now().After(deadline) { + t.Fatal("Router B did not federate with Router A within 5s") + } + time.Sleep(50 * time.Millisecond) + } + + execFn := a2asrv.AgentExecutorFunc(func(_ context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewMessageForTask(a2a.MessageRoleAgent, ec, a2a.NewTextPart("relayed via router A")), nil) + } + }) + card := &a2a.AgentCard{ + Name: "coder", + Version: "1.0.0", + SupportedInterfaces: []*a2a.AgentInterface{ + {URL: "http://127.0.0.1:9997/invoke", ProtocolBinding: a2a.TransportProtocolJSONRPC, ProtocolVersion: "1.0"}, + }, + } + backendMux := http.NewServeMux() + backendMux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(card)) + backendMux.Handle("/invoke", a2asrv.NewJSONRPCHandler(a2asrv.NewHandler(execFn))) + backend := httptest.NewServer(backendMux) + t.Cleanup(backend.Close) + + // Enroll a private node (zero listen addresses!) attached strictly to Router A (tier=relay). + nodeJWT := mintJWT(map[string]any{"sub": "node-owner", "email": "node-owner@example.com"}) + provider := enrollAndStartRuntimeNode( + t, + ctx, + cpURL, + "", + nodeJWT, + map[string]string{"tier": "relay"}, + node.ReachabilityPrivate, + []string{}, + []api.ServiceConfig{ + { + Type: "a2a", + Name: "coder", + TargetURL: backend.URL, + Ingress: &api.IngressConfig{AuthMode: "edge"}, + }, + { + Type: "a2a", + Name: "ungranted", + TargetURL: backend.URL, + Ingress: &api.IngressConfig{AuthMode: "passthrough"}, + }, + }, + ) + peerID := provider.Host.ID() + + // Wait until the private node announces a /p2p-circuit address via Router A and + // Router B can resolve the public AgentCard across Router B -> Router A relay -> node. + httpClient := &http.Client{Timeout: 5 * time.Second} + resolver := agentcard.NewResolver(httpClient) + coderURL := fmt.Sprintf("%s/%s/a2a/coder", ingressBaseB, peerID) + var resolvedCard *a2a.AgentCard + deadline = time.Now().Add(8 * time.Second) + for { + var rErr error + resolvedCard, rErr = resolver.Resolve(ctx, coderURL) + if rErr == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("Router B failed to resolve relayed AgentCard on %s: %v", coderURL, rErr) + } + time.Sleep(100 * time.Millisecond) + } + + // Ungranted PASSTHROUGH service was rejected on external ingress -> 404 on Router B, + // while announceServiceKeys fell back to plain Provide so internal mesh discovery still finds it. + ungrantedResp, err := httpClient.Get(fmt.Sprintf("%s/%s/a2a/ungranted/.well-known/agent-card.json", ingressBaseB, peerID)) + if err != nil { + t.Fatalf("GET ungranted passthrough service: %v", err) + } + _ = ungrantedResp.Body.Close() + if ungrantedResp.StatusCode != http.StatusNotFound { + t.Fatalf("ungranted passthrough service status = %d, want 404", ungrantedResp.StatusCode) + } + ungrantedKey, err := api.ServiceKey(api.ServiceType_SERVICE_TYPE_A2A, "ungranted") + if err != nil { + t.Fatalf("ServiceKey(a2a, ungranted): %v", err) + } + internalProviders, err := provider.Directory.FindProviders(ctx, ungrantedKey, 10) + if err != nil { + t.Fatalf("FindProviders(a2a://ungranted): %v", err) + } + foundInternal := false + for _, pi := range internalProviders { + if pi.ID == peerID { + foundInternal = true + break + } + } + if !foundInternal { + t.Fatalf("a2a://ungranted missing from internal directory after ingress ceiling refusal; got providers %v", internalProviders) + } + + // External caller exchanges OIDC JWT at POST /oauth/token and invokes a2a://coder through Router B -> Router A circuit relay -> private node. + alanJWT := mintJWT(map[string]any{"sub": "alan-sub", "email": "alan@example.com"}) + alanBiscuit := exchangeJWTForBiscuit(t, cpURL, alanJWT, "", false) + a2aCli, err := a2aclient.NewFromCard(ctx, resolvedCard, a2aclient.WithJSONRPCTransport(bearerClient(alanBiscuit, nil))) + if err != nil { + t.Fatalf("NewFromCard(alan): %v", err) + } + res, err := a2aCli.SendMessage(ctx, &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping"))}) + if err != nil { + t.Fatalf("relayed SendMessage failed: %v", err) + } + msg, ok := res.(*a2a.Message) + if !ok || len(msg.Parts) == 0 || msg.Parts[0].Text() != "relayed via router A" { + t.Fatalf("relayed SendMessage reply = %#v, want 'relayed via router A'", res) + } + + // Admin bans the provider node -> BANNED gossip propagates from CP -> Router A -> Router B -> 404 immediately. + revokePayload, err := (protojson.MarshalOptions{UseProtoNames: true}).Marshal(&api.TokenRevokeRequest{PeerId: peerID.String()}) + if err != nil { + t.Fatalf("marshal TokenRevokeRequest: %v", err) + } + revokeReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cpURL+"/admin/revoke", bytes.NewReader(revokePayload)) + if err != nil { + t.Fatalf("new POST /admin/revoke: %v", err) + } + revokeReq.Header.Set("Authorization", "Bearer "+adminToken) + revokeReq.Header.Set("Content-Type", "application/json") + revokeResp, err := httpClient.Do(revokeReq) + if err != nil { + t.Fatalf("POST /admin/revoke: %v", err) + } + _ = revokeResp.Body.Close() + if revokeResp.StatusCode != http.StatusOK { + t.Fatalf("POST /admin/revoke = %s, want 200", revokeResp.Status) + } + + deadline = time.Now().Add(5 * time.Second) + for { + resp, gErr := httpClient.Get(coderURL + "/.well-known/agent-card.json") + if gErr == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + break + } + } + if time.Now().After(deadline) { + t.Fatal("banned provider node did not become 404 on Router B within 5s") + } + time.Sleep(50 * time.Millisecond) + } +}