From b196ded31e4c0282338cfdbb2ad0f9df511463c4 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 31 Oct 2026 20:21:14 +0000 Subject: [PATCH 1/4] router, node, standalone: external HTTP ingress for a2a, mcp, and inference services Add zero-trust external HTTP ingress so callers outside the mesh can reach explicitly opted-in a2a://, mcp://, and inference:// services on enrolled agentmesh-node members through agentmesh-router (or agentmesh-one's single port) without routing data-plane traffic through agentmesh-control-plane. Two-layer opt-in: - Mesh policy admins set the ceiling via PolicyRole.allowed_ingress (repeated IngressGrant), compiled into Datalog rules by the control plane and enforced at directory announcement (RequireIngress). Omitting allowed_auth_modes grants INGRESS_AUTH_MODE_EDGE only; PASSTHROUGH requires an explicit role grant. - Service owners opt in per service via ServiceIngress (ingress: in agentmesh-node.yaml), with optional allow narrowing in PolicyBinding.members syntax (e.g. "email:alice@example.com", "group:eng"). Default edge authentication (INGRESS_AUTH_MODE_EDGE): - Routers serve path-based endpoints at /ingress///[/] plus RFC 9728 /.well-known/oauth-protected-resource, returning 401 WWW-Authenticate with resource_metadata when unauthenticated. - Verifies Delegated Session Biscuits, RFC 8693 task-attenuated Task Biscuits, and trusted OIDC JWTs against mesh Datalog policy, TAR blocks, revocations, banned peers, and ServiceIngress.allow, while rejecting unattenuated standing member/router transport Biscuits. - Rewrites public A2A AgentCard URLs and populates OAuth 2.1 / OIDC / Bearer securitySchemes on GET /.well-known/agent-card.json (preserving backend securitySchemes in PASSTHROUGH mode). - Streams across local or federated peer routers over /mesh/ingress/1.0.0 to the serving node, which admits only authenticated mesh:role:router peers, re-verifies local ingress opt-in and caller authorization end-to-end, strips spoofed X-Mesh-* headers, and injects verified attribution headers. Deployment & operations: - Enabled out of the box with zero extra flags on agentmesh-one. - Added --ingress-addr, --ingress-url, --public-control-plane, --ingress-tls-cert-path, --ingress-tls-key-path, and --allow-insecure-ingress-http on agentmesh-router, surfaced RouterInfo.ingress_url on /info and the console (SQLite/Postgres schema v21), and wired the Helm chart and GKE Gateway HTTPRoute (/ingress/* -> agentmesh-router). --- .../k8s/agentmesh-control-plane-template.yaml | 30 + .github/k8s/agentmesh-router-template.yaml | 21 + .github/workflows/deploy.yaml | 4 +- api/agentmesh.pb.go | 1169 ++++++++++------- api/agentmesh.proto | 54 + api/datalog.go | 125 ++ api/ingress.go | 245 ++++ api/ingress_test.go | 322 +++++ api/network.go | 21 + api/policy.go | 12 +- api/policy_rules.go | 3 + charts/agentmesh/README.md | 6 +- charts/agentmesh/templates/bootstrap-job.yaml | 2 +- charts/agentmesh/templates/gateway.yaml | 12 + .../agentmesh/templates/router-service.yaml | 6 + .../templates/router-statefulset.yaml | 16 + .../agentmesh/tests/bootstrap-job_test.yaml | 4 +- charts/agentmesh/tests/gateway_test.yaml | 35 +- .../tests/router-statefulset_test.yaml | 28 + charts/agentmesh/values.yaml | 21 +- cmd/agentmesh-one/main.go | 1 + cmd/agentmesh-router/main.go | 24 + hack/gen-sdk-datalog/main.go | 2 + internal/controlplane/catalog.go | 9 + internal/controlplane/server.go | 11 + internal/controlplane/views.go | 1 + internal/envoy/server.go | 15 +- internal/identity/biscuit.go | 80 +- internal/identity/ingress.go | 392 ++++++ internal/identity/ingress_test.go | 377 ++++++ internal/node/config.go | 11 +- internal/node/debug_handlers.go | 2 + internal/node/directory_client.go | 6 +- internal/node/ingress.go | 535 ++++++++ internal/node/ingress_test.go | 554 ++++++++ internal/node/node.go | 20 +- internal/node/oidc.go | 14 +- internal/node/resource_limits.go | 6 +- internal/node/routers.go | 2 + internal/node/service.go | 8 + internal/node/service_registry.go | 33 +- internal/node/sts_test.go | 24 + internal/router/config.go | 80 ++ internal/router/directory.go | 124 +- internal/router/directory_store.go | 66 +- internal/router/ingress.go | 1040 +++++++++++++++ internal/router/ingress_test.go | 772 +++++++++++ internal/router/resource_limits.go | 2 + internal/router/router.go | 119 +- internal/standalone/standalone.go | 51 +- internal/standalone/standalone_test.go | 108 ++ internal/storage/migration_test.go | 77 ++ internal/storage/round_trip_test.go | 1 + internal/storage/sql_store.go | 49 +- internal/storage/storage.go | 1 + sdk/js/src/gen/agentmesh_pb.ts | 295 ++++- sdk/js/src/gen/datalog.ts | 9 + sdk/python/src/agent_mesh/_gen/datalog.json | 9 + .../src/agent_mesh/_proto/agentmesh_pb2.py | 308 ++--- .../src/agent_mesh/_proto/agentmesh_pb2.pyi | 64 +- site/content/docs/guides/external-ingress.md | 192 +++ site/content/docs/reference/node-config.md | 1 + site/content/docs/reference/policy.md | 1 + site/content/docs/reference/router.md | 6 + tests/e2e/fixtures/fake_openai.py | 32 + tests/e2e/standalone.bats | 58 + tests/integration/ingress_test.go | 844 ++++++++++++ 67 files changed, 7795 insertions(+), 777 deletions(-) create mode 100644 api/ingress.go create mode 100644 api/ingress_test.go create mode 100644 internal/identity/ingress.go create mode 100644 internal/identity/ingress_test.go create mode 100644 internal/node/ingress.go create mode 100644 internal/node/ingress_test.go create mode 100644 internal/router/ingress.go create mode 100644 internal/router/ingress_test.go create mode 100644 site/content/docs/guides/external-ingress.md create mode 100644 tests/integration/ingress_test.go 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..5a2a35a5 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,9 @@ 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" + - "--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..7731063d 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 verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + 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..be559300 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 verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + 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..75358ca3 --- /dev/null +++ b/api/ingress.go @@ -0,0 +1,245 @@ +// 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/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. +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 + } + 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) + } + 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 +} + +// 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..13ada69a --- /dev/null +++ b/api/ingress_test.go @@ -0,0 +1,322 @@ +// 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" + "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") + } +} + +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/network.go b/api/network.go index ffc1d612..762b228b 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,23 @@ 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" + + // HeaderMeshIngressClaims carries base64url-encoded JSON OIDC fact claims + // verified at the router edge over IngressProtocolID when the external caller + // authenticated with a trusted OIDC JWT rather than a Mesh Biscuit. Stripped + // before forwarding to the backend service. + HeaderMeshIngressClaims = "X-Mesh-Ingress-Claims" ) // ============================================================================ 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..fd0414b2 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,9 @@ 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. + ingressRoute: true # External prefix the console is served under. Empty leaves the console unrouted. consolePath: /console listeners: @@ -153,6 +156,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 +239,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..263854d7 100644 --- a/cmd/agentmesh-router/main.go +++ b/cmd/agentmesh-router/main.go @@ -76,6 +76,14 @@ var ( shutdownLeaseTTL time.Duration authDeadline time.Duration labelFlags []string + ingressAddr string + ingressURL string + ingressTLSCertPath string + ingressTLSKeyPath string + allowInsecureIngressHTTP bool + oidcIssuer string + audience string + publicControlPlaneURL string ) var logger = golog.Logger("agentmesh-router-cli") @@ -110,6 +118,14 @@ func main() { RelayMaxCircuits: relayMaxCircuits, ShutdownLeaseTTL: shutdownLeaseTTL, AuthDeadline: authDeadline, + IngressAddr: ingressAddr, + IngressURL: ingressURL, + IngressTLSCertPath: ingressTLSCertPath, + IngressTLSKeyPath: ingressTLSKeyPath, + AllowInsecureIngressHTTP: allowInsecureIngressHTTP, + OIDCIssuer: oidcIssuer, + Audience: audience, + PublicControlPlaneURL: publicControlPlaneURL, } labels, err := api.ParseLabelFlags(labelFlags) if err != nil { @@ -169,6 +185,14 @@ 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 (only when behind a TLS-terminating L7 load balancer) and allow http:// in --ingress-url") + rootCmd.Flags().StringVar(&oidcIssuer, "oidc-issuer", "", "Optional OIDC issuer URL override for verifying OIDC JWTs on edge ingress (defaults to the control plane's /info oidc_issuer)") + rootCmd.Flags().StringVar(&audience, "audience", "", "Optional OIDC audience override for verifying OIDC JWTs on edge ingress (defaults to the control plane's /info audience)") + 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..989602e6 100644 --- a/internal/envoy/server.go +++ b/internal/envoy/server.go @@ -482,8 +482,8 @@ 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"` @@ -495,7 +495,8 @@ func InspectJSONRPCMCPBody(body []byte) (mcpTool string, allowStreamInit bool) { return "", false } switch rpc.Method { - case "initialize", "ping", "tools/list": + case "initialize", "notifications/initialized", "server/discover", "ping", + "tools/list", "resources/list", "prompts/list", "notifications/cancelled": return "", true case "tools/call": rawTool := strings.TrimSpace(rpc.Params.Name) @@ -514,7 +515,13 @@ func InspectJSONRPCMCPBody(body []byte) (mcpTool string, allowStreamInit bool) { // 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) { + 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 } bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, MaxGRPCMessageBytes+1)) 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..adc8baa6 --- /dev/null +++ b/internal/identity/ingress.go @@ -0,0 +1,392 @@ +// 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" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/eclipse-biscuit/biscuit-go/v2" + "github.com/google/agentmesh/api" +) + +var ( + ephemeralOnce sync.Once + ephemeralPub ed25519.PublicKey + ephemeralPriv ed25519.PrivateKey +) + +func ephemeralEvalKeys() (ed25519.PublicKey, ed25519.PrivateKey) { + ephemeralOnce.Do(func() { + var err error + ephemeralPub, ephemeralPriv, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(fmt.Sprintf("failed to generate ephemeral evaluation key: %v", err)) + } + }) + return ephemeralPub, ephemeralPriv +} + +// 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) +} + +// OIDCFactsFromJWTClaims extracts standard Agent Mesh OIDC identity facts +// ("user", "email", "group", "idp_role") from verified JWT claims. FactRole and +// FactNode are never derived from OIDC claims. +func OIDCFactsFromJWTClaims(claims map[string]any) map[string][]string { + out := make(map[string][]string) + if len(claims) == 0 { + return out + } + claimMap := api.OIDCClaimToFact() + for claimKey, factName := range claimMap { + raw, ok := claims[claimKey] + if !ok || raw == nil { + continue + } + seen := make(map[string]bool) + for _, item := range toStringSlice(raw) { + if item == "" || seen[item] { + continue + } + seen[item] = true + out[factName] = append(out[factName], item) + } + sort.Strings(out[factName]) + } + return out +} + +func allowedOIDCFactNames() map[string]bool { + allowed := make(map[string]bool) + for _, factName := range api.OIDCClaimToFact() { + allowed[factName] = true + } + return allowed +} + +// SanitizeOIDCFacts returns a copy containing only permitted OIDC fact names +// ("user", "email", "group", "idp_role") and non-empty deduplicated values. +func SanitizeOIDCFacts(facts map[string][]string) map[string][]string { + allowed := allowedOIDCFactNames() + clean := make(map[string][]string) + for k, vals := range facts { + if !allowed[k] { + continue + } + seen := make(map[string]bool) + for _, v := range vals { + if v == "" || seen[v] { + continue + } + seen[v] = true + clean[k] = append(clean[k], v) + } + if len(clean[k]) > 0 { + sort.Strings(clean[k]) + } + } + return clean +} + +// MarshalIngressClaimsHeader encodes verified OIDC fact claims for transport +// over IngressProtocolID from an authenticated router to the serving node. +func MarshalIngressClaimsHeader(facts map[string][]string) (string, error) { + clean := SanitizeOIDCFacts(facts) + raw, err := json.Marshal(clean) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// UnmarshalIngressClaimsHeader decodes and sanitizes verified OIDC fact claims +// received over IngressProtocolID from an authenticated router. +func UnmarshalIngressClaimsHeader(encoded string) (map[string][]string, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("invalid base64url ingress claims header: %w", err) + } + var parsed map[string][]string + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("invalid json ingress claims header: %w", err) + } + return SanitizeOIDCFacts(parsed), nil +} + +// 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 +} + +// AuthorizeIngressDelegatedBiscuit authorizes a verified Delegated Session +// Biscuit or task-attenuated Task Biscuit (minted via RFC 8693 /oauth/token 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) { + 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") + } + + 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 + } + + 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") + } + + actorNodeID := claims.ActorNodePeerID + if actorNodeID == "" { + actorNodeID = claims.NodePeerID + } + + return &IngressCallerResult{ + Principal: claims.Principal(), + Roles: roles, + TaskName: claims.InnermostTaskName(), + ActorNodeID: actorNodeID, + IdentityFacts: idMap, + }, nil +} + +// AuthorizeIngressOIDCFacts authorizes verified OIDC JWT fact claims against +// mesh Datalog policy, local node attenuation, and ServiceIngress.allow using +// the exact same Biscuit Datalog authorizer as Delegated Session Biscuits. +func AuthorizeIngressOIDCFacts(oidcFacts map[string][]string, p IngressEvalParams) (*IngressCallerResult, error) { + clean := SanitizeOIDCFacts(oidcFacts) + if len(clean) == 0 { + return nil, fmt.Errorf("verified OIDC token carries no identity claims") + } + + pub, priv := ephemeralEvalKeys() + builder := biscuit.NewBuilder(priv) + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactExpiration, + IDs: []biscuit.Term{biscuit.Date(time.Now().Add(5 * time.Minute))}, + }}); err != nil { + return nil, err + } + keys := make([]string, 0, len(clean)) + for k := range clean { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + for _, v := range clean[k] { + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: k, + IDs: []biscuit.Term{biscuit.String(v)}, + }}); err != nil { + return nil, err + } + } + } + tok, err := builder.Build() + if err != nil { + return nil, err + } + authorizer, err := tok.Authorizer(pub, AuthorizerOptions(p.Timeout)...) + if err != nil { + return nil, err + } + roles, err := runIngressAuthorizer(authorizer, p) + if err != nil { + return nil, err + } + + if !api.MatchIngressAllow(p.Allow, clean) { + return nil, fmt.Errorf("caller does not satisfy service ingress allow list") + } + + var principal string + if emails := clean[api.FactEmail]; len(emails) > 0 { + principal = emails[0] + } else if users := clean[api.FactUser]; len(users) > 0 { + principal = users[0] + } + + return &IngressCallerResult{ + Principal: principal, + Roles: roles, + IdentityFacts: clean, + }, nil +} diff --git a/internal/identity/ingress_test.go b/internal/identity/ingress_test.go new file mode 100644 index 00000000..40f27aff --- /dev/null +++ b/internal/identity/ingress_test.go @@ -0,0 +1,377 @@ +// 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. Verified OIDC JWT claims round-trip through X-Mesh-Ingress-Claims and evaluate identically. + oidcFacts := OIDCFactsFromJWTClaims(map[string]any{ + "sub": "uid-2", + "email": "alan@google.com", + "groups": []any{"a2a"}, + "roles": []string{"mesh:role:router"}, // mapped to idp_role, NEVER role! + }) + hdr, err := MarshalIngressClaimsHeader(oidcFacts) + if err != nil { + t.Fatalf("MarshalIngressClaimsHeader: %v", err) + } + unmarshaled, err := UnmarshalIngressClaimsHeader(hdr) + if err != nil { + t.Fatalf("UnmarshalIngressClaimsHeader: %v", err) + } + oidcRes, err := AuthorizeIngressOIDCFacts(unmarshaled, IngressEvalParams{ + ServiceType: "a2a", + ServiceName: "assistant", + Method: "POST", + Path: "/message:send", + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: meshRules, + Allow: []string{"email:*@google.com"}, + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("AuthorizeIngressOIDCFacts: %v", err) + } + if oidcRes.Principal != "alan@google.com" { + t.Fatalf("got principal %q, want alan@google.com", oidcRes.Principal) + } + for _, r := range oidcRes.Roles { + if r == api.RoleRouter { + t.Fatal("OIDC roles claim must never become mesh role") + } + } +} + +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/config.go b/internal/node/config.go index d74f706c..77a72a09 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -110,9 +110,18 @@ 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) + } + if _, err := api.ServiceIngressFromConfig(sType, svc.Ingress); err != nil { + return nil, fmt.Errorf("service %q: %w", svc.Name, err) + } + } 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/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..28a6e735 --- /dev/null +++ b/internal/node/ingress.go @@ -0,0 +1,535 @@ +// 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. +func (n *AgentMeshNode) StartExternalIngressServer(ctx context.Context) error { + gated := &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 { + 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 { + stripExternalIngressHeaders(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)) + callerClaimsHeader := strings.TrimSpace(r.Header.Get(api.HeaderMeshIngressClaims)) + if serviceType == api.ServiceType_SERVICE_TYPE_A2A && + r.Method == http.MethodGet && + upstreamPath == a2aAgentCardPath && + callerBiscuitHeader == "" && + callerClaimsHeader == "" { + stripExternalIngressHeaders(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 == "" && callerClaimsHeader == "" { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeUnauthenticated).Inc() + http.Error(w, "Missing caller credentials on edge ingress request", http.StatusUnauthorized) + return + } + if callerBiscuitHeader != "" && callerClaimsHeader != "" { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() + http.Error(w, "Ambiguous caller credentials on edge ingress request", http.StatusBadRequest) + 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 + } + + authorizeCaller := func(params identity.IngressEvalParams) (*identity.IngressCallerResult, []byte, int, string) { + if callerBiscuitHeader != "" { + rawCaller, decErr := identity.DecodeBase64Biscuit(callerBiscuitHeader) + if decErr != nil { + return nil, nil, http.StatusBadRequest, "Invalid caller biscuit encoding" + } + verifiedCaller, verErr := n.VerifyLocalBiscuit(rawCaller) + if verErr != nil { + return nil, nil, http.StatusForbidden, fmt.Sprintf("Authorization failed: %v", verErr) + } + res, authErr := identity.AuthorizeIngressDelegatedBiscuit(verifiedCaller, params) + if authErr != nil { + return nil, nil, http.StatusForbidden, "Authorization failed" + } + return res, rawCaller, http.StatusOK, "" + } + oidcFacts, decErr := identity.UnmarshalIngressClaimsHeader(callerClaimsHeader) + if decErr != nil { + return nil, nil, http.StatusBadRequest, "Invalid caller claims header" + } + res, authErr := identity.AuthorizeIngressOIDCFacts(oidcFacts, params) + if authErr != nil { + return nil, nil, http.StatusForbidden, "Authorization failed" + } + return res, nil, http.StatusOK, "" + } + + if serviceType == api.ServiceType_SERVICE_TYPE_MCP { + preParams := evalParams + preParams.AllowMCPStreamInit = true + if _, _, status, msg := authorizeCaller(preParams); status != http.StatusOK { + outcome := externalIngressOutcomeDeny + if status == http.StatusBadRequest { + outcome = externalIngressOutcomeInvalid + } + externalIngressRequestsTotal.WithLabelValues(modeMetric, outcome).Inc() + logger.Warnf("[ExternalIngress] Pre-body MCP AuthZ failed via router %s for %s: %s", remotePeer, target, msg) + if status == http.StatusBadRequest { + http.Error(w, msg, status) + } else { + refuse(w, status, "Authorization failed", proxyStatusDenied) + } + return + } + mcpTool, allowInit, bodyErr := inspectMCPHTTPRequestBody(r) + if bodyErr != nil { + externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeDeny).Inc() + refuse(w, http.StatusRequestEntityTooLarge, bodyErr.Error(), proxyStatusDenied) + return + } + evalParams.MCPTool = mcpTool + evalParams.AllowMCPStreamInit = allowInit + } + + callerRes, callerBiscuitBytes, status, errMsg := authorizeCaller(evalParams) + if status != http.StatusOK { + outcome := externalIngressOutcomeDeny + if status == http.StatusBadRequest { + outcome = externalIngressOutcomeInvalid + } + externalIngressRequestsTotal.WithLabelValues(modeMetric, outcome).Inc() + logger.Warnf("[ExternalIngress] AuthZ denied via router %s for %s: %s", remotePeer, target, errMsg) + if status == http.StatusBadRequest { + http.Error(w, errMsg, status) + } else { + refuse(w, status, "Authorization failed", proxyStatusDenied) + } + return + } + + stripExternalIngressHeaders(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 stripExternalIngressHeaders(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, api.HeaderPeerID) { + delete(h, name) + } + } +} + +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..252c6180 --- /dev/null +++ b/internal/node/ingress_test.go @@ -0,0 +1,554 @@ +// 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" + "net/http" + "net/http/httptest" + "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 +} + +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 nil +} + +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 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("edge OIDC claims header evaluated identically against Datalog + local policy", func(t *testing.T) { + claimsHdr, err := identity.MarshalIngressClaimsHeader(map[string][]string{ + api.FactUser: {"uid-alice"}, + api.FactEmail: {"alice@example.com"}, + }) + if err != nil { + t.Fatalf("MarshalIngressClaimsHeader: %v", err) + } + + okReq := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) + okReq.RemoteAddr = routerPID.String() + okReq.Header.Set(api.HeaderMeshBiscuit, routerB64) + okReq.Header.Set(api.HeaderMeshIngressClaims, claimsHdr) + okRec := httptest.NewRecorder() + h.ServeHTTP(okRec, okReq) + if okRec.Code != http.StatusOK { + t.Fatalf("OIDC claims status = %d, want 200: %s", okRec.Code, okRec.Body.String()) + } + if got := capturedHeaders.Get(api.HeaderMeshPrincipal); got != "alice@example.com" { + t.Errorf("X-Mesh-Principal = %q, want alice@example.com", got) + } + + // Local nodeConfig policy denies /admin + 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.HeaderMeshIngressClaims, claimsHdr) + 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("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..a706c9fa 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -198,10 +198,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 +454,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 +704,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 +785,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) diff --git a/internal/node/oidc.go b/internal/node/oidc.go index d485ec5d..455e97c9 100644 --- a/internal/node/oidc.go +++ b/internal/node/oidc.go @@ -178,12 +178,18 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t break } } + if listener == nil && deviceAuthURL == "" { + // When no device flow fallback is available (or AuthModeBrowser + // explicitly disabled it), try an RFC 8252 ephemeral loopback port + // before degrading to deprecated out-of-band paste. + listener, err = net.Listen("tcp", "127.0.0.1:0") + } if listener == nil { if deviceAuthURL != "" { - logger.Warn("Could not bind local OIDC listener (ports 13000-13002 busy); falling back to device authorization flow.") + logger.Warnf("Could not bind local OIDC listener (ports 13000-13002 busy: %v); falling back to device authorization flow.", err) return n.DeviceLogin(ctx, deviceAuthURL, tokenURL, clientID, audience, requestRefresh) } - logger.Warn("Could not bind local OIDC listener (ports 13000-13002 busy). Falling back to headless (OOB) authorization.") + logger.Warnf("Could not bind local OIDC listener (ports 13000-13002 busy: %v). Falling back to headless (OOB) authorization.", err) isHeadless = true redirectURI = "urn:ietf:wg:oauth:2.0:oob" } else { @@ -226,6 +232,9 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t if !isHeadless { if err := openBrowserFunc(targetURL); err != nil { if deviceAuthURL != "" { + if listener != nil { + _ = listener.Close() + } logger.Warnf("Could not open a browser (%v); falling back to device authorization flow.", err) return n.DeviceLogin(ctx, deviceAuthURL, tokenURL, clientID, audience, requestRefresh) } @@ -289,6 +298,7 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t } w.Header().Set("Content-Type", "text/html") + w.Header().Set("Connection", "close") _, _ = w.Write([]byte("

Authorization successful!

You can close this window and return to the CLI.

")) select { 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..205518ac 100644 --- a/internal/node/service.go +++ b/internal/node/service.go @@ -132,6 +132,9 @@ 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) + } switch info.Type { case api.ServiceType_SERVICE_TYPE_MCP: return &MCPService{baseService: baseService{info: info, backend: req.Backend}}, nil @@ -153,11 +156,16 @@ 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) + } 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..8f0d984d 100644 --- a/internal/node/service_registry.go +++ b/internal/node/service_registry.go @@ -33,6 +33,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 +83,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 +134,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 @@ -156,13 +167,13 @@ func (r *ServiceRegistry) Register(ctx context.Context, svc Service) error { } else { nameCtx, nameCancel := context.WithTimeout(ctx, 5*time.Second) defer nameCancel() - if err := r.dht.Provide(nameCtx, nameKey, serviceTarget(info)); err != nil { + if err := r.provide(nameCtx, nameKey, serviceTarget(info), info.GetIngress()); 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 { + if err := r.provide(typeCtx, typeKey, serviceTarget(info), nil); err != nil { logger.Warnf("[ServiceRegistry] Announcing %s by type: %v", info.Name, err) } } @@ -180,6 +191,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 info.GetIngress() != nil && r.onIngressRegistered != nil { + r.onIngressRegistered(info) + } } else if r.reprovideNow != nil { r.reprovideNow() } @@ -218,6 +232,15 @@ func (r *ServiceRegistry) Unregister(ctx context.Context, name string) error { return nil } +func (r *ServiceRegistry) provide(ctx context.Context, key []byte, service string, ingress *api.ServiceIngress) error { + if ingress != nil { + if ip, ok := r.dht.(ingressDirectoryProvider); ok { + return ip.ProvideWithIngress(ctx, key, service, ingress) + } + } + return r.dht.Provide(ctx, key, service) +} + // serviceTarget is a service as a record names it, "://". func serviceTarget(info *api.ServiceInfo) string { typeStr, err := api.ServiceTypeToString(info.GetType()) @@ -367,8 +390,12 @@ Loop: if err != nil { continue } + var ing *api.ServiceIngress + if name != "" { + ing = info.GetIngress() + } provideCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - if err := r.dht.Provide(provideCtx, key, serviceTarget(info)); err != nil && ctx.Err() == nil { + if err := r.provide(provideCtx, key, serviceTarget(info), ing); err != nil && ctx.Err() == nil { logger.Warnf("[ServiceRegistry] Announcing %s/%s again: %v", info.Type, info.Name, err) } cancel() 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..d9ca3f6d 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,37 @@ 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 + // OIDCIssuer and Audience optionally override the OIDC issuer and audience + // learned from the control plane's GET /info when verifying OIDC JWTs on + // INGRESS_AUTH_MODE_EDGE requests. + OIDCIssuer string + Audience string + // 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 +208,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..2483f6a0 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,39 @@ 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) + if entry, err := providerEntryOfRecord(rec); err == nil { + 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 +610,38 @@ 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 } - 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 { 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..d764a599 --- /dev/null +++ b/internal/router/ingress.go @@ -0,0 +1,1040 @@ +// 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" + "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/coreos/go-oidc/v3/oidc" + "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" + + maxIngressBodyBytes = 32 << 20 + maxIngressMCPBodyBytes = 4 << 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, + } { + 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: 60 * time.Second, + } + + 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 (r *Router) leasedIngressURL() string { + if configured := strings.TrimSuffix(strings.TrimSpace(r.config.IngressURL), "/"); configured != "" { + return configured + } + if r.ingressAddr != nil { + scheme := "http" + if r.config.IngressTLSCertPath != "" { + scheme = "https" + } + return scheme + "://" + r.ingressAddr.String() + "/ingress" + } + if r.config.AdvertiseSinglePortIngress && r.Host != nil && r.Host.Network() != nil { + host := strings.TrimSpace(r.config.SinglePortBindHost) + if host == "" { + host = "0.0.0.0" + } + for _, a := range r.Host.Network().ListenAddresses() { + if _, err := a.ValueForProtocol(multiaddr.P_WS); err != nil { + continue + } + port, err := a.ValueForProtocol(multiaddr.P_TCP) + if err != nil { + continue + } + return "http://" + net.JoinHostPort(host, port) + "/ingress" + } + } + return "" +} + +func (r *Router) effectiveIngressBaseURL(req *http.Request) string { + if leased := r.leasedIngressURL(); leased != "" { + return leased + } + scheme := "http" + if r.config.IngressTLSCertPath != "" || (req != nil && req.TLS != nil) { + scheme = "https" + } + if req != nil && req.Host != "" { + return scheme + "://" + req.Host + "/ingress" + } + return strings.TrimSuffix(r.config.ControlPlaneURL, "/") + "/ingress" +} + +func (r *Router) effectivePublicControlPlaneURL(req *http.Request) 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(req), "/ingress") + } + return strings.TrimSuffix(strings.TrimSpace(r.config.ControlPlaneURL), "/") +} + +func (r *Router) effectiveOIDCIssuer() string { + if configured := strings.TrimSpace(r.config.OIDCIssuer); configured != "" { + return strings.TrimSuffix(configured, "/") + } + if synced := r.syncedOIDCIssuer.Load(); synced != nil && *synced != "" { + return strings.TrimSuffix(*synced, "/") + } + return "" +} + +func (r *Router) allowedAudiences() []string { + var out []string + add := func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + for _, existing := range out { + if existing == s { + return + } + } + out = append(out, s) + } + add(r.config.Audience) + if synced := r.syncedAudience.Load(); synced != nil { + add(*synced) + } + add(api.DefaultAudience) + return out +} + +func (r *Router) resolveOIDCProviders(ctx context.Context) map[string]*oidc.Provider { + iss := r.effectiveOIDCIssuer() + if iss == "" { + return nil + } + r.oidcMu.Lock() + defer r.oidcMu.Unlock() + if r.oidcProviders == nil { + r.oidcProviders = make(map[string]*oidc.Provider) + } + if p, ok := r.oidcProviders[iss]; ok { + return map[string]*oidc.Provider{iss: p} + } + discCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + p, err := oidc.NewProvider(discCtx, iss) + if err != nil { + logger.Warnf("[Ingress] OIDC discovery for %s failed: %v", iss, err) + return nil + } + r.oidcProviders[iss] = p + return map[string]*oidc.Provider{iss: p} +} + +func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, req *http.Request) { + base := r.effectiveIngressBaseURL(req) + var servers []string + if cp := r.effectivePublicControlPlaneURL(req); cp != "" { + servers = append(servers, cp) + } + if iss := r.effectiveOIDCIssuer(); iss != "" && (len(servers) == 0 || servers[0] != iss) { + servers = append(servers, iss) + } + doc := api.OAuthProtectedResourceMetadata{ + Resource: base, + AuthorizationServers: servers, + BearerMethodsSupported: []string{"header"}, + ScopesSupported: []string{"openid", "email", "profile"}, + } + out, err := json.Marshal(doc) + if err != nil { + 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, req *http.Request, modeMetric, message string) { + routerIngressRequestsTotal.WithLabelValues(modeMetric, ingressOutcomeUnauthenticated).Inc() + metaURL := r.effectiveIngressBaseURL(req) + "/.well-known/oauth-protected-resource" + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, metaURL)) + http.Error(w, message, http.StatusUnauthorized) +} + +func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, transport http.RoundTripper) { + if !r.isReady.Load() || r.shutdown.Load() { + http.Error(w, "Router is not ready", http.StatusServiceUnavailable) + return + } + + clientIP := clientIPFromRemoteAddr(req.RemoteAddr) + if r.ingressLimiter != nil && !r.ingressLimiter.Allow(clientIP) { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeRateLimited).Inc() + logger.Warnf("[Ingress] Rate limit exceeded for client %s", clientIP) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + + req.Body = http.MaxBytesReader(w, req.Body, maxIngressBodyBytes) + + if hasDotSegment(req.URL.Path) { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + 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() + 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() + http.Error(w, "Invalid peer ID", http.StatusBadRequest) + return + } + svcType, err := api.ParseServiceType(serviceTypeStr) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + http.Error(w, "Invalid service type", http.StatusBadRequest) + return + } + if !api.IsInboundServiceType(svcType) { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeNotFound).Inc() + http.Error(w, "Service not found", http.StatusNotFound) + return + } + if err := api.ValidateServiceFormat(serviceTypeStr + "://" + serviceName); err != nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeInvalid).Inc() + 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() + 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 { + if rec.GetPeerId() == targetPeer.String() { + providerRec = rec + break + } + } + if providerRec == nil || providerRec.GetIngress() == nil { + routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeNotFound).Inc() + 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() + http.Error(w, "Unsupported ingress auth mode", http.StatusForbidden) + 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 + } + + r.keysMu.RLock() + routerBiscuit := r.credential.biscuit + r.keysMu.RUnlock() + if len(routerBiscuit) == 0 { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + http.Error(w, "Router credential unavailable", http.StatusServiceUnavailable) + return + } + routerBiscuitHeader := base64.StdEncoding.EncodeToString(routerBiscuit) + + targetURI := serviceTypeStr + "://" + serviceName + backendPath := "/" + subpath + + // Unauthenticated GET discovery of A2A AgentCard (both well-known path and + // bare service root GET for resolvers such as a2a-go). + if svcType == api.ServiceType_SERVICE_TYPE_A2A && + req.Method == http.MethodGet && + (subpath == "" || subpath == a2aAgentCardSubpath) { + 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 + } + + // INGRESS_AUTH_MODE_EDGE: authenticate Bearer token and evaluate policy. + bearerToken, ok := extractBearerToken(req.Header.Get("Authorization")) + if !ok { + r.writeIngressUnauthorized(w, req, modeStr, "Missing or invalid Authorization: Bearer header") + 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 := identity.IngressEvalParams{ + ServiceType: serviceTypeStr, + ServiceName: serviceName, + Method: req.Method, + Path: backendPath, + TargetFacts: targetFacts, + EnforceTargetCheck: true, + MeshPolicyRules: r.meshPolicyRules(), + Allow: ing.GetAllow(), + Timeout: r.config.BiscuitTimeout, + } + + if svcType == api.ServiceType_SERVICE_TYPE_MCP { + mcpTool, allowInit, bodyErr := inspectRouterMCPBody(req) + if bodyErr != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeInvalid).Inc() + http.Error(w, bodyErr.Error(), http.StatusRequestEntityTooLarge) + return + } + evalParams.MCPTool = mcpTool + evalParams.AllowMCPStreamInit = allowInit + } + + var callerRes *identity.IngressCallerResult + var callerBiscuitHdr, callerClaimsHdr string + + if isCompactJWT(bearerToken) { + providersMap := r.resolveOIDCProviders(req.Context()) + if len(providersMap) == 0 { + r.writeIngressUnauthorized(w, req, modeStr, "No OIDC provider configured for JWT verification") + return + } + jwtClaims, _, err := identity.VerifyJWT(req.Context(), bearerToken, r.allowedAudiences(), providersMap) + if err != nil { + logger.Warnf("[Ingress] OIDC JWT verification failed from %s for %s: %v", clientIP, targetURI, err) + r.writeIngressUnauthorized(w, req, modeStr, "Invalid Bearer token") + return + } + oidcFacts := identity.OIDCFactsFromJWTClaims(map[string]any(jwtClaims)) + callerRes, err = identity.AuthorizeIngressOIDCFacts(oidcFacts, evalParams) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Edge AuthZ denied OIDC caller from %s for %s: %v", clientIP, targetURI, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + callerClaimsHdr, err = identity.MarshalIngressClaimsHeader(oidcFacts) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } else { + rawCaller, err := identity.DecodeBase64Biscuit(bearerToken) + if err != nil { + r.writeIngressUnauthorized(w, req, modeStr, "Invalid Bearer token encoding") + return + } + verified, err := identity.InspectVerifiedBiscuit(rawCaller, r.getTrustedPublicKeys(), r.config.BiscuitTimeout) + if err != nil { + logger.Warnf("[Ingress] Biscuit verification failed from %s for %s: %v", clientIP, targetURI, err) + r.writeIngressUnauthorized(w, req, modeStr, "Invalid Bearer token") + return + } + if r.isBiscuitRevoked(verified.Biscuit) { + logger.Warnf("[Ingress] Refusing revoked Biscuit from %s for %s", clientIP, targetURI) + r.writeIngressUnauthorized(w, req, 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 + } + } + } + callerRes, err = identity.AuthorizeIngressDelegatedBiscuit(verified, evalParams) + if err != nil { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() + logger.Warnf("[Ingress] Edge AuthZ denied Biscuit caller (%s) from %s for %s: %v", verified.Principal(), clientIP, targetURI, err) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + callerBiscuitHdr = base64.StdEncoding.EncodeToString(rawCaller) + } + + 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, callerClaimsHdr) +} + +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, callerClaimsHdr 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() + + sanitizeRouterOutboundHeaders(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) + } + if callerClaimsHdr != "" { + pr.Out.Header.Set(api.HeaderMeshIngressClaims, callerClaimsHdr) + } + }, + ErrorHandler: func(rw http.ResponseWriter, _ *http.Request, err error) { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeInvalid).Inc() + 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() + 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 { + 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(req), targetPeer.String(), api.ServiceTypeStringA2A, serviceName) + if err := r.rewriteAgentCardForIngress(&card, serviceBase, r.effectivePublicControlPlaneURL(req), 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 { + 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 or OIDC JWT", + Description: "Agent Mesh delegated session Biscuit or trusted OIDC JWT", + }, + } + 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": {}}) + } + if iss := r.effectiveOIDCIssuer(); iss != "" { + schemes["oidc"] = a2a.OpenIDConnectSecurityScheme{ + Description: "Mesh OIDC Identity Provider", + OpenIDConnectURL: iss + "/.well-known/openid-configuration", + } + reqs = append(reqs, a2a.SecurityRequirements{"oidc": {}}) + } + 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) + } + 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 { + continue + } + tAddr, pid := peer.SplitAddr(ma) + if pid != "" && 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 sanitizeRouterOutboundHeaders(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, api.HeaderPeerID) { + delete(h, name) + } + } +} + +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 +} + +func inspectRouterMCPBody(r *http.Request) (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 + } + bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxIngressMCPBodyBytes+1)) + if err != nil { + return "", false, fmt.Errorf("failed to read MCP request body: %w", err) + } + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + if len(bodyBytes) > maxIngressMCPBodyBytes { + return "", false, fmt.Errorf("MCP request body exceeds %d byte inspection limit", maxIngressMCPBodyBytes) + } + if len(bytes.TrimSpace(bodyBytes)) == 0 { + return "", false, nil + } + + var envelope struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(bodyBytes, &envelope); err != nil { + return "", false, nil + } + switch envelope.Method { + case "tools/call": + var params struct { + Name string `json:"name"` + } + if err := json.Unmarshal(envelope.Params, ¶ms); err == nil { + rawTool := strings.TrimSpace(params.Name) + if rawTool == "" { + return "", false, nil + } + if _, stripped, splitErr := api.SplitToolName(rawTool); splitErr == nil { + return stripped, false, nil + } + return rawTool, false, nil + } + return "", false, nil + case "initialize", "notifications/initialized", "server/discover", "ping", + "tools/list", "resources/list", "prompts/list", "notifications/cancelled": + return "", true, nil + default: + return "", false, nil + } +} diff --git a/internal/router/ingress_test.go b/internal/router/ingress_test.go new file mode 100644 index 00000000..8853422c --- /dev/null +++ b/internal/router/ingress_test.go @@ -0,0 +1,772 @@ +// 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" + "crypto/rsa" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "math/big" + "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" + lru "github.com/hashicorp/golang-lru/v2" + gostream "github.com/libp2p/go-libp2p-gostream" + kb "github.com/libp2p/go-libp2p-kbucket" + "github.com/libp2p/go-libp2p/core/host" +) + +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("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 newTestOIDCServer(t *testing.T) (string, *rsa.PrivateKey) { + t.Helper() + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL, + "jwks_uri": srv.URL + "/keys", + }) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "test-kid", + "n": base64.RawURLEncoding.EncodeToString(privKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privKey.E)).Bytes()), + }, + }, + }) + }) + return srv.URL, privKey +} + +func signTestOIDCJWT(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = "test-kid" + signed, err := tok.SignedString(key) + if err != nil { + t.Fatalf("SignedString: %v", err) + } + return signed +} + +func TestRouterIngressHandlerEndToEnd(t *testing.T) { + cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + oidcIssuer, oidcKey := newTestOIDCServer(t) + + r := newDirectoryRouter(t) + r.config.ControlPlaneURL = "https://hub.example.com" + r.config.OIDCIssuer = oidcIssuer + r.config.Audience = api.DefaultAudience + r.config.BiscuitTimeout = 2 * time.Second + limiter, err := ratelimit.NewPeerRateLimiterWithRate(128, ingressRateLimitPerIP, ingressRateBurstPerIP) + if err != nil { + t.Fatalf("NewPeerRateLimiterWithRate: %v", err) + } + r.ingressLimiter = limiter + revoked, err := lru.New[string, time.Time](128) + if err != nil { + t.Fatalf("lru.New: %v", err) + } + r.revokedTokens = revoked + + 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"` + CallerClaims string `json:"caller_claims"` + 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) != "", + CallerClaims: req.Header.Get(api.HeaderMeshIngressClaims), + 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) < 2 || doc.AuthorizationServers[0] != "https://hub.example.com" || doc.AuthorizationServers[1] != oidcIssuer { + t.Fatalf("AuthorizationServers = %v, want [https://hub.example.com %s]", doc.AuthorizationServers, oidcIssuer) + } + }) + + 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 AgentCard discovery rewrites URL, injects 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", "oidc"} { + if _, ok := card.SecuritySchemes[schemeName]; !ok { + t.Fatalf("SecuritySchemes missing %q: %+v", schemeName, 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. + 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("EDGE OIDC JWT verification forwards attested claims", func(t *testing.T) { + jwtStr := signTestOIDCJWT(t, oidcKey, jwt.MapClaims{ + "iss": oidcIssuer, + "sub": "oidc-alice", + "aud": api.DefaultAudience, + "email": "alice@example.com", + "email_verified": true, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Add(-time.Minute).Unix(), + }) + req, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer "+jwtStr) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("POST with OIDC JWT: %v", err) + } + var got capturedReq + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode OIDC upstream: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("OIDC JWT status = %d, want 200", resp.StatusCode) + } + if got.CallerClaims == "" || got.Auth != "" { + t.Fatalf("expected CallerClaims set and Authorization stripped, got %+v", got) + } + claimsMap, err := identity.UnmarshalIngressClaimsHeader(got.CallerClaims) + if err != nil { + t.Fatalf("UnmarshalIngressClaimsHeader: %v", err) + } + if len(claimsMap[api.FactEmail]) != 1 || claimsMap[api.FactEmail][0] != "alice@example.com" { + t.Fatalf("forwarded email claims = %v, want [alice@example.com]", claimsMap[api.FactEmail]) + } + }) + + 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) + } + }) +} 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..ce7b3dc8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -35,12 +35,14 @@ import ( "sync/atomic" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/eclipse-biscuit/biscuit-go/v2" "github.com/google/agentmesh/api" cpclient "github.com/google/agentmesh/internal/controlplane/client" "github.com/google/agentmesh/internal/identity" "github.com/google/agentmesh/internal/p2plimits" "github.com/google/agentmesh/internal/ratelimit" + lru "github.com/hashicorp/golang-lru/v2" golog "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" @@ -395,6 +397,17 @@ type Router struct { metricsServer *http.Server metricsAddr net.Addr + ingressServer *http.Server + ingressAddr net.Addr + ingressLimiter *ratelimit.PeerRateLimiter + + revokedTokens *lru.Cache[string, time.Time] + + oidcMu sync.Mutex + oidcProviders map[string]*oidc.Provider + syncedOIDCIssuer atomic.Pointer[string] + syncedAudience atomic.Pointer[string] + // 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 +429,25 @@ 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) + } + revokedTokens, err := lru.New[string, time.Time](handshakeLimiterSize) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to create revocation cache: %w", err) + } return &Router{ config: config, ctx: ctx, cancel: cancel, handshakeLimiter: handshakeLimiter, + ingressLimiter: ingressLimiter, + revokedTokens: revokedTokens, + oidcProviders: make(map[string]*oidc.Provider), syncTrigger: make(chan struct{}, 1), }, nil } @@ -600,6 +626,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 +648,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 +1081,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 +1155,73 @@ 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) + } + if len(resp.GetBannedPeerIds()) > 0 { + r.reconcileBannedPeers(resp.GetBannedPeerIds(), fetchedAt) + } + return nil +} + +func (r *Router) recordRevokedBiscuitID(revID string, expiry time.Time) { + if revID == "" || r.revokedTokens == nil { + return + } + r.revokedTokens.Add(revID, expiry) + if raw, err := hex.DecodeString(revID); err == nil && len(raw) > 0 { + r.revokedTokens.Add(base64.RawURLEncoding.EncodeToString(raw), expiry) + } +} + +func (r *Router) isBiscuitRevoked(b *biscuit.Biscuit) bool { + if r == nil || r.revokedTokens == nil || b == nil { + return false + } + now := time.Now() + for _, id := range b.RevocationIds() { + for _, key := range []string{hex.EncodeToString(id), base64.RawURLEncoding.EncodeToString(id)} { + if exp, ok := r.revokedTokens.Get(key); ok { + if now.Before(exp) { + return true + } + r.revokedTokens.Remove(key) + } + } + } + return false +} + // 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 +1366,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 +1518,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) } @@ -1451,6 +1543,13 @@ func (r *Router) connectBootstrapRouters() { return } + if iss := strings.TrimSpace(info.GetOidcIssuer()); iss != "" { + r.syncedOIDCIssuer.Store(&iss) + } + if aud := strings.TrimSpace(info.GetAudience()); aud != "" { + r.syncedAudience.Store(&aud) + } + // /info carries the whole ban set, so this is also where a router that // restarted or missed a MeshEvent_BANNED catches up. r.reconcileBannedPeers(info.GetBannedPeerIds(), fetchedAt) @@ -1652,7 +1751,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: @@ -1809,6 +1912,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..797c7371 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,40 @@ 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 + allowInsecureIngress := true if s.opts.ExternalURL != "" { ext, err := externalMultiaddr(s.opts.ExternalURL) if err != nil { return err } externalAddrs = []string{ext} + ingressURL = strings.TrimRight(s.opts.ExternalURL, "/") + "/ingress" + allowInsecureIngress = strings.HasPrefix(s.opts.ExternalURL, "http://") } routerKeyPath := filepath.Join(s.opts.DataDir, routerKeyFile) @@ -399,12 +423,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 +499,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 } @@ -551,10 +587,17 @@ 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. func defaultDevPolicyRoles() []*api.PolicyRole { + devIngress := []*api.IngressGrant{{ + Services: []string{"*"}, + AllowedAuthModes: []api.IngressAuthMode{ + api.IngressAuthMode_INGRESS_AUTH_MODE_EDGE, + api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, + }, + }} 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{"*"}, AllowedPublish: []string{"*"}, AllowedTargets: []string{"*"}, AllowedLabels: []string{"*"}, AllowedIngress: devIngress}, } } diff --git a/internal/standalone/standalone_test.go b/internal/standalone/standalone_test.go index 028563e7..2ecaddf7 100644 --- a/internal/standalone/standalone_test.go +++ b/internal/standalone/standalone_test.go @@ -15,15 +15,22 @@ package standalone import ( + "context" + "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" "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 +205,104 @@ func TestWrapPublicHTTPHandlerCountsProxiedRequestsWithoutExternalURL(t *testing t.Errorf("next handler served %d requests, want 4", served) } } + +func TestStandaloneSinglePortIngressAndSeededPolicy(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*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) + } + foundNodeIngress := false + for _, role := range pol.GetRoles() { + if role.GetName() == api.RoleNode && len(role.GetAllowedIngress()) > 0 { + foundNodeIngress = true + break + } + } + if !foundNodeIngress { + t.Fatalf("seeded PolicyConfig for %s lacks allowed_ingress: %+v", api.RoleNode, pol.GetRoles()) + } +} 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..baa12d69 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 verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + * + * @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/guides/external-ingress.md b/site/content/docs/guides/external-ingress.md new file mode 100644 index 00000000..86d83882 --- /dev/null +++ b/site/content/docs/guides/external-ingress.md @@ -0,0 +1,192 @@ +--- +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` +includes `allowed_ingress` out of the box so local relays and Cloud Run sandboxes +work with zero configuration. + +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 OAuth/OIDC/Biscuit + # identity and evaluate Datalog RBAC, TAR attenuation, and optional 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) +``` + +## How `edge` and `passthrough` behave + +### `auth_mode: edge` (default) + +1. Unauthenticated requests 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 + either: + - a **Delegated Session Biscuit** minted by the control plane's OAuth 2.1 + Authorization Server (`/oauth/authorize` + `/oauth/token`) or + `POST /token/exchange` (optionally attenuated with a + `TaskAuthorizationRule`), or + - a **trusted OIDC JWT** issued by the mesh's `oidc_issuer`. +3. Both the router (at the network edge) and the serving node (defense in depth + over `/mesh/ingress/1.0.0`) verify the caller's identity and enforce the + intersection of mesh Datalog RBAC, any appended `TaskAuthorizationRule` + (including MCP `tools/call` tool names), `ServiceIngress.allow`, and local + `agentmesh-node.yaml` `attenuation`. +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`, + `agentmesh_oauth2`, and `oidc`. + +### `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`. + +## 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 + --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. 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..8b9c180b 100644 --- a/site/content/docs/reference/router.md +++ b/site/content/docs/reference/router.md @@ -48,6 +48,12 @@ 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`. | +| `--oidc-issuer`, `--audience` | from `/info` | Optional overrides for OIDC JWT verification on `edge` ingress requests. | +| `--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`. | 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..bf57ba4a --- /dev/null +++ b/tests/integration/ingress_test.go @@ -0,0 +1,844 @@ +// 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 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, +// OIDC JWT + ServiceIngress.allow ACL enforcement, stock A2A SendMessage & SSE SendStreamingMessage, +// signed X-AgentMesh-Identity-Evidence delivery to the backend, and RFC 8693 /oauth/token Delegated +// Session Biscuit with TAR MCP tool attenuation +// - Opt-in PASSTHROUGH mode: upstream Bearer pair token preserved end-to-end while spoofed X-AgentMesh-* +// headers are stripped. +func TestStandaloneExternalIngressCUJ(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*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) < 2 || prm.AuthorizationServers[0] != srv.PublicURL() || prm.AuthorizationServers[1] != oidcURL { + t.Errorf("PRM authorization_servers = %v, want [%s %s]", prm.AuthorizationServers, srv.PublicURL(), oidcURL) + } + + // 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")) + } + + // OIDC caller 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"}) + deniedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(otherJWT, 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 OIDC caller succeeds on both SendMessage and SSE SendStreamingMessage. + yaroslavJWT := mintJWT(map[string]any{"sub": "yaroslav-sub", "email": "yaroslav@example.com"}) + allowedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(yaroslavJWT, 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"]}}]}` + form := url.Values{ + "grant_type": {api.GrantTypeTokenExchange}, + "subject_token": {yaroslavJWT}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:id_token"}, + "options": {tarJSON}, + "seal": {"true"}, + } + tokResp, err := httpClient.PostForm(srv.PublicURL()+"/oauth/token", form) + if err != nil { + t.Fatalf("POST /oauth/token: %v", err) + } + tokBody, _ := io.ReadAll(tokResp.Body) + _ = tokResp.Body.Close() + if tokResp.StatusCode != http.StatusOK { + t.Fatalf("POST /oauth/token = %s: %s", tokResp.Status, tokBody) + } + var oauthOut struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(tokBody, &oauthOut); err != nil || oauthOut.AccessToken == "" { + t.Fatalf("decode /oauth/token response: %v (%s)", err, tokBody) + } + + 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(oauthOut.AccessToken, 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 by the router directory. +// - 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(), 25*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 by Router A's directory ceiling check -> 404 on Router B. + 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) + } + + // External caller invokes a2a://coder through Router B -> Router A circuit relay -> private node. + alanJWT := mintJWT(map[string]any{"sub": "alan-sub", "email": "alan@example.com"}) + a2aCli, err := a2aclient.NewFromCard(ctx, resolvedCard, a2aclient.WithJSONRPCTransport(bearerClient(alanJWT, 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) + } +} From 2aeb78c112095c3ad75d3e457359a717c8c09ad2 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 1 Nov 2026 08:04:56 +0000 Subject: [PATCH 2/4] router, node, standalone: address PR #838 review and harden external ingress - Remove direct OIDC JWT verification from agentmesh-router (--oidc-issuer, --audience, HeaderMeshIngressClaims, and node trust in router-asserted OIDC claims); EDGE mode on /ingress/ accepts only Mesh Biscuits while returning 401 + RFC 9728 resource_metadata so JWT callers exchange at POST /oauth/token. - Verify EDGE caller Biscuit signature, revocation, banned status, TAR rules, and ServiceIngress.allow locally before dialing or handshaking any provider. - Replace the router's bounded LRU revocation cache with a non-evicting, expiry-pruned revocationSet, export agentmesh_router_revoked_tokens, and enforce revocations across mutual auth, live sessions, and /mesh/ingress. - Key verified EDGE ingress rate limits per Biscuit revocation ID so callers behind an L7 Gateway do not share one SNAT IP bucket, and document Gateway source-IP rate limiting for unauthenticated routes. - Return "" from leasedIngressURL() on unspecified bind hosts (0.0.0.0 / ::) and remove req.Host fallbacks from metadata and AgentCard URL rewriting. - Refuse auth_mode: passthrough combined with target_auth_path or inline target_url credentials at config validation and service registration. - Log errors, increment agentmesh_node_service_provide_failures_total, and fall back to internal-only directory announcement when an ingress role ceiling rejects ProvideWithIngress. - Deduplicate MCP JSON-RPC body inspection and external ingress header sanitization in api/, decode peer.ID before identity comparisons, add missing log/counter traces on early returns, revert unrelated internal/node/oidc.go diffs, and restrict agentmesh-one's default dev policy to EDGE-only ingress. --- .github/k8s/agentmesh-router-template.yaml | 1 + api/agentmesh.pb.go | 2 +- api/agentmesh.proto | 2 +- api/ingress.go | 27 + api/mcp.go | 96 ++++ api/mcp_test.go | 159 +++++ api/network.go | 6 - charts/agentmesh/values.yaml | 3 + cmd/agentmesh-router/main.go | 6 - internal/envoy/server.go | 49 +- internal/identity/ingress.go | 202 +------ internal/identity/ingress_test.go | 46 +- internal/node/config.go | 13 +- internal/node/ingress.go | 105 +--- internal/node/ingress_test.go | 205 ++++++- internal/node/oidc.go | 14 +- internal/node/service.go | 21 + internal/node/service_registry.go | 121 +++- internal/router/config.go | 5 - internal/router/directory.go | 12 +- internal/router/ingress.go | 542 ++++++++---------- internal/router/ingress_test.go | 449 +++++++++++---- internal/router/metrics.go | 8 +- internal/router/metrics_test.go | 2 + internal/router/router.go | 172 ++++-- internal/standalone/standalone.go | 13 +- internal/standalone/standalone_test.go | 53 +- sdk/js/src/gen/agentmesh_pb.ts | 2 +- .../contributing/security-architecture.md | 31 +- site/content/docs/guides/external-ingress.md | 61 +- site/content/docs/reference/router.md | 4 +- tests/integration/ingress_test.go | 128 +++-- 32 files changed, 1624 insertions(+), 936 deletions(-) create mode 100644 api/mcp.go create mode 100644 api/mcp_test.go diff --git a/.github/k8s/agentmesh-router-template.yaml b/.github/k8s/agentmesh-router-template.yaml index 5a2a35a5..793e8926 100644 --- a/.github/k8s/agentmesh-router-template.yaml +++ b/.github/k8s/agentmesh-router-template.yaml @@ -95,6 +95,7 @@ spec: - "--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`. diff --git a/api/agentmesh.pb.go b/api/agentmesh.pb.go index 7731063d..ecd9221a 100644 --- a/api/agentmesh.pb.go +++ b/api/agentmesh.pb.go @@ -153,7 +153,7 @@ type IngressAuthMode int32 const ( // Defaults to INGRESS_AUTH_MODE_EDGE. IngressAuthMode_INGRESS_AUTH_MODE_UNSPECIFIED IngressAuthMode = 0 - // Router verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + // 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. diff --git a/api/agentmesh.proto b/api/agentmesh.proto index be559300..b265be2a 100644 --- a/api/agentmesh.proto +++ b/api/agentmesh.proto @@ -153,7 +153,7 @@ enum ServiceType { enum IngressAuthMode { // Defaults to INGRESS_AUTH_MODE_EDGE. INGRESS_AUTH_MODE_UNSPECIFIED = 0; - // Router verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + // 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. diff --git a/api/ingress.go b/api/ingress.go index 75358ca3..58b20ac4 100644 --- a/api/ingress.go +++ b/api/ingress.go @@ -16,6 +16,7 @@ package api import ( "fmt" + "net/http" "net/url" "slices" "strings" @@ -110,6 +111,16 @@ func ValidateServiceIngress(svcType ServiceType, ing *ServiceIngress) error { 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 @@ -133,6 +144,22 @@ func ValidateServiceIngress(svcType ServiceType, ing *ServiceIngress) error { 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 { 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 762b228b..40177dd4 100644 --- a/api/network.go +++ b/api/network.go @@ -318,12 +318,6 @@ const ( // 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" - - // HeaderMeshIngressClaims carries base64url-encoded JSON OIDC fact claims - // verified at the router edge over IngressProtocolID when the external caller - // authenticated with a trusted OIDC JWT rather than a Mesh Biscuit. Stripped - // before forwarding to the backend service. - HeaderMeshIngressClaims = "X-Mesh-Ingress-Claims" ) // ============================================================================ diff --git a/charts/agentmesh/values.yaml b/charts/agentmesh/values.yaml index fd0414b2..12ecd864 100644 --- a/charts/agentmesh/values.yaml +++ b/charts/agentmesh/values.yaml @@ -13,6 +13,9 @@ gateway: 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 diff --git a/cmd/agentmesh-router/main.go b/cmd/agentmesh-router/main.go index 263854d7..5f880e55 100644 --- a/cmd/agentmesh-router/main.go +++ b/cmd/agentmesh-router/main.go @@ -81,8 +81,6 @@ var ( ingressTLSCertPath string ingressTLSKeyPath string allowInsecureIngressHTTP bool - oidcIssuer string - audience string publicControlPlaneURL string ) @@ -123,8 +121,6 @@ func main() { IngressTLSCertPath: ingressTLSCertPath, IngressTLSKeyPath: ingressTLSKeyPath, AllowInsecureIngressHTTP: allowInsecureIngressHTTP, - OIDCIssuer: oidcIssuer, - Audience: audience, PublicControlPlaneURL: publicControlPlaneURL, } labels, err := api.ParseLabelFlags(labelFlags) @@ -190,8 +186,6 @@ func main() { 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 (only when behind a TLS-terminating L7 load balancer) and allow http:// in --ingress-url") - rootCmd.Flags().StringVar(&oidcIssuer, "oidc-issuer", "", "Optional OIDC issuer URL override for verifying OIDC JWTs on edge ingress (defaults to the control plane's /info oidc_issuer)") - rootCmd.Flags().StringVar(&audience, "audience", "", "Optional OIDC audience override for verifying OIDC JWTs on edge ingress (defaults to the control plane's /info audience)") 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) diff --git a/internal/envoy/server.go b/internal/envoy/server.go index 989602e6..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" @@ -485,53 +482,11 @@ func (s *GatewayServer) evaluateExtProcDecision(ctx context.Context, method, pat // 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", "notifications/initialized", "server/discover", "ping", - "tools/list", "resources/list", "prompts/list", "notifications/cancelled": - 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 { - 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 - } - 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/ingress.go b/internal/identity/ingress.go index adc8baa6..f3e939af 100644 --- a/internal/identity/ingress.go +++ b/internal/identity/ingress.go @@ -16,37 +16,17 @@ package identity import ( "crypto/ed25519" - "crypto/rand" "encoding/base64" - "encoding/json" "fmt" "slices" "sort" "strings" - "sync" "time" "github.com/eclipse-biscuit/biscuit-go/v2" "github.com/google/agentmesh/api" ) -var ( - ephemeralOnce sync.Once - ephemeralPub ed25519.PublicKey - ephemeralPriv ed25519.PrivateKey -) - -func ephemeralEvalKeys() (ed25519.PublicKey, ed25519.PrivateKey) { - ephemeralOnce.Do(func() { - var err error - ephemeralPub, ephemeralPriv, err = ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(fmt.Sprintf("failed to generate ephemeral evaluation key: %v", err)) - } - }) - return ephemeralPub, ephemeralPriv -} - // DecodeBase64Biscuit decodes a base64 or unpadded base64url encoded Biscuit. func DecodeBase64Biscuit(encoded string) ([]byte, error) { encoded = strings.TrimSpace(encoded) @@ -59,90 +39,6 @@ func DecodeBase64Biscuit(encoded string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(encoded) } -// OIDCFactsFromJWTClaims extracts standard Agent Mesh OIDC identity facts -// ("user", "email", "group", "idp_role") from verified JWT claims. FactRole and -// FactNode are never derived from OIDC claims. -func OIDCFactsFromJWTClaims(claims map[string]any) map[string][]string { - out := make(map[string][]string) - if len(claims) == 0 { - return out - } - claimMap := api.OIDCClaimToFact() - for claimKey, factName := range claimMap { - raw, ok := claims[claimKey] - if !ok || raw == nil { - continue - } - seen := make(map[string]bool) - for _, item := range toStringSlice(raw) { - if item == "" || seen[item] { - continue - } - seen[item] = true - out[factName] = append(out[factName], item) - } - sort.Strings(out[factName]) - } - return out -} - -func allowedOIDCFactNames() map[string]bool { - allowed := make(map[string]bool) - for _, factName := range api.OIDCClaimToFact() { - allowed[factName] = true - } - return allowed -} - -// SanitizeOIDCFacts returns a copy containing only permitted OIDC fact names -// ("user", "email", "group", "idp_role") and non-empty deduplicated values. -func SanitizeOIDCFacts(facts map[string][]string) map[string][]string { - allowed := allowedOIDCFactNames() - clean := make(map[string][]string) - for k, vals := range facts { - if !allowed[k] { - continue - } - seen := make(map[string]bool) - for _, v := range vals { - if v == "" || seen[v] { - continue - } - seen[v] = true - clean[k] = append(clean[k], v) - } - if len(clean[k]) > 0 { - sort.Strings(clean[k]) - } - } - return clean -} - -// MarshalIngressClaimsHeader encodes verified OIDC fact claims for transport -// over IngressProtocolID from an authenticated router to the serving node. -func MarshalIngressClaimsHeader(facts map[string][]string) (string, error) { - clean := SanitizeOIDCFacts(facts) - raw, err := json.Marshal(clean) - if err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(raw), nil -} - -// UnmarshalIngressClaimsHeader decodes and sanitizes verified OIDC fact claims -// received over IngressProtocolID from an authenticated router. -func UnmarshalIngressClaimsHeader(encoded string) (map[string][]string, error) { - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) - if err != nil { - return nil, fmt.Errorf("invalid base64url ingress claims header: %w", err) - } - var parsed map[string][]string - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, fmt.Errorf("invalid json ingress claims header: %w", err) - } - return SanitizeOIDCFacts(parsed), nil -} - // 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. @@ -269,12 +165,10 @@ func runIngressAuthorizer(authorizer biscuit.Authorizer, p IngressEvalParams) ([ return roles, nil } -// AuthorizeIngressDelegatedBiscuit authorizes a verified Delegated Session -// Biscuit or task-attenuated Task Biscuit (minted via RFC 8693 /oauth/token 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) { +// 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") } @@ -284,16 +178,6 @@ func AuthorizeIngressDelegatedBiscuit(claims *VerifiedBiscuitClaims, p IngressEv 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") } - - 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 - } - if len(claims.TaskRules) > 0 { taskReq := api.TaskRequestContext{ ServiceType: p.ServiceType, @@ -308,85 +192,43 @@ func AuthorizeIngressDelegatedBiscuit(claims *VerifiedBiscuitClaims, p IngressEv return nil, err } } - idMap := claims.IdentityClaimsMap() if !api.MatchIngressAllow(p.Allow, idMap) { return nil, fmt.Errorf("caller does not satisfy service ingress allow list") } - - actorNodeID := claims.ActorNodePeerID - if actorNodeID == "" { - actorNodeID = claims.NodePeerID - } - - return &IngressCallerResult{ - Principal: claims.Principal(), - Roles: roles, - TaskName: claims.InnermostTaskName(), - ActorNodeID: actorNodeID, - IdentityFacts: idMap, - }, nil + return idMap, nil } -// AuthorizeIngressOIDCFacts authorizes verified OIDC JWT fact claims against -// mesh Datalog policy, local node attenuation, and ServiceIngress.allow using -// the exact same Biscuit Datalog authorizer as Delegated Session Biscuits. -func AuthorizeIngressOIDCFacts(oidcFacts map[string][]string, p IngressEvalParams) (*IngressCallerResult, error) { - clean := SanitizeOIDCFacts(oidcFacts) - if len(clean) == 0 { - return nil, fmt.Errorf("verified OIDC token carries no identity claims") - } - - pub, priv := ephemeralEvalKeys() - builder := biscuit.NewBuilder(priv) - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactExpiration, - IDs: []biscuit.Term{biscuit.Date(time.Now().Add(5 * time.Minute))}, - }}); err != nil { - return nil, err - } - keys := make([]string, 0, len(clean)) - for k := range clean { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - for _, v := range clean[k] { - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: k, - IDs: []biscuit.Term{biscuit.String(v)}, - }}); err != nil { - return nil, err - } - } - } - tok, err := builder.Build() +// 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 := tok.Authorizer(pub, AuthorizerOptions(p.Timeout)...) + + authorizer, err := newAuthorizer(claims.Biscuit, claims.VerifyingKey, p.Timeout) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create biscuit authorizer: %w", err) } roles, err := runIngressAuthorizer(authorizer, p) if err != nil { return nil, err } - if !api.MatchIngressAllow(p.Allow, clean) { - return nil, fmt.Errorf("caller does not satisfy service ingress allow list") - } - - var principal string - if emails := clean[api.FactEmail]; len(emails) > 0 { - principal = emails[0] - } else if users := clean[api.FactUser]; len(users) > 0 { - principal = users[0] + actorNodeID := claims.ActorNodePeerID + if actorNodeID == "" { + actorNodeID = claims.NodePeerID } return &IngressCallerResult{ - Principal: principal, + Principal: claims.Principal(), Roles: roles, - IdentityFacts: clean, + TaskName: claims.InnermostTaskName(), + ActorNodeID: actorNodeID, + IdentityFacts: idMap, }, nil } diff --git a/internal/identity/ingress_test.go b/internal/identity/ingress_test.go index 40f27aff..058b922e 100644 --- a/internal/identity/ingress_test.go +++ b/internal/identity/ingress_test.go @@ -273,42 +273,28 @@ func TestAuthorizeIngressCallers(t *testing.T) { t.Fatal("expected allow list mismatch to deny") } - // 3. Verified OIDC JWT claims round-trip through X-Mesh-Ingress-Claims and evaluate identically. - oidcFacts := OIDCFactsFromJWTClaims(map[string]any{ - "sub": "uid-2", - "email": "alan@google.com", - "groups": []any{"a2a"}, - "roles": []string{"mesh:role:router"}, // mapped to idp_role, NEVER role! - }) - hdr, err := MarshalIngressClaimsHeader(oidcFacts) + // 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("MarshalIngressClaimsHeader: %v", err) + t.Fatalf("MintBootstrapBiscuitToken(router): %v", err) } - unmarshaled, err := UnmarshalIngressClaimsHeader(hdr) + attRouterRaw, err := AttenuateBiscuit(routerRaw, tarRule) if err != nil { - t.Fatalf("UnmarshalIngressClaimsHeader: %v", err) + t.Fatalf("AttenuateBiscuit(router): %v", err) } - oidcRes, err := AuthorizeIngressOIDCFacts(unmarshaled, IngressEvalParams{ - ServiceType: "a2a", - ServiceName: "assistant", - Method: "POST", - Path: "/message:send", - TargetFacts: targetFacts, - EnforceTargetCheck: true, - MeshPolicyRules: meshRules, - Allow: []string{"email:*@google.com"}, - Timeout: time.Second, - }) + routerClaims, err := InspectVerifiedBiscuit(attRouterRaw, []ed25519.PublicKey{pub}, time.Second) if err != nil { - t.Fatalf("AuthorizeIngressOIDCFacts: %v", err) - } - if oidcRes.Principal != "alan@google.com" { - t.Fatalf("got principal %q, want alan@google.com", oidcRes.Principal) + t.Fatalf("InspectVerifiedBiscuit(router): %v", err) } - for _, r := range oidcRes.Roles { - if r == api.RoleRouter { - t.Fatal("OIDC roles claim must never become mesh role") - } + 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") } } diff --git a/internal/node/config.go b/internal/node/config.go index 77a72a09..f0be11c9 100644 --- a/internal/node/config.go +++ b/internal/node/config.go @@ -118,9 +118,20 @@ func CompleteNodeConfig(config api.NodeConfig) (*NodeConfigComplete, error) { if typeErr != nil { return nil, fmt.Errorf("service %q: %w", svc.Name, typeErr) } - if _, err := api.ServiceIngressFromConfig(sType, svc.Ingress); err != nil { + 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/ingress.go b/internal/node/ingress.go index 28a6e735..dffe2bc4 100644 --- a/internal/node/ingress.go +++ b/internal/node/ingress.go @@ -260,6 +260,8 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { } 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 } @@ -297,7 +299,7 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { backendPath := "/" + upstreamPath if effectiveMode == api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH { - stripExternalIngressHeaders(r.Header, false) + api.SanitizeExternalIngressHeaders(r.Header, false) r.Header.Set(api.HeaderPeerID, remotePeer.String()) r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringPassthrough) @@ -320,13 +322,11 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { // AgentCard without caller credentials so external A2A clients can // discover SecuritySchemes before authenticating. callerBiscuitHeader := strings.TrimSpace(r.Header.Get(api.HeaderMeshIngressCallerBiscuit)) - callerClaimsHeader := strings.TrimSpace(r.Header.Get(api.HeaderMeshIngressClaims)) if serviceType == api.ServiceType_SERVICE_TYPE_A2A && r.Method == http.MethodGet && upstreamPath == a2aAgentCardPath && - callerBiscuitHeader == "" && - callerClaimsHeader == "" { - stripExternalIngressHeaders(r.Header, true) + callerBiscuitHeader == "" { + api.SanitizeExternalIngressHeaders(r.Header, true) r.Header.Set(api.HeaderPeerID, remotePeer.String()) r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringEdge) @@ -346,14 +346,25 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { return } - if callerBiscuitHeader == "" && callerClaimsHeader == "" { + 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 } - if callerBiscuitHeader != "" && callerClaimsHeader != "" { + + callerBiscuitBytes, err := identity.DecodeBase64Biscuit(callerBiscuitHeader) + if err != nil { externalIngressRequestsTotal.WithLabelValues(modeMetric, externalIngressOutcomeInvalid).Inc() - http.Error(w, "Ambiguous caller credentials on edge ingress request", http.StatusBadRequest) + 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 } @@ -365,53 +376,19 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { return } - authorizeCaller := func(params identity.IngressEvalParams) (*identity.IngressCallerResult, []byte, int, string) { - if callerBiscuitHeader != "" { - rawCaller, decErr := identity.DecodeBase64Biscuit(callerBiscuitHeader) - if decErr != nil { - return nil, nil, http.StatusBadRequest, "Invalid caller biscuit encoding" - } - verifiedCaller, verErr := n.VerifyLocalBiscuit(rawCaller) - if verErr != nil { - return nil, nil, http.StatusForbidden, fmt.Sprintf("Authorization failed: %v", verErr) - } - res, authErr := identity.AuthorizeIngressDelegatedBiscuit(verifiedCaller, params) - if authErr != nil { - return nil, nil, http.StatusForbidden, "Authorization failed" - } - return res, rawCaller, http.StatusOK, "" - } - oidcFacts, decErr := identity.UnmarshalIngressClaimsHeader(callerClaimsHeader) - if decErr != nil { - return nil, nil, http.StatusBadRequest, "Invalid caller claims header" - } - res, authErr := identity.AuthorizeIngressOIDCFacts(oidcFacts, params) - if authErr != nil { - return nil, nil, http.StatusForbidden, "Authorization failed" - } - return res, nil, http.StatusOK, "" - } - if serviceType == api.ServiceType_SERVICE_TYPE_MCP { preParams := evalParams preParams.AllowMCPStreamInit = true - if _, _, status, msg := authorizeCaller(preParams); status != http.StatusOK { - outcome := externalIngressOutcomeDeny - if status == http.StatusBadRequest { - outcome = externalIngressOutcomeInvalid - } - externalIngressRequestsTotal.WithLabelValues(modeMetric, outcome).Inc() - logger.Warnf("[ExternalIngress] Pre-body MCP AuthZ failed via router %s for %s: %s", remotePeer, target, msg) - if status == http.StatusBadRequest { - http.Error(w, msg, status) - } else { - refuse(w, status, "Authorization failed", proxyStatusDenied) - } + 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 := inspectMCPHTTPRequestBody(r) + 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 } @@ -419,23 +396,15 @@ func (n *AgentMeshNode) externalIngressHandler() http.Handler { evalParams.AllowMCPStreamInit = allowInit } - callerRes, callerBiscuitBytes, status, errMsg := authorizeCaller(evalParams) - if status != http.StatusOK { - outcome := externalIngressOutcomeDeny - if status == http.StatusBadRequest { - outcome = externalIngressOutcomeInvalid - } - externalIngressRequestsTotal.WithLabelValues(modeMetric, outcome).Inc() - logger.Warnf("[ExternalIngress] AuthZ denied via router %s for %s: %s", remotePeer, target, errMsg) - if status == http.StatusBadRequest { - http.Error(w, errMsg, status) - } else { - refuse(w, status, "Authorization failed", proxyStatusDenied) - } + 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 } - stripExternalIngressHeaders(r.Header, true) + api.SanitizeExternalIngressHeaders(r.Header, true) r.Header.Set(api.HeaderPeerID, remotePeer.String()) r.Header.Set(api.HeaderMeshIngress, api.IngressAuthModeStringEdge) if callerRes.Principal != "" { @@ -510,18 +479,6 @@ func (n *AgentMeshNode) buildExternalIngressEvalParams(serviceType, serviceName, return p, nil } -func stripExternalIngressHeaders(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, api.HeaderPeerID) { - delete(h, name) - } - } -} - func rewriteExternalIngressRequestPath(r *http.Request, parts []string, upstreamPath string) { if upstreamPath == "" { r.URL.Path = "/" diff --git a/internal/node/ingress_test.go b/internal/node/ingress_test.go index 252c6180..3de2c6ff 100644 --- a/internal/node/ingress_test.go +++ b/internal/node/ingress_test.go @@ -20,8 +20,11 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/base64" + "errors" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -50,7 +53,8 @@ func mustTestPeerID(t *testing.T) (peer.ID, libp2pcrypto.PrivKey) { type fakeIngressDHT struct { fakeDHT - ingressCalls []ingressProvideCall + ingressCalls []ingressProvideCall + failIngressErr error } type ingressProvideCall struct { @@ -65,7 +69,7 @@ func (f *fakeIngressDHT) ProvideWithIngress(_ context.Context, key []byte, servi service: service, ingress: ing, }) - return nil + return f.failIngressErr } func TestServiceRegistry_ProvideWithIngressOnlyOnNamedKey(t *testing.T) { @@ -135,6 +139,156 @@ func TestCompleteNodeConfig_ValidatesServiceIngress(t *testing.T) { } } +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 { @@ -454,33 +608,11 @@ func TestExternalIngressHandler(t *testing.T) { } }) - t.Run("edge OIDC claims header evaluated identically against Datalog + local policy", func(t *testing.T) { - claimsHdr, err := identity.MarshalIngressClaimsHeader(map[string][]string{ - api.FactUser: {"uid-alice"}, - api.FactEmail: {"alice@example.com"}, - }) - if err != nil { - t.Fatalf("MarshalIngressClaimsHeader: %v", err) - } - - okReq := httptest.NewRequest(http.MethodPost, "http://mesh/a2a/assistant/", strings.NewReader(`{}`)) - okReq.RemoteAddr = routerPID.String() - okReq.Header.Set(api.HeaderMeshBiscuit, routerB64) - okReq.Header.Set(api.HeaderMeshIngressClaims, claimsHdr) - okRec := httptest.NewRecorder() - h.ServeHTTP(okRec, okReq) - if okRec.Code != http.StatusOK { - t.Fatalf("OIDC claims status = %d, want 200: %s", okRec.Code, okRec.Body.String()) - } - if got := capturedHeaders.Get(api.HeaderMeshPrincipal); got != "alice@example.com" { - t.Errorf("X-Mesh-Principal = %q, want alice@example.com", got) - } - - // Local nodeConfig policy denies /admin + 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.HeaderMeshIngressClaims, claimsHdr) + adminReq.Header.Set(api.HeaderMeshIngressCallerBiscuit, delegatedAliceB64) adminRec := httptest.NewRecorder() h.ServeHTTP(adminRec, adminReq) if adminRec.Code != http.StatusForbidden { @@ -488,6 +620,27 @@ func TestExternalIngressHandler(t *testing.T) { } }) + 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)) diff --git a/internal/node/oidc.go b/internal/node/oidc.go index 455e97c9..d485ec5d 100644 --- a/internal/node/oidc.go +++ b/internal/node/oidc.go @@ -178,18 +178,12 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t break } } - if listener == nil && deviceAuthURL == "" { - // When no device flow fallback is available (or AuthModeBrowser - // explicitly disabled it), try an RFC 8252 ephemeral loopback port - // before degrading to deprecated out-of-band paste. - listener, err = net.Listen("tcp", "127.0.0.1:0") - } if listener == nil { if deviceAuthURL != "" { - logger.Warnf("Could not bind local OIDC listener (ports 13000-13002 busy: %v); falling back to device authorization flow.", err) + logger.Warn("Could not bind local OIDC listener (ports 13000-13002 busy); falling back to device authorization flow.") return n.DeviceLogin(ctx, deviceAuthURL, tokenURL, clientID, audience, requestRefresh) } - logger.Warnf("Could not bind local OIDC listener (ports 13000-13002 busy: %v). Falling back to headless (OOB) authorization.", err) + logger.Warn("Could not bind local OIDC listener (ports 13000-13002 busy). Falling back to headless (OOB) authorization.") isHeadless = true redirectURI = "urn:ietf:wg:oauth:2.0:oob" } else { @@ -232,9 +226,6 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t if !isHeadless { if err := openBrowserFunc(targetURL); err != nil { if deviceAuthURL != "" { - if listener != nil { - _ = listener.Close() - } logger.Warnf("Could not open a browser (%v); falling back to device authorization flow.", err) return n.DeviceLogin(ctx, deviceAuthURL, tokenURL, clientID, audience, requestRefresh) } @@ -298,7 +289,6 @@ func (n *AgentMeshNode) InteractiveLoginWithMode(ctx context.Context, authURL, t } w.Header().Set("Content-Type", "text/html") - w.Header().Set("Connection", "close") _, _ = w.Write([]byte("

Authorization successful!

You can close this window and return to the CLI.

")) select { diff --git a/internal/node/service.go b/internal/node/service.go index 205518ac..0600c02b 100644 --- a/internal/node/service.go +++ b/internal/node/service.go @@ -135,6 +135,17 @@ func NewServiceFromRequest(req *api.RegisterServiceRequest) (Service, error) { 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 @@ -160,6 +171,16 @@ func buildRegisterRequest(sCfg api.ServiceConfig) (*api.RegisterServiceRequest, 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, diff --git a/internal/node/service_registry.go b/internal/node/service_registry.go index 8f0d984d..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. @@ -162,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.provide(nameCtx, nameKey, serviceTarget(info), info.GetIngress()); 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.provide(typeCtx, typeKey, serviceTarget(info), nil); err != nil { - logger.Warnf("[ServiceRegistry] Announcing %s by type: %v", info.Name, err) - } + ingressAnnounced = r.announceServiceKeys(ctx, info, nameKey, typeKey) } r.mu.Lock() @@ -191,7 +204,7 @@ 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 info.GetIngress() != nil && r.onIngressRegistered != nil { + if ingressAnnounced && r.onIngressRegistered != nil { r.onIngressRegistered(info) } } else if r.reprovideNow != nil { @@ -232,13 +245,67 @@ func (r *ServiceRegistry) Unregister(ctx context.Context, name string) error { return nil } -func (r *ServiceRegistry) provide(ctx context.Context, key []byte, service string, ingress *api.ServiceIngress) error { - if ingress != 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 { - return ip.ProvideWithIngress(ctx, key, service, ingress) + 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 r.dht.Provide(ctx, key, service) + + return ingressAnnounced } // serviceTarget is a service as a record names it, "://". @@ -385,21 +452,15 @@ Loop: return } - for _, name := range []string{info.Name, ""} { - key, err := api.ServiceKey(info.Type, name) - if err != nil { - continue - } - var ing *api.ServiceIngress - if name != "" { - ing = info.GetIngress() - } - provideCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - if err := r.provide(provideCtx, key, serviceTarget(info), ing); 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/router/config.go b/internal/router/config.go index d9ca3f6d..dceb38de 100644 --- a/internal/router/config.go +++ b/internal/router/config.go @@ -134,11 +134,6 @@ type Options struct { // non-loopback interface (for example behind a TLS-terminating L7 Gateway) // and permits http:// in IngressURL on non-loopback hosts. AllowInsecureIngressHTTP bool - // OIDCIssuer and Audience optionally override the OIDC issuer and audience - // learned from the control plane's GET /info when verifying OIDC JWTs on - // INGRESS_AUTH_MODE_EDGE requests. - OIDCIssuer string - Audience string // PublicControlPlaneURL optionally overrides ControlPlaneURL in external // RFC 9728 and A2A OAuth discovery metadata when ControlPlaneURL is a // cluster-internal or loopback address. diff --git a/internal/router/directory.go b/internal/router/directory.go index 2483f6a0..d50307c6 100644 --- a/internal/router/directory.go +++ b/internal/router/directory.go @@ -571,9 +571,12 @@ func (r *Router) findProviders(ctx context.Context, key []byte, limit int) []pro directoryLookupsTotal.WithLabelValues(directoryResultOK).Inc() var entries []providerEntry for _, rec := range resp.GetProviders() { - if entry, err := providerEntryOfRecord(rec); err == nil { - entries = append(entries, entry) + 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 <- entries }(target) @@ -638,6 +641,9 @@ func providerEntryOfRecord(rec *api.PeerRecord) (providerEntry, error) { if err != nil { return providerEntry{}, err } + if err := api.ValidatePeerRecordIngress(rec.GetIngress()); err != nil { + return providerEntry{}, err + } return providerEntry{ info: peer.AddrInfo{ID: p, Addrs: addrs}, ingress: cloneServiceIngress(rec.GetIngress()), @@ -671,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/ingress.go b/internal/router/ingress.go index d764a599..d4f369d8 100644 --- a/internal/router/ingress.go +++ b/internal/router/ingress.go @@ -15,7 +15,6 @@ package router import ( - "bytes" "context" "crypto/sha256" "encoding/base64" @@ -33,7 +32,7 @@ import ( "time" "github.com/a2aproject/a2a-go/v2/a2a" - "github.com/coreos/go-oidc/v3/oidc" + "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" @@ -57,9 +56,9 @@ const ( ingressOutcomeRateLimited = "rate_limited" ingressOutcomeBadGateway = "bad_gateway" ingressOutcomeInvalid = "invalid" + ingressOutcomeUnavailable = "unavailable" maxIngressBodyBytes = 32 << 20 - maxIngressMCPBodyBytes = 4 << 20 maxIngressCardBodyBytes = 1 << 20 a2aAgentCardSubpath = ".well-known/agent-card.json" ingressDialTimeout = 10 * time.Second @@ -83,6 +82,7 @@ func init() { ingressOutcomeRateLimited, ingressOutcomeBadGateway, ingressOutcomeInvalid, + ingressOutcomeUnavailable, } { routerIngressRequestsTotal.WithLabelValues(mode, outcome) } @@ -150,7 +150,7 @@ func (r *Router) IngressHandler() http.Handler { }, MaxIdleConnsPerHost: 16, IdleConnTimeout: 60 * time.Second, - ResponseHeaderTimeout: 60 * time.Second, + ResponseHeaderTimeout: 5 * time.Minute, } mux := http.NewServeMux() @@ -167,125 +167,91 @@ func (r *Router) IngressHandler() http.Handler { 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 { - scheme := "http" - if r.config.IngressTLSCertPath != "" { - scheme = "https" + 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 scheme + "://" + r.ingressAddr.String() + "/ingress" + return "" } - if r.config.AdvertiseSinglePortIngress && r.Host != nil && r.Host.Network() != nil { + if r.config.AdvertiseSinglePortIngress { host := strings.TrimSpace(r.config.SinglePortBindHost) - if host == "" { - host = "0.0.0.0" - } - for _, a := range r.Host.Network().ListenAddresses() { - if _, err := a.ValueForProtocol(multiaddr.P_WS); err != nil { - continue - } - port, err := a.ValueForProtocol(multiaddr.P_TCP) - if err != nil { - continue + if !isUnspecifiedBindHost(host) { + if port := r.singlePortWSPort(); port != "" { + return "http://" + net.JoinHostPort(host, port) + "/ingress" } - return "http://" + net.JoinHostPort(host, port) + "/ingress" } } return "" } -func (r *Router) effectiveIngressBaseURL(req *http.Request) string { +// 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 } - scheme := "http" - if r.config.IngressTLSCertPath != "" || (req != nil && req.TLS != nil) { - scheme = "https" + if pub := strings.TrimSuffix(strings.TrimSpace(r.config.PublicControlPlaneURL), "/"); pub != "" { + return pub + "/ingress" } - if req != nil && req.Host != "" { - return scheme + "://" + req.Host + "/ingress" + if r.config.AdvertiseSinglePortIngress { + if port := r.singlePortWSPort(); port != "" { + return "http://" + net.JoinHostPort("127.0.0.1", port) + "/ingress" + } } - return strings.TrimSuffix(r.config.ControlPlaneURL, "/") + "/ingress" + return strings.TrimSuffix(strings.TrimSpace(r.config.ControlPlaneURL), "/") + "/ingress" } -func (r *Router) effectivePublicControlPlaneURL(req *http.Request) string { +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(req), "/ingress") + return strings.TrimSuffix(r.effectiveIngressBaseURL(), "/ingress") } return strings.TrimSuffix(strings.TrimSpace(r.config.ControlPlaneURL), "/") } -func (r *Router) effectiveOIDCIssuer() string { - if configured := strings.TrimSpace(r.config.OIDCIssuer); configured != "" { - return strings.TrimSuffix(configured, "/") - } - if synced := r.syncedOIDCIssuer.Load(); synced != nil && *synced != "" { - return strings.TrimSuffix(*synced, "/") - } - return "" -} - -func (r *Router) allowedAudiences() []string { - var out []string - add := func(s string) { - s = strings.TrimSpace(s) - if s == "" { - return - } - for _, existing := range out { - if existing == s { - return - } - } - out = append(out, s) - } - add(r.config.Audience) - if synced := r.syncedAudience.Load(); synced != nil { - add(*synced) - } - add(api.DefaultAudience) - return out -} - -func (r *Router) resolveOIDCProviders(ctx context.Context) map[string]*oidc.Provider { - iss := r.effectiveOIDCIssuer() - if iss == "" { - return nil - } - r.oidcMu.Lock() - defer r.oidcMu.Unlock() - if r.oidcProviders == nil { - r.oidcProviders = make(map[string]*oidc.Provider) - } - if p, ok := r.oidcProviders[iss]; ok { - return map[string]*oidc.Provider{iss: p} - } - discCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - p, err := oidc.NewProvider(discCtx, iss) - if err != nil { - logger.Warnf("[Ingress] OIDC discovery for %s failed: %v", iss, err) - return nil - } - r.oidcProviders[iss] = p - return map[string]*oidc.Provider{iss: p} -} - -func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, req *http.Request) { - base := r.effectiveIngressBaseURL(req) +func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) { + base := r.effectiveIngressBaseURL() var servers []string - if cp := r.effectivePublicControlPlaneURL(req); cp != "" { + if cp := r.effectivePublicControlPlaneURL(); cp != "" { servers = append(servers, cp) } - if iss := r.effectiveOIDCIssuer(); iss != "" && (len(servers) == 0 || servers[0] != iss) { - servers = append(servers, iss) - } doc := api.OAuthProtectedResourceMetadata{ Resource: base, AuthorizationServers: servers, @@ -294,6 +260,8 @@ func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, r } 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 } @@ -303,31 +271,37 @@ func (r *Router) handleIngressProtectedResourceMetadata(w http.ResponseWriter, r _, _ = w.Write(out) } -func (r *Router) writeIngressUnauthorized(w http.ResponseWriter, req *http.Request, modeMetric, message string) { +func (r *Router) writeIngressUnauthorized(w http.ResponseWriter, modeMetric, message string) { routerIngressRequestsTotal.WithLabelValues(modeMetric, ingressOutcomeUnauthenticated).Inc() - metaURL := r.effectiveIngressBaseURL(req) + "/.well-known/oauth-protected-resource" + 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) - if r.ingressLimiter != nil && !r.ingressLimiter.Allow(clientIP) { - routerIngressRequestsTotal.WithLabelValues(ingressModeUnknown, ingressOutcomeRateLimited).Inc() - logger.Warnf("[Ingress] Rate limit exceeded for client %s", clientIP) - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) - return - } - 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 } @@ -336,6 +310,7 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, 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 } @@ -351,22 +326,26 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, 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 } @@ -380,6 +359,7 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, 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 } @@ -392,13 +372,19 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, } var providerRec *api.PeerRecord for _, rec := range providers { - if rec.GetPeerId() == targetPeer.String() { + 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 } @@ -408,32 +394,17 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, 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 } - 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 - } - r.keysMu.RLock() routerBiscuit := r.credential.biscuit r.keysMu.RUnlock() if len(routerBiscuit) == 0 { - routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() + routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeUnavailable).Inc() + logger.Warn("[Ingress] Router credential unavailable") http.Error(w, "Router credential unavailable", http.StatusServiceUnavailable) return } @@ -441,114 +412,74 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, targetURI := serviceTypeStr + "://" + serviceName backendPath := "/" + subpath - - // Unauthenticated GET discovery of A2A AgentCard (both well-known path and - // bare service root GET for resolvers such as a2a-go). - if svcType == api.ServiceType_SERVICE_TYPE_A2A && + isAgentCardGet := svcType == api.ServiceType_SERVICE_TYPE_A2A && req.Method == http.MethodGet && - (subpath == "" || subpath == a2aAgentCardSubpath) { - 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 - } - - // INGRESS_AUTH_MODE_EDGE: authenticate Bearer token and evaluate policy. - bearerToken, ok := extractBearerToken(req.Header.Get("Authorization")) - if !ok { - r.writeIngressUnauthorized(w, req, modeStr, "Missing or invalid Authorization: Bearer header") - 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 := identity.IngressEvalParams{ - ServiceType: serviceTypeStr, - ServiceName: serviceName, - Method: req.Method, - Path: backendPath, - TargetFacts: targetFacts, - EnforceTargetCheck: true, - MeshPolicyRules: r.meshPolicyRules(), - Allow: ing.GetAllow(), - Timeout: r.config.BiscuitTimeout, - } - - if svcType == api.ServiceType_SERVICE_TYPE_MCP { - mcpTool, allowInit, bodyErr := inspectRouterMCPBody(req) - if bodyErr != nil { - routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeInvalid).Inc() - http.Error(w, bodyErr.Error(), http.StatusRequestEntityTooLarge) - return - } - evalParams.MCPTool = mcpTool - evalParams.AllowMCPStreamInit = allowInit - } + (subpath == "" || subpath == a2aAgentCardSubpath) - var callerRes *identity.IngressCallerResult - var callerBiscuitHdr, callerClaimsHdr string + var ( + verifiedCaller *identity.VerifiedBiscuitClaims + callerBiscuitHdr string + evalParams identity.IngressEvalParams + ) - if isCompactJWT(bearerToken) { - providersMap := r.resolveOIDCProviders(req.Context()) - if len(providersMap) == 0 { - r.writeIngressUnauthorized(w, req, modeStr, "No OIDC provider configured for JWT verification") - return - } - jwtClaims, _, err := identity.VerifyJWT(req.Context(), bearerToken, r.allowedAudiences(), providersMap) - if err != nil { - logger.Warnf("[Ingress] OIDC JWT verification failed from %s for %s: %v", clientIP, targetURI, err) - r.writeIngressUnauthorized(w, req, modeStr, "Invalid Bearer token") - return - } - oidcFacts := identity.OIDCFactsFromJWTClaims(map[string]any(jwtClaims)) - callerRes, err = identity.AuthorizeIngressOIDCFacts(oidcFacts, evalParams) - if err != nil { - routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeDeny).Inc() - logger.Warnf("[Ingress] Edge AuthZ denied OIDC caller from %s for %s: %v", clientIP, targetURI, err) - http.Error(w, "Forbidden", http.StatusForbidden) + // 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 } - callerClaimsHdr, err = identity.MarshalIngressClaimsHeader(oidcFacts) - if err != nil { - routerIngressRequestsTotal.WithLabelValues(modeStr, ingressOutcomeBadGateway).Inc() - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + 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 } - } else { rawCaller, err := identity.DecodeBase64Biscuit(bearerToken) if err != nil { - r.writeIngressUnauthorized(w, req, modeStr, "Invalid Bearer token encoding") + 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, req, modeStr, "Invalid Bearer token") + 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, req, modeStr, "Token has been revoked") + r.writeIngressUnauthorized(w, modeStr, "Token has been revoked") return } for _, pidStr := range []string{verified.NodePeerID, verified.ActorNodePeerID, verified.ClientPeerID} { @@ -564,14 +495,101 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, } } } - callerRes, err = identity.AuthorizeIngressDelegatedBiscuit(verified, evalParams) - if err != nil { + + 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] Edge AuthZ denied Biscuit caller (%s) from %s for %s: %v", verified.Principal(), clientIP, targetURI, err) + 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() @@ -589,7 +607,7 @@ func (r *Router) handleIngressRequest(w http.ResponseWriter, req *http.Request, "path", backendPath, ) - r.forwardIngressHTTP(w, req, transport, targetPeer, serviceTypeStr, serviceName, subpath, hasTrailingOrSubpath, routerBiscuitHeader, api.IngressAuthModeStringEdge, callerBiscuitHdr, callerClaimsHdr) + r.forwardIngressHTTP(w, req, transport, targetPeer, serviceTypeStr, serviceName, subpath, hasTrailingOrSubpath, routerBiscuitHeader, api.IngressAuthModeStringEdge, callerBiscuitHdr) } func (r *Router) forwardIngressHTTP( @@ -599,7 +617,7 @@ func (r *Router) forwardIngressHTTP( targetPeer peer.ID, serviceTypeStr, serviceName, subpath string, hasTrailingOrSubpath bool, - routerBiscuitHeader, modeStr, callerBiscuitHdr, callerClaimsHdr string, + routerBiscuitHeader, modeStr, callerBiscuitHdr string, ) { upstreamPath := "/" + serviceTypeStr + "/" + serviceName if hasTrailingOrSubpath { @@ -618,20 +636,18 @@ func (r *Router) forwardIngressHTTP( pr.Out.URL.RawQuery = pr.In.URL.RawQuery pr.Out.Host = targetPeer.String() - sanitizeRouterOutboundHeaders(pr.Out.Header, stripAuth) + 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) } - if callerClaimsHdr != "" { - pr.Out.Header.Set(api.HeaderMeshIngressClaims, callerClaimsHdr) - } }, 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 } @@ -657,6 +673,7 @@ func (r *Router) serveIngressAgentCard( 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 } @@ -679,6 +696,8 @@ func (r *Router) serveIngressAgentCard( 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) } @@ -695,8 +714,8 @@ func (r *Router) serveIngressAgentCard( return } - serviceBase := fmt.Sprintf("%s/%s/%s/%s", r.effectiveIngressBaseURL(req), targetPeer.String(), api.ServiceTypeStringA2A, serviceName) - if err := r.rewriteAgentCardForIngress(&card, serviceBase, r.effectivePublicControlPlaneURL(req), effectiveMode); err != nil { + 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) @@ -705,6 +724,8 @@ func (r *Router) serveIngressAgentCard( 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 } @@ -757,8 +778,8 @@ func (r *Router) rewriteAgentCardForIngress(card *a2a.AgentCard, serviceBase, pu schemes := a2a.NamedSecuritySchemes{ "bearer": a2a.HTTPAuthSecurityScheme{ Scheme: "bearer", - BearerFormat: "Biscuit or OIDC JWT", - Description: "Agent Mesh delegated session Biscuit or trusted OIDC JWT", + BearerFormat: "Biscuit", + Description: "Agent Mesh delegated session or task Biscuit", }, } reqs := a2a.SecurityRequirementsOptions{ @@ -781,13 +802,6 @@ func (r *Router) rewriteAgentCardForIngress(card *a2a.AgentCard, serviceBase, pu } reqs = append(reqs, a2a.SecurityRequirements{"agentmesh_oauth2": {}}) } - if iss := r.effectiveOIDCIssuer(); iss != "" { - schemes["oidc"] = a2a.OpenIDConnectSecurityScheme{ - Description: "Mesh OIDC Identity Provider", - OpenIDConnectURL: iss + "/.well-known/openid-configuration", - } - reqs = append(reqs, a2a.SecurityRequirements{"oidc": {}}) - } card.SecuritySchemes = schemes card.SecurityRequirements = reqs } @@ -875,6 +889,9 @@ func (r *Router) authenticateIngressMember(ctx context.Context, targetPeer peer. 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) @@ -887,10 +904,12 @@ func splitProviderAddrs(targetPeer peer.ID, rawAddrs []string) ([]multiaddr.Mult 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 { @@ -908,18 +927,6 @@ func splitProviderAddrs(targetPeer peer.ID, rawAddrs []string) ([]multiaddr.Mult return transport, relays } -func sanitizeRouterOutboundHeaders(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, api.HeaderPeerID) { - delete(h, name) - } - } -} - func extractBearerToken(header string) (string, bool) { parts := strings.SplitN(strings.TrimSpace(header), " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { @@ -985,56 +992,3 @@ func ingressETagMatches(header, etag string) bool { } return false } - -func inspectRouterMCPBody(r *http.Request) (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 - } - bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, maxIngressMCPBodyBytes+1)) - if err != nil { - return "", false, fmt.Errorf("failed to read MCP request body: %w", err) - } - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - if len(bodyBytes) > maxIngressMCPBodyBytes { - return "", false, fmt.Errorf("MCP request body exceeds %d byte inspection limit", maxIngressMCPBodyBytes) - } - if len(bytes.TrimSpace(bodyBytes)) == 0 { - return "", false, nil - } - - var envelope struct { - Method string `json:"method"` - Params json.RawMessage `json:"params"` - } - if err := json.Unmarshal(bodyBytes, &envelope); err != nil { - return "", false, nil - } - switch envelope.Method { - case "tools/call": - var params struct { - Name string `json:"name"` - } - if err := json.Unmarshal(envelope.Params, ¶ms); err == nil { - rawTool := strings.TrimSpace(params.Name) - if rawTool == "" { - return "", false, nil - } - if _, stripped, splitErr := api.SplitToolName(rawTool); splitErr == nil { - return stripped, false, nil - } - return rawTool, false, nil - } - return "", false, nil - case "initialize", "notifications/initialized", "server/discover", "ping", - "tools/list", "resources/list", "prompts/list", "notifications/cancelled": - return "", true, nil - default: - return "", false, nil - } -} diff --git a/internal/router/ingress_test.go b/internal/router/ingress_test.go index 8853422c..fa5a942a 100644 --- a/internal/router/ingress_test.go +++ b/internal/router/ingress_test.go @@ -18,12 +18,11 @@ import ( "bytes" "crypto/ed25519" "crypto/rand" - "crypto/rsa" "encoding/base64" "encoding/hex" "encoding/json" "io" - "math/big" + "net" "net/http" "net/http/httptest" "path/filepath" @@ -37,10 +36,12 @@ import ( "github.com/google/agentmesh/api" "github.com/google/agentmesh/internal/identity" "github.com/google/agentmesh/internal/ratelimit" - lru "github.com/hashicorp/golang-lru/v2" 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) { @@ -228,6 +229,20 @@ func TestDirectoryIngressEnforcementAndPropagation(t *testing.T) { } }) + 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, @@ -263,74 +278,20 @@ func TestDirectoryIngressEnforcementAndPropagation(t *testing.T) { }) } -func newTestOIDCServer(t *testing.T) (string, *rsa.PrivateKey) { - t.Helper() - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("rsa.GenerateKey: %v", err) - } - mux := http.NewServeMux() - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - - mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "issuer": srv.URL, - "jwks_uri": srv.URL + "/keys", - }) - }) - mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "keys": []map[string]any{ - { - "kty": "RSA", - "alg": "RS256", - "use": "sig", - "kid": "test-kid", - "n": base64.RawURLEncoding.EncodeToString(privKey.N.Bytes()), - "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privKey.E)).Bytes()), - }, - }, - }) - }) - return srv.URL, privKey -} - -func signTestOIDCJWT(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { - t.Helper() - tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - tok.Header["kid"] = "test-kid" - signed, err := tok.SignedString(key) - if err != nil { - t.Fatalf("SignedString: %v", err) - } - return signed -} - func TestRouterIngressHandlerEndToEnd(t *testing.T) { cpPub, cpPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("GenerateKey: %v", err) } - oidcIssuer, oidcKey := newTestOIDCServer(t) r := newDirectoryRouter(t) r.config.ControlPlaneURL = "https://hub.example.com" - r.config.OIDCIssuer = oidcIssuer - r.config.Audience = api.DefaultAudience r.config.BiscuitTimeout = 2 * time.Second limiter, err := ratelimit.NewPeerRateLimiterWithRate(128, ingressRateLimitPerIP, ingressRateBurstPerIP) if err != nil { t.Fatalf("NewPeerRateLimiterWithRate: %v", err) } r.ingressLimiter = limiter - revoked, err := lru.New[string, time.Time](128) - if err != nil { - t.Fatalf("lru.New: %v", err) - } - r.revokedTokens = revoked routerBiscuitRaw, err := identity.MintBootstrapBiscuitToken(cpPriv, r.Host.ID(), api.RoleRouter, time.Now().Add(time.Hour), []*api.PolicyRole{ {Name: api.RoleRouter, AllowedServices: []string{"*"}, AllowedTargets: []string{"*"}}, @@ -385,7 +346,6 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { MeshIngress string `json:"mesh_ingress"` MeshBiscuit bool `json:"mesh_biscuit"` CallerBiscuit bool `json:"caller_biscuit"` - CallerClaims string `json:"caller_claims"` SpoofedHeader string `json:"spoofed_header"` } ingListener, err := gostream.Listen(provider, api.IngressProtocolID) @@ -431,7 +391,6 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { MeshIngress: req.Header.Get(api.HeaderMeshIngress), MeshBiscuit: req.Header.Get(api.HeaderMeshBiscuit) != "", CallerBiscuit: req.Header.Get(api.HeaderMeshIngressCallerBiscuit) != "", - CallerClaims: req.Header.Get(api.HeaderMeshIngressClaims), SpoofedHeader: req.Header.Get("X-Mesh-Spoofed"), }) }) @@ -509,8 +468,8 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { if doc.Resource != baseURL { t.Fatalf("Resource = %q, want %q", doc.Resource, baseURL) } - if len(doc.AuthorizationServers) < 2 || doc.AuthorizationServers[0] != "https://hub.example.com" || doc.AuthorizationServers[1] != oidcIssuer { - t.Fatalf("AuthorizationServers = %v, want [https://hub.example.com %s]", doc.AuthorizationServers, oidcIssuer) + if len(doc.AuthorizationServers) != 1 || doc.AuthorizationServers[0] != "https://hub.example.com" { + t.Fatalf("AuthorizationServers = %v, want [https://hub.example.com]", doc.AuthorizationServers) } }) @@ -541,7 +500,25 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { } }) - t.Run("EDGE AgentCard discovery rewrites URL, injects SecuritySchemes, and honors ETag", func(t *testing.T) { + 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 { @@ -566,11 +543,14 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { if len(card.Signatures) != 0 { t.Fatalf("Signatures = %v, want cleared after URL rewrite", card.Signatures) } - for _, schemeName := range []a2a.SecuritySchemeName{"bearer", "agentmesh_oauth2", "oidc"} { + 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) @@ -666,7 +646,7 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { t.Fatalf("MCP drop_db status = %d, want 403", dropResp.StatusCode) } - // Revoked Biscuit -> 401. + // 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)) @@ -683,42 +663,6 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { } }) - t.Run("EDGE OIDC JWT verification forwards attested claims", func(t *testing.T) { - jwtStr := signTestOIDCJWT(t, oidcKey, jwt.MapClaims{ - "iss": oidcIssuer, - "sub": "oidc-alice", - "aud": api.DefaultAudience, - "email": "alice@example.com", - "email_verified": true, - "exp": time.Now().Add(time.Hour).Unix(), - "iat": time.Now().Add(-time.Minute).Unix(), - }) - req, _ := http.NewRequest(http.MethodPost, baseURL+"/"+provider.ID().String()+"/a2a/echo", strings.NewReader(`{}`)) - req.Header.Set("Authorization", "Bearer "+jwtStr) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("POST with OIDC JWT: %v", err) - } - var got capturedReq - if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { - t.Fatalf("decode OIDC upstream: %v", err) - } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("OIDC JWT status = %d, want 200", resp.StatusCode) - } - if got.CallerClaims == "" || got.Auth != "" { - t.Fatalf("expected CallerClaims set and Authorization stripped, got %+v", got) - } - claimsMap, err := identity.UnmarshalIngressClaimsHeader(got.CallerClaims) - if err != nil { - t.Fatalf("UnmarshalIngressClaimsHeader: %v", err) - } - if len(claimsMap[api.FactEmail]) != 1 || claimsMap[api.FactEmail][0] != "alice@example.com" { - t.Fatalf("forwarded email claims = %v, want [alice@example.com]", claimsMap[api.FactEmail]) - } - }) - 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") @@ -770,3 +714,308 @@ func TestRouterIngressHandlerEndToEnd(t *testing.T) { } }) } + +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/router.go b/internal/router/router.go index ce7b3dc8..4bc34d84 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -35,14 +35,12 @@ import ( "sync/atomic" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/eclipse-biscuit/biscuit-go/v2" "github.com/google/agentmesh/api" cpclient "github.com/google/agentmesh/internal/controlplane/client" "github.com/google/agentmesh/internal/identity" "github.com/google/agentmesh/internal/p2plimits" "github.com/google/agentmesh/internal/ratelimit" - lru "github.com/hashicorp/golang-lru/v2" golog "github.com/ipfs/go-log/v2" "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" @@ -401,12 +399,7 @@ type Router struct { ingressAddr net.Addr ingressLimiter *ratelimit.PeerRateLimiter - revokedTokens *lru.Cache[string, time.Time] - - oidcMu sync.Mutex - oidcProviders map[string]*oidc.Provider - syncedOIDCIssuer atomic.Pointer[string] - syncedAudience atomic.Pointer[string] + revokedTokens revocationSet // identityLoggedAt is when the "Router Online" line was last written. identityLoggedAt atomic.Pointer[time.Time] @@ -434,11 +427,6 @@ func NewRouter(ctx context.Context, config Options) (*Router, error) { cancel() return nil, fmt.Errorf("failed to create ingress rate limiter: %w", err) } - revokedTokens, err := lru.New[string, time.Time](handshakeLimiterSize) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create revocation cache: %w", err) - } return &Router{ config: config, @@ -446,8 +434,6 @@ func NewRouter(ctx context.Context, config Options) (*Router, error) { cancel: cancel, handshakeLimiter: handshakeLimiter, ingressLimiter: ingressLimiter, - revokedTokens: revokedTokens, - oidcProviders: make(map[string]*oidc.Provider), syncTrigger: make(chan struct{}, 1), }, nil } @@ -1188,40 +1174,148 @@ func (r *Router) syncRevocations() error { 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 } -func (r *Router) recordRevokedBiscuitID(revID string, expiry time.Time) { - if revID == "" || r.revokedTokens == 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 } - r.revokedTokens.Add(revID, expiry) - if raw, err := hex.DecodeString(revID); err == nil && len(raw) > 0 { - r.revokedTokens.Add(base64.RawURLEncoding.EncodeToString(raw), expiry) + 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 (r *Router) isBiscuitRevoked(b *biscuit.Biscuit) bool { - if r == nil || r.revokedTokens == nil || b == nil { +func (s *revocationSet) isRevoked(b *biscuit.Biscuit, now time.Time) bool { + if s == nil || b == nil { return false } - now := time.Now() - for _, id := range b.RevocationIds() { - for _, key := range []string{hex.EncodeToString(id), base64.RawURLEncoding.EncodeToString(id)} { - if exp, ok := r.revokedTokens.Get(key); ok { - if now.Before(exp) { - return true - } - r.revokedTokens.Remove(key) + 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. @@ -1543,13 +1637,6 @@ func (r *Router) connectBootstrapRouters() { return } - if iss := strings.TrimSpace(info.GetOidcIssuer()); iss != "" { - r.syncedOIDCIssuer.Store(&iss) - } - if aud := strings.TrimSpace(info.GetAudience()); aud != "" { - r.syncedAudience.Store(&aud) - } - // /info carries the whole ban set, so this is also where a router that // restarted or missed a MeshEvent_BANNED catches up. r.reconcileBannedPeers(info.GetBannedPeerIds(), fetchedAt) @@ -1812,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}) @@ -1885,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 { diff --git a/internal/standalone/standalone.go b/internal/standalone/standalone.go index 797c7371..302543fd 100644 --- a/internal/standalone/standalone.go +++ b/internal/standalone/standalone.go @@ -389,6 +389,7 @@ func (s *Server) Start(ctx context.Context) (retErr error) { } var externalAddrs []string var ingressURL string + var publicControlPlaneURL string allowInsecureIngress := true if s.opts.ExternalURL != "" { ext, err := externalMultiaddr(s.opts.ExternalURL) @@ -396,7 +397,8 @@ func (s *Server) Start(ctx context.Context) (retErr error) { return err } externalAddrs = []string{ext} - ingressURL = strings.TrimRight(s.opts.ExternalURL, "/") + "/ingress" + publicControlPlaneURL = strings.TrimRight(s.opts.ExternalURL, "/") + ingressURL = publicControlPlaneURL + "/ingress" allowInsecureIngress = strings.HasPrefix(s.opts.ExternalURL, "http://") } @@ -407,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, @@ -585,19 +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, - api.IngressAuthMode_INGRESS_AUTH_MODE_PASSTHROUGH, }, }} return []*api.PolicyRole{ {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{"*"}, AllowedIngress: devIngress}, + {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 2ecaddf7..c2a48082 100644 --- a/internal/standalone/standalone_test.go +++ b/internal/standalone/standalone_test.go @@ -22,6 +22,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "testing" "time" @@ -206,8 +207,44 @@ func TestWrapPublicHTTPHandlerCountsProxiedRequestsWithoutExternalURL(t *testing } } +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(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() srv, err := New(Options{ @@ -295,14 +332,20 @@ func TestStandaloneSinglePortIngressAndSeededPolicy(t *testing.T) { if err := protojson.Unmarshal(polBody, &pol); err != nil { t.Fatalf("unmarshal PolicyConfig: %v", err) } - foundNodeIngress := false + var nodeRole *api.PolicyRole for _, role := range pol.GetRoles() { - if role.GetName() == api.RoleNode && len(role.GetAllowedIngress()) > 0 { - foundNodeIngress = true + if role.GetName() == api.RoleNode { + nodeRole = role break } } - if !foundNodeIngress { + 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/sdk/js/src/gen/agentmesh_pb.ts b/sdk/js/src/gen/agentmesh_pb.ts index baa12d69..5cda8a51 100644 --- a/sdk/js/src/gen/agentmesh_pb.ts +++ b/sdk/js/src/gen/agentmesh_pb.ts @@ -3312,7 +3312,7 @@ export enum IngressAuthMode { UNSPECIFIED = 0, /** - * Router verifies Mesh Biscuit or trusted OIDC JWT + mesh policy at the edge. + * Router and provider node verify a delegated Mesh Biscuit + mesh policy. * * @generated from enum value: INGRESS_AUTH_MODE_EDGE = 1; */ 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 index 86d83882..83eb4344 100644 --- a/site/content/docs/guides/external-ingress.md +++ b/site/content/docs/guides/external-ingress.md @@ -44,8 +44,10 @@ administrator and the node owner opt in: ## What the admin writes In `agentmesh-one` development mode (no `--policy-file`), `mesh:role:node` -includes `allowed_ingress` out of the box so local relays and Cloud Run sandboxes -work with zero configuration. +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` @@ -94,8 +96,8 @@ 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 OAuth/OIDC/Biscuit - # identity and evaluate Datalog RBAC, TAR attenuation, and optional allow list. + # 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" @@ -121,31 +123,41 @@ from the mesh's router lease and exposes it in `agentmesh-node debug services`: [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 receive `401 Unauthorized` with an RFC 9728 header: +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 - either: - - a **Delegated Session Biscuit** minted by the control plane's OAuth 2.1 - Authorization Server (`/oauth/authorize` + `/oauth/token`) or - `POST /token/exchange` (optionally attenuated with a - `TaskAuthorizationRule`), or - - a **trusted OIDC JWT** issued by the mesh's `oidc_issuer`. -3. Both the router (at the network edge) and the serving node (defense in depth - over `/mesh/ingress/1.0.0`) verify the caller's identity and enforce the - intersection of mesh Datalog RBAC, any appended `TaskAuthorizationRule` - (including MCP `tools/call` tool names), `ServiceIngress.allow`, and local +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`. 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`, - `agentmesh_oauth2`, and `oidc`. + `signatures` cleared, and `securitySchemes` populated with `bearer` + (`bearerFormat: "Biscuit"`) and `agentmesh_oauth2`. ### `auth_mode: passthrough` (opt-in) @@ -155,6 +167,11 @@ 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) @@ -173,6 +190,7 @@ agentmesh-one --external-url https://my-hub.run.app --data-dir /data ```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 @@ -183,7 +201,12 @@ agentmesh-one --external-url https://my-hub.run.app --data-dir /data 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. With the Helm chart (`charts/agentmesh`), `router.ingressPort` (`8090` by +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`), diff --git a/site/content/docs/reference/router.md b/site/content/docs/reference/router.md index 8b9c180b..93034bb2 100644 --- a/site/content/docs/reference/router.md +++ b/site/content/docs/reference/router.md @@ -52,7 +52,6 @@ replica count. | `--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`. | -| `--oidc-issuer`, `--audience` | from `/info` | Optional overrides for OIDC JWT verification on `edge` ingress requests. | | `--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`. | @@ -150,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. | @@ -164,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/integration/ingress_test.go b/tests/integration/ingress_test.go index bf57ba4a..faf22f17 100644 --- a/tests/integration/ingress_test.go +++ b/tests/integration/ingress_test.go @@ -82,6 +82,37 @@ func postOperatorPolicy(t *testing.T, ctx context.Context, baseURL, adminToken s } } +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, @@ -186,13 +217,13 @@ func enrollAndStartRuntimeNode( // - 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, -// OIDC JWT + ServiceIngress.allow ACL enforcement, stock A2A SendMessage & SSE SendStreamingMessage, -// signed X-AgentMesh-Identity-Evidence delivery to the backend, and RFC 8693 /oauth/token Delegated -// Session Biscuit with TAR MCP tool attenuation -// - Opt-in PASSTHROUGH mode: upstream Bearer pair token preserved end-to-end while spoofed X-AgentMesh-* +// 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(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() oidcURL, mintJWT := startCustomMockOIDC(t) @@ -389,8 +420,8 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { if prm.Resource != srv.IngressURL() { t.Errorf("PRM resource = %q, want %q", prm.Resource, srv.IngressURL()) } - if len(prm.AuthorizationServers) < 2 || prm.AuthorizationServers[0] != srv.PublicURL() || prm.AuthorizationServers[1] != oidcURL { - t.Errorf("PRM authorization_servers = %v, want [%s %s]", prm.AuthorizationServers, srv.PublicURL(), oidcURL) + 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/... @@ -443,9 +474,25 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { t.Fatalf("unauthenticated POST = %d WWW-Authenticate=%q, want 401 with resource_metadata", unauthResp.StatusCode, unauthResp.Header.Get("WWW-Authenticate")) } - // OIDC caller permitted by mesh role ("external-caller") but excluded by ServiceIngress.allow gets 403. + // 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"}) - deniedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(otherJWT, nil))) + 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) } @@ -453,9 +500,9 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { t.Fatal("SendMessage with caller excluded by ServiceIngress.allow succeeded, want error") } - // Authorized OIDC caller succeeds on both SendMessage and SSE SendStreamingMessage. - yaroslavJWT := mintJWT(map[string]any{"sub": "yaroslav-sub", "email": "yaroslav@example.com"}) - allowedClient, err := a2aclient.NewFromCard(ctx, publicCoderCard, a2aclient.WithJSONRPCTransport(bearerClient(yaroslavJWT, nil))) + // 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) } @@ -489,28 +536,7 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { // 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"]}}]}` - form := url.Values{ - "grant_type": {api.GrantTypeTokenExchange}, - "subject_token": {yaroslavJWT}, - "subject_token_type": {"urn:ietf:params:oauth:token-type:id_token"}, - "options": {tarJSON}, - "seal": {"true"}, - } - tokResp, err := httpClient.PostForm(srv.PublicURL()+"/oauth/token", form) - if err != nil { - t.Fatalf("POST /oauth/token: %v", err) - } - tokBody, _ := io.ReadAll(tokResp.Body) - _ = tokResp.Body.Close() - if tokResp.StatusCode != http.StatusOK { - t.Fatalf("POST /oauth/token = %s: %s", tokResp.Status, tokBody) - } - var oauthOut struct { - AccessToken string `json:"access_token"` - } - if err := json.Unmarshal(tokBody, &oauthOut); err != nil || oauthOut.AccessToken == "" { - t.Fatalf("decode /oauth/token response: %v (%s)", err, tokBody) - } + 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) @@ -519,7 +545,7 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { for { mcpSess, err = mcpCli.Connect(ctx, &mcp.StreamableClientTransport{ Endpoint: mcpURL, - HTTPClient: bearerClient(oauthOut.AccessToken, nil), + HTTPClient: bearerClient(tarBiscuit, nil), }, nil) if err == nil { break @@ -589,10 +615,12 @@ func TestStandaloneExternalIngressCUJ(t *testing.T) { // 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 by the router directory. +// 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(), 25*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() oidcURL, mintJWT := startCustomMockOIDC(t) @@ -782,7 +810,8 @@ func TestFederatedMultiRouterIngressRoleCeilingAndBan(t *testing.T) { time.Sleep(100 * time.Millisecond) } - // Ungranted PASSTHROUGH service was rejected by Router A's directory ceiling check -> 404 on Router B. + // 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) @@ -791,10 +820,29 @@ func TestFederatedMultiRouterIngressRoleCeilingAndBan(t *testing.T) { 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 invokes a2a://coder through Router B -> Router A circuit relay -> private node. + // 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"}) - a2aCli, err := a2aclient.NewFromCard(ctx, resolvedCard, a2aclient.WithJSONRPCTransport(bearerClient(alanJWT, nil))) + 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) } From 4a04b858d1b409fd124004e0d1a478d3751c213d Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 1 Nov 2026 09:06:21 +0000 Subject: [PATCH 3/4] node: serve /mesh/ingress only to routers and inventory every protocol's gate The external ingress protocol was registered through meshHost, so any peer that had completed the handshake with the node, a member included, could open a /mesh/ingress/1.0.0 stream and reach the HTTP server behind it before the handler's X-Mesh-Biscuit check refused it with 403. The gate now decides first: routerHost wraps the handler in requireRouter, which waits for the peer's handshake like requireAuthenticated and then resets the stream of any peer that did not authenticate as mesh:role:router, logging it and counting it under agentmesh_node_streams_refused_total{scope="not_router"}. The handler's credential check stays as the second, independent layer. To know a peer's role at the gate, HandleAuthHandshake verifies the credential with VerifyBiscuitAdmission and records the role under the key that verified it: every admitted peer in authPeers as before, and routers additionally in authRouters with the same expiry. isAdmittedRouter requires both, so a ban or an eviction that drops the admission withdraws the router standing with it. A router the node attached to is already role-verified on that session and passes through isAuthenticatedAndConnected. A protocol added without a gate used to go unnoticed until someone read the code. TestNodeProtocolInventoryIsClassified lists every protocol a started node registers and fails on any that is not named in one of three sets: handshakeProtocols (open before the handshake), credentialProtocols (the handler verifies a Biscuit in the stream) or gatedProtocols. For each gated protocol it opens a stream from an unauthenticated peer, checks that nothing is answered, and checks the refusal counter once the peer disconnects. TestExternalIngressProtocolServesOnlyRouters checks that an admitted member is reset at the gate and counted, that an admitted router reaches the handler, and that a ban withdraws router standing. Both fail against the previous code: the first on an ungated protocol, the second on the member receiving an HTTP 401 from the handler. --- internal/node/auth_gate.go | 49 ++++ internal/node/controlplane_sync.go | 2 +- internal/node/ingress.go | 7 +- internal/node/node.go | 68 ++++- internal/node/protocol_inventory_test.go | 279 +++++++++++++++++++ site/content/docs/guides/external-ingress.md | 6 +- 6 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 internal/node/protocol_inventory_test.go 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/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/ingress.go b/internal/node/ingress.go index dffe2bc4..c0bdab10 100644 --- a/internal/node/ingress.go +++ b/internal/node/ingress.go @@ -112,9 +112,12 @@ func (n *AgentMeshNode) logIngressURLs(info *api.ServiceInfo) { // StartExternalIngressServer starts the HTTP-over-libp2p server on // api.IngressProtocolID (/mesh/ingress/1.0.0) that receives external HTTP -// requests forwarded by enrolled routers. +// 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 := &meshHost{Host: n.Host, n: n} + gated := &routerHost{meshHost{Host: n.Host, n: n}} listener, err := gostream.Listen(gated, api.IngressProtocolID) if err != nil { return err diff --git a/internal/node/node.go b/internal/node/node.go index a706c9fa..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 @@ -2073,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/site/content/docs/guides/external-ingress.md b/site/content/docs/guides/external-ingress.md index 83eb4344..81385402 100644 --- a/site/content/docs/guides/external-ingress.md +++ b/site/content/docs/guides/external-ingress.md @@ -150,7 +150,11 @@ to announcing the service internally so mesh peers can still reach it. 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`. + `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 From 339ec9c857db74b2aef98b8bf4650f5e2e0a74ee Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 1 Nov 2026 10:12:24 +0000 Subject: [PATCH 4/4] api, router: canonical node: allow entries and name the plaintext risk ServiceIngressFromConfig is where an operator's ingress.allow text enters the mesh. A "node:" entry was validated by decoding it and then stored as typed, so a record could carry the CIDv1 spelling of a peer ID while every comparison decoded both sides again. The config boundary now rewrites the entry to the canonical base58 form once, and the validator stays a validator with no side effect on its input. The help text of --allow-insecure-ingress-http said when to set it but not what it costs: every caller's bearer Biscuit and request body then cross the network in plaintext between the client and the router. It now says so and names the one deployment where that is acceptable. --- api/ingress.go | 13 ++++++++++++- api/ingress_test.go | 19 +++++++++++++++++++ cmd/agentmesh-router/main.go | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/api/ingress.go b/api/ingress.go index 58b20ac4..84bb4451 100644 --- a/api/ingress.go +++ b/api/ingress.go @@ -83,7 +83,10 @@ func ParseIngressAuthMode(s string) (IngressAuthMode, error) { } // ServiceIngressFromConfig converts a YAML IngressConfig into a validated -// protobuf ServiceIngress message. +// 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 @@ -99,6 +102,14 @@ func ServiceIngressFromConfig(svcType ServiceType, cfg *IngressConfig) (*Service 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 } diff --git a/api/ingress_test.go b/api/ingress_test.go index 13ada69a..14472b38 100644 --- a/api/ingress_test.go +++ b/api/ingress_test.go @@ -17,6 +17,7 @@ package api import ( "crypto/ed25519" "crypto/rand" + "slices" "testing" "github.com/eclipse-biscuit/biscuit-go/v2" @@ -130,6 +131,24 @@ func TestServiceIngressFromConfig(t *testing.T) { 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) { diff --git a/cmd/agentmesh-router/main.go b/cmd/agentmesh-router/main.go index 5f880e55..5b702fe1 100644 --- a/cmd/agentmesh-router/main.go +++ b/cmd/agentmesh-router/main.go @@ -185,7 +185,7 @@ func main() { 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 (only when behind a TLS-terminating L7 load balancer) and allow http:// in --ingress-url") + 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)