From 3bcc7298a29dadc6b6432ecbd3c52c1c27de9a69 Mon Sep 17 00:00:00 2001 From: youfak Date: Fri, 7 Aug 2026 16:16:00 +0800 Subject: [PATCH] feat: support static gateway direct routes --- api/proto/controlplane/v1/controlplane.proto | 8 + docs/configuration/reference.md | 9 + gen/controlplane/v1/controlplane.pb.go | 272 +++++++++++------- internal/config/config.go | 1 + internal/config/config_test.go | 16 ++ internal/config/validate.go | 36 +++ .../worker/routing_snapshot_source.go | 34 ++- .../worker/routing_snapshot_source_test.go | 21 ++ internal/gateway/controlplane/watcher.go | 41 ++- internal/gateway/controlplane/watcher_test.go | 24 ++ internal/gateway/dispatch/dispatcher.go | 1 + internal/gateway/server/handler.go | 63 +++- internal/gateway/server/handler_test.go | 121 +++++++- internal/gateway/server/routing.go | 4 +- internal/gateway/server/routing_test.go | 19 ++ 15 files changed, 544 insertions(+), 126 deletions(-) diff --git a/api/proto/controlplane/v1/controlplane.proto b/api/proto/controlplane/v1/controlplane.proto index 1e8f963..d1fbd5a 100644 --- a/api/proto/controlplane/v1/controlplane.proto +++ b/api/proto/controlplane/v1/controlplane.proto @@ -99,6 +99,7 @@ message RoutingRule { RoutingStrategy strategy = 8; UnavailableAction on_unavailable = 9; google.protobuf.Duration wait_timeout = 10; + RoutingAction action = 11; } message RoutingStrategy { @@ -123,6 +124,13 @@ enum UnavailableAction { UNAVAILABLE_ACTION_DIRECT = 3; } +enum RoutingAction { + ROUTING_ACTION_UNSPECIFIED = 0; + ROUTING_ACTION_PROXY = 1; + ROUTING_ACTION_DIRECT = 2; + ROUTING_ACTION_REJECT = 3; +} + message OwnedProxy { string id = 1; string upstream = 2; diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index e97ff21..7ba2c74 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -444,6 +444,7 @@ routing: - name: api-post enabled: true purpose: gateway + action: proxy match: hostRegex: '^api\\.example\\.com$' methods: [POST] @@ -463,6 +464,10 @@ routing: - Routing 列表有序,首条匹配后停止。 - `purpose` 为 `gateway` 或 `extract`。 +- `action` 省略时为 `proxy`。`proxy` 使用 Upstream 与 Strategy;`direct` 直接连接已通过 + 目标地址策略验证的目标;`reject` 拒绝匹配请求。`direct` 与 `reject` 仅允许 + `gateway` Routing,且不得同时配置 `upstreams`、`strategy`、`onUnavailable` 或 + `check.targets`。 - `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、 `leastConnections`。 - `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。 @@ -470,6 +475,10 @@ routing: `switchAfterEmptyFetch`;`endBehavior` 省略时默认为 `stop`,也可显式设置 `loop` 或 `stayLast`。 - `onUnavailable.action` 为 `reject`、`wait` 或 `direct`;默认建议 `reject`。 +- `action: direct` 在 Dispatcher 前执行,不申请 Proxy 容量、不写 Proxy Outcome,也不建立 + 粘性 Proxy 绑定;它仍执行入口认证、Client 路由约束、速率/并发限制和目标地址策略。 + `onUnavailable.action: direct` 仅是 `action: proxy` 在本地没有候选时的回退,二者 + 不可互换。 - `check.targets` 是 Routing 级 HTTP/HTTPS 探测目标。每个 Routing 最多 16 个 URL,且一个 启用 Upstream 被其引用的 TARGET Profile 总数最多 64 个。每个 Profile 的身份是 `(routing.name, target URL)`;失败只影响该 Profile,不改变 Proxy 的全局健康状态。 diff --git a/gen/controlplane/v1/controlplane.pb.go b/gen/controlplane/v1/controlplane.pb.go index ba4cf73..f4c8afa 100644 --- a/gen/controlplane/v1/controlplane.pb.go +++ b/gen/controlplane/v1/controlplane.pb.go @@ -134,6 +134,58 @@ func (UnavailableAction) EnumDescriptor() ([]byte, []int) { return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{1} } +type RoutingAction int32 + +const ( + RoutingAction_ROUTING_ACTION_UNSPECIFIED RoutingAction = 0 + RoutingAction_ROUTING_ACTION_PROXY RoutingAction = 1 + RoutingAction_ROUTING_ACTION_DIRECT RoutingAction = 2 + RoutingAction_ROUTING_ACTION_REJECT RoutingAction = 3 +) + +// Enum value maps for RoutingAction. +var ( + RoutingAction_name = map[int32]string{ + 0: "ROUTING_ACTION_UNSPECIFIED", + 1: "ROUTING_ACTION_PROXY", + 2: "ROUTING_ACTION_DIRECT", + 3: "ROUTING_ACTION_REJECT", + } + RoutingAction_value = map[string]int32{ + "ROUTING_ACTION_UNSPECIFIED": 0, + "ROUTING_ACTION_PROXY": 1, + "ROUTING_ACTION_DIRECT": 2, + "ROUTING_ACTION_REJECT": 3, + } +) + +func (x RoutingAction) Enum() *RoutingAction { + p := new(RoutingAction) + *p = x + return p +} + +func (x RoutingAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RoutingAction) Descriptor() protoreflect.EnumDescriptor { + return file_controlplane_v1_controlplane_proto_enumTypes[2].Descriptor() +} + +func (RoutingAction) Type() protoreflect.EnumType { + return &file_controlplane_v1_controlplane_proto_enumTypes[2] +} + +func (x RoutingAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RoutingAction.Descriptor instead. +func (RoutingAction) EnumDescriptor() ([]byte, []int) { + return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{2} +} + type ProxyProtocol int32 const ( @@ -170,11 +222,11 @@ func (x ProxyProtocol) String() string { } func (ProxyProtocol) Descriptor() protoreflect.EnumDescriptor { - return file_controlplane_v1_controlplane_proto_enumTypes[2].Descriptor() + return file_controlplane_v1_controlplane_proto_enumTypes[3].Descriptor() } func (ProxyProtocol) Type() protoreflect.EnumType { - return &file_controlplane_v1_controlplane_proto_enumTypes[2] + return &file_controlplane_v1_controlplane_proto_enumTypes[3] } func (x ProxyProtocol) Number() protoreflect.EnumNumber { @@ -183,7 +235,7 @@ func (x ProxyProtocol) Number() protoreflect.EnumNumber { // Deprecated: Use ProxyProtocol.Descriptor instead. func (ProxyProtocol) EnumDescriptor() ([]byte, []int) { - return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{2} + return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{3} } type OutcomeStage int32 @@ -225,11 +277,11 @@ func (x OutcomeStage) String() string { } func (OutcomeStage) Descriptor() protoreflect.EnumDescriptor { - return file_controlplane_v1_controlplane_proto_enumTypes[3].Descriptor() + return file_controlplane_v1_controlplane_proto_enumTypes[4].Descriptor() } func (OutcomeStage) Type() protoreflect.EnumType { - return &file_controlplane_v1_controlplane_proto_enumTypes[3] + return &file_controlplane_v1_controlplane_proto_enumTypes[4] } func (x OutcomeStage) Number() protoreflect.EnumNumber { @@ -238,7 +290,7 @@ func (x OutcomeStage) Number() protoreflect.EnumNumber { // Deprecated: Use OutcomeStage.Descriptor instead. func (OutcomeStage) EnumDescriptor() ([]byte, []int) { - return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{3} + return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{4} } type CheckLevel int32 @@ -277,11 +329,11 @@ func (x CheckLevel) String() string { } func (CheckLevel) Descriptor() protoreflect.EnumDescriptor { - return file_controlplane_v1_controlplane_proto_enumTypes[4].Descriptor() + return file_controlplane_v1_controlplane_proto_enumTypes[5].Descriptor() } func (CheckLevel) Type() protoreflect.EnumType { - return &file_controlplane_v1_controlplane_proto_enumTypes[4] + return &file_controlplane_v1_controlplane_proto_enumTypes[5] } func (x CheckLevel) Number() protoreflect.EnumNumber { @@ -290,7 +342,7 @@ func (x CheckLevel) Number() protoreflect.EnumNumber { // Deprecated: Use CheckLevel.Descriptor instead. func (CheckLevel) EnumDescriptor() ([]byte, []int) { - return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{4} + return file_controlplane_v1_controlplane_proto_rawDescGZIP(), []int{5} } type RegisterWorkerRequest struct { @@ -885,6 +937,7 @@ type RoutingRule struct { Strategy *RoutingStrategy `protobuf:"bytes,8,opt,name=strategy,proto3" json:"strategy,omitempty"` OnUnavailable UnavailableAction `protobuf:"varint,9,opt,name=on_unavailable,json=onUnavailable,proto3,enum=proxy_pool.controlplane.v1.UnavailableAction" json:"on_unavailable,omitempty"` WaitTimeout *durationpb.Duration `protobuf:"bytes,10,opt,name=wait_timeout,json=waitTimeout,proto3" json:"wait_timeout,omitempty"` + Action RoutingAction `protobuf:"varint,11,opt,name=action,proto3,enum=proxy_pool.controlplane.v1.RoutingAction" json:"action,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -989,6 +1042,13 @@ func (x *RoutingRule) GetWaitTimeout() *durationpb.Duration { return nil } +func (x *RoutingRule) GetAction() RoutingAction { + if x != nil { + return x.Action + } + return RoutingAction_ROUTING_ACTION_UNSPECIFIED +} + type RoutingStrategy struct { state protoimpl.MessageState `protogen:"open.v1"` Type StrategyType `protobuf:"varint,1,opt,name=type,proto3,enum=proxy_pool.controlplane.v1.StrategyType" json:"type,omitempty"` @@ -2240,7 +2300,7 @@ const file_controlplane_v1_controlplane_proto_rawDesc = "" + "\x10upserted_routing\x18\x06 \x03(\v2'.proxy_pool.controlplane.v1.RoutingRuleR\x0fupsertedRouting\x122\n" + "\x15removed_routing_names\x18\a \x03(\tR\x13removedRoutingNames\x12Q\n" + "\x10upserted_proxies\x18\b \x03(\v2&.proxy_pool.controlplane.v1.OwnedProxyR\x0fupsertedProxies\x12*\n" + - "\x11removed_proxy_ids\x18\t \x03(\tR\x0fremovedProxyIds\"\x9a\x04\n" + + "\x11removed_proxy_ids\x18\t \x03(\tR\x0fremovedProxyIds\"\xdd\x04\n" + "\vRoutingRule\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1d\n" + @@ -2254,7 +2314,8 @@ const file_controlplane_v1_controlplane_proto_rawDesc = "" + "\bstrategy\x18\b \x01(\v2+.proxy_pool.controlplane.v1.RoutingStrategyR\bstrategy\x12T\n" + "\x0eon_unavailable\x18\t \x01(\x0e2-.proxy_pool.controlplane.v1.UnavailableActionR\ronUnavailable\x12<\n" + "\fwait_timeout\x18\n" + - " \x01(\v2\x19.google.protobuf.DurationR\vwaitTimeout\x1a:\n" + + " \x01(\v2\x19.google.protobuf.DurationR\vwaitTimeout\x12A\n" + + "\x06action\x18\v \x01(\x0e2).proxy_pool.controlplane.v1.RoutingActionR\x06action\x1a:\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\x02\n" + @@ -2397,7 +2458,12 @@ const file_controlplane_v1_controlplane_proto_rawDesc = "" + "\x1eUNAVAILABLE_ACTION_UNSPECIFIED\x10\x00\x12\x1d\n" + "\x19UNAVAILABLE_ACTION_REJECT\x10\x01\x12\x1b\n" + "\x17UNAVAILABLE_ACTION_WAIT\x10\x02\x12\x1d\n" + - "\x19UNAVAILABLE_ACTION_DIRECT\x10\x03*}\n" + + "\x19UNAVAILABLE_ACTION_DIRECT\x10\x03*\x7f\n" + + "\rRoutingAction\x12\x1e\n" + + "\x1aROUTING_ACTION_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14ROUTING_ACTION_PROXY\x10\x01\x12\x19\n" + + "\x15ROUTING_ACTION_DIRECT\x10\x02\x12\x19\n" + + "\x15ROUTING_ACTION_REJECT\x10\x03*}\n" + "\rProxyProtocol\x12\x1e\n" + "\x1aPROXY_PROTOCOL_UNSPECIFIED\x10\x00\x12\x17\n" + "\x13PROXY_PROTOCOL_HTTP\x10\x01\x12\x18\n" + @@ -2437,102 +2503,104 @@ func file_controlplane_v1_controlplane_proto_rawDescGZIP() []byte { return file_controlplane_v1_controlplane_proto_rawDescData } -var file_controlplane_v1_controlplane_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_controlplane_v1_controlplane_proto_enumTypes = make([]protoimpl.EnumInfo, 6) var file_controlplane_v1_controlplane_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_controlplane_v1_controlplane_proto_goTypes = []any{ (StrategyType)(0), // 0: proxy_pool.controlplane.v1.StrategyType (UnavailableAction)(0), // 1: proxy_pool.controlplane.v1.UnavailableAction - (ProxyProtocol)(0), // 2: proxy_pool.controlplane.v1.ProxyProtocol - (OutcomeStage)(0), // 3: proxy_pool.controlplane.v1.OutcomeStage - (CheckLevel)(0), // 4: proxy_pool.controlplane.v1.CheckLevel - (*RegisterWorkerRequest)(nil), // 5: proxy_pool.controlplane.v1.RegisterWorkerRequest - (*RegisterWorkerResponse)(nil), // 6: proxy_pool.controlplane.v1.RegisterWorkerResponse - (*WatchSnapshotsRequest)(nil), // 7: proxy_pool.controlplane.v1.WatchSnapshotsRequest - (*SnapshotEnvelope)(nil), // 8: proxy_pool.controlplane.v1.SnapshotEnvelope - (*WorkerSnapshot)(nil), // 9: proxy_pool.controlplane.v1.WorkerSnapshot - (*SnapshotCredential)(nil), // 10: proxy_pool.controlplane.v1.SnapshotCredential - (*SnapshotDelta)(nil), // 11: proxy_pool.controlplane.v1.SnapshotDelta - (*RoutingRule)(nil), // 12: proxy_pool.controlplane.v1.RoutingRule - (*RoutingStrategy)(nil), // 13: proxy_pool.controlplane.v1.RoutingStrategy - (*OwnedProxy)(nil), // 14: proxy_pool.controlplane.v1.OwnedProxy - (*AcknowledgeSnapshotRequest)(nil), // 15: proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest - (*OutcomeBatch)(nil), // 16: proxy_pool.controlplane.v1.OutcomeBatch - (*ProxyOutcome)(nil), // 17: proxy_pool.controlplane.v1.ProxyOutcome - (*ReportOutcomesResponse)(nil), // 18: proxy_pool.controlplane.v1.ReportOutcomesResponse - (*ReportRuntimeRequest)(nil), // 19: proxy_pool.controlplane.v1.ReportRuntimeRequest - (*ProxyRuntime)(nil), // 20: proxy_pool.controlplane.v1.ProxyRuntime - (*ReportRuntimeResponse)(nil), // 21: proxy_pool.controlplane.v1.ReportRuntimeResponse - (*StreamCheckTasksRequest)(nil), // 22: proxy_pool.controlplane.v1.StreamCheckTasksRequest - (*CheckTask)(nil), // 23: proxy_pool.controlplane.v1.CheckTask - (*ObservationBatch)(nil), // 24: proxy_pool.controlplane.v1.ObservationBatch - (*HealthObservation)(nil), // 25: proxy_pool.controlplane.v1.HealthObservation - (*ReportObservationsResponse)(nil), // 26: proxy_pool.controlplane.v1.ReportObservationsResponse - nil, // 27: proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry - nil, // 28: proxy_pool.controlplane.v1.RoutingRule.HeadersEntry - nil, // 29: proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry - nil, // 30: proxy_pool.controlplane.v1.OwnedProxy.TagsEntry - (*durationpb.Duration)(nil), // 31: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 32: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 33: google.protobuf.Empty + (RoutingAction)(0), // 2: proxy_pool.controlplane.v1.RoutingAction + (ProxyProtocol)(0), // 3: proxy_pool.controlplane.v1.ProxyProtocol + (OutcomeStage)(0), // 4: proxy_pool.controlplane.v1.OutcomeStage + (CheckLevel)(0), // 5: proxy_pool.controlplane.v1.CheckLevel + (*RegisterWorkerRequest)(nil), // 6: proxy_pool.controlplane.v1.RegisterWorkerRequest + (*RegisterWorkerResponse)(nil), // 7: proxy_pool.controlplane.v1.RegisterWorkerResponse + (*WatchSnapshotsRequest)(nil), // 8: proxy_pool.controlplane.v1.WatchSnapshotsRequest + (*SnapshotEnvelope)(nil), // 9: proxy_pool.controlplane.v1.SnapshotEnvelope + (*WorkerSnapshot)(nil), // 10: proxy_pool.controlplane.v1.WorkerSnapshot + (*SnapshotCredential)(nil), // 11: proxy_pool.controlplane.v1.SnapshotCredential + (*SnapshotDelta)(nil), // 12: proxy_pool.controlplane.v1.SnapshotDelta + (*RoutingRule)(nil), // 13: proxy_pool.controlplane.v1.RoutingRule + (*RoutingStrategy)(nil), // 14: proxy_pool.controlplane.v1.RoutingStrategy + (*OwnedProxy)(nil), // 15: proxy_pool.controlplane.v1.OwnedProxy + (*AcknowledgeSnapshotRequest)(nil), // 16: proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest + (*OutcomeBatch)(nil), // 17: proxy_pool.controlplane.v1.OutcomeBatch + (*ProxyOutcome)(nil), // 18: proxy_pool.controlplane.v1.ProxyOutcome + (*ReportOutcomesResponse)(nil), // 19: proxy_pool.controlplane.v1.ReportOutcomesResponse + (*ReportRuntimeRequest)(nil), // 20: proxy_pool.controlplane.v1.ReportRuntimeRequest + (*ProxyRuntime)(nil), // 21: proxy_pool.controlplane.v1.ProxyRuntime + (*ReportRuntimeResponse)(nil), // 22: proxy_pool.controlplane.v1.ReportRuntimeResponse + (*StreamCheckTasksRequest)(nil), // 23: proxy_pool.controlplane.v1.StreamCheckTasksRequest + (*CheckTask)(nil), // 24: proxy_pool.controlplane.v1.CheckTask + (*ObservationBatch)(nil), // 25: proxy_pool.controlplane.v1.ObservationBatch + (*HealthObservation)(nil), // 26: proxy_pool.controlplane.v1.HealthObservation + (*ReportObservationsResponse)(nil), // 27: proxy_pool.controlplane.v1.ReportObservationsResponse + nil, // 28: proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry + nil, // 29: proxy_pool.controlplane.v1.RoutingRule.HeadersEntry + nil, // 30: proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry + nil, // 31: proxy_pool.controlplane.v1.OwnedProxy.TagsEntry + (*durationpb.Duration)(nil), // 32: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 34: google.protobuf.Empty } var file_controlplane_v1_controlplane_proto_depIdxs = []int32{ - 27, // 0: proxy_pool.controlplane.v1.RegisterWorkerRequest.labels:type_name -> proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry - 31, // 1: proxy_pool.controlplane.v1.RegisterWorkerResponse.heartbeat_interval:type_name -> google.protobuf.Duration - 31, // 2: proxy_pool.controlplane.v1.RegisterWorkerResponse.max_stale_age:type_name -> google.protobuf.Duration - 9, // 3: proxy_pool.controlplane.v1.SnapshotEnvelope.full:type_name -> proxy_pool.controlplane.v1.WorkerSnapshot - 11, // 4: proxy_pool.controlplane.v1.SnapshotEnvelope.delta:type_name -> proxy_pool.controlplane.v1.SnapshotDelta - 32, // 5: proxy_pool.controlplane.v1.WorkerSnapshot.generated_at:type_name -> google.protobuf.Timestamp - 32, // 6: proxy_pool.controlplane.v1.WorkerSnapshot.valid_until:type_name -> google.protobuf.Timestamp - 12, // 7: proxy_pool.controlplane.v1.WorkerSnapshot.routing:type_name -> proxy_pool.controlplane.v1.RoutingRule - 14, // 8: proxy_pool.controlplane.v1.WorkerSnapshot.proxies:type_name -> proxy_pool.controlplane.v1.OwnedProxy - 10, // 9: proxy_pool.controlplane.v1.WorkerSnapshot.credentials:type_name -> proxy_pool.controlplane.v1.SnapshotCredential - 32, // 10: proxy_pool.controlplane.v1.SnapshotDelta.generated_at:type_name -> google.protobuf.Timestamp - 12, // 11: proxy_pool.controlplane.v1.SnapshotDelta.upserted_routing:type_name -> proxy_pool.controlplane.v1.RoutingRule - 14, // 12: proxy_pool.controlplane.v1.SnapshotDelta.upserted_proxies:type_name -> proxy_pool.controlplane.v1.OwnedProxy - 28, // 13: proxy_pool.controlplane.v1.RoutingRule.headers:type_name -> proxy_pool.controlplane.v1.RoutingRule.HeadersEntry - 13, // 14: proxy_pool.controlplane.v1.RoutingRule.strategy:type_name -> proxy_pool.controlplane.v1.RoutingStrategy + 28, // 0: proxy_pool.controlplane.v1.RegisterWorkerRequest.labels:type_name -> proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry + 32, // 1: proxy_pool.controlplane.v1.RegisterWorkerResponse.heartbeat_interval:type_name -> google.protobuf.Duration + 32, // 2: proxy_pool.controlplane.v1.RegisterWorkerResponse.max_stale_age:type_name -> google.protobuf.Duration + 10, // 3: proxy_pool.controlplane.v1.SnapshotEnvelope.full:type_name -> proxy_pool.controlplane.v1.WorkerSnapshot + 12, // 4: proxy_pool.controlplane.v1.SnapshotEnvelope.delta:type_name -> proxy_pool.controlplane.v1.SnapshotDelta + 33, // 5: proxy_pool.controlplane.v1.WorkerSnapshot.generated_at:type_name -> google.protobuf.Timestamp + 33, // 6: proxy_pool.controlplane.v1.WorkerSnapshot.valid_until:type_name -> google.protobuf.Timestamp + 13, // 7: proxy_pool.controlplane.v1.WorkerSnapshot.routing:type_name -> proxy_pool.controlplane.v1.RoutingRule + 15, // 8: proxy_pool.controlplane.v1.WorkerSnapshot.proxies:type_name -> proxy_pool.controlplane.v1.OwnedProxy + 11, // 9: proxy_pool.controlplane.v1.WorkerSnapshot.credentials:type_name -> proxy_pool.controlplane.v1.SnapshotCredential + 33, // 10: proxy_pool.controlplane.v1.SnapshotDelta.generated_at:type_name -> google.protobuf.Timestamp + 13, // 11: proxy_pool.controlplane.v1.SnapshotDelta.upserted_routing:type_name -> proxy_pool.controlplane.v1.RoutingRule + 15, // 12: proxy_pool.controlplane.v1.SnapshotDelta.upserted_proxies:type_name -> proxy_pool.controlplane.v1.OwnedProxy + 29, // 13: proxy_pool.controlplane.v1.RoutingRule.headers:type_name -> proxy_pool.controlplane.v1.RoutingRule.HeadersEntry + 14, // 14: proxy_pool.controlplane.v1.RoutingRule.strategy:type_name -> proxy_pool.controlplane.v1.RoutingStrategy 1, // 15: proxy_pool.controlplane.v1.RoutingRule.on_unavailable:type_name -> proxy_pool.controlplane.v1.UnavailableAction - 31, // 16: proxy_pool.controlplane.v1.RoutingRule.wait_timeout:type_name -> google.protobuf.Duration - 0, // 17: proxy_pool.controlplane.v1.RoutingStrategy.type:type_name -> proxy_pool.controlplane.v1.StrategyType - 29, // 18: proxy_pool.controlplane.v1.RoutingStrategy.weights:type_name -> proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry - 2, // 19: proxy_pool.controlplane.v1.OwnedProxy.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol - 32, // 20: proxy_pool.controlplane.v1.OwnedProxy.expires_at:type_name -> google.protobuf.Timestamp - 30, // 21: proxy_pool.controlplane.v1.OwnedProxy.tags:type_name -> proxy_pool.controlplane.v1.OwnedProxy.TagsEntry - 32, // 22: proxy_pool.controlplane.v1.OwnedProxy.usable_until:type_name -> google.protobuf.Timestamp - 17, // 23: proxy_pool.controlplane.v1.OutcomeBatch.outcomes:type_name -> proxy_pool.controlplane.v1.ProxyOutcome - 3, // 24: proxy_pool.controlplane.v1.ProxyOutcome.stage:type_name -> proxy_pool.controlplane.v1.OutcomeStage - 31, // 25: proxy_pool.controlplane.v1.ProxyOutcome.latency:type_name -> google.protobuf.Duration - 32, // 26: proxy_pool.controlplane.v1.ProxyOutcome.observed_at:type_name -> google.protobuf.Timestamp - 20, // 27: proxy_pool.controlplane.v1.ReportRuntimeRequest.counters:type_name -> proxy_pool.controlplane.v1.ProxyRuntime - 32, // 28: proxy_pool.controlplane.v1.ReportRuntimeRequest.observed_at:type_name -> google.protobuf.Timestamp - 4, // 29: proxy_pool.controlplane.v1.StreamCheckTasksRequest.supported_levels:type_name -> proxy_pool.controlplane.v1.CheckLevel - 2, // 30: proxy_pool.controlplane.v1.CheckTask.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol - 4, // 31: proxy_pool.controlplane.v1.CheckTask.level:type_name -> proxy_pool.controlplane.v1.CheckLevel - 31, // 32: proxy_pool.controlplane.v1.CheckTask.timeout:type_name -> google.protobuf.Duration - 32, // 33: proxy_pool.controlplane.v1.CheckTask.deadline:type_name -> google.protobuf.Timestamp - 25, // 34: proxy_pool.controlplane.v1.ObservationBatch.observations:type_name -> proxy_pool.controlplane.v1.HealthObservation - 4, // 35: proxy_pool.controlplane.v1.HealthObservation.level:type_name -> proxy_pool.controlplane.v1.CheckLevel - 31, // 36: proxy_pool.controlplane.v1.HealthObservation.latency:type_name -> google.protobuf.Duration - 32, // 37: proxy_pool.controlplane.v1.HealthObservation.observed_at:type_name -> google.protobuf.Timestamp - 5, // 38: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:input_type -> proxy_pool.controlplane.v1.RegisterWorkerRequest - 7, // 39: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:input_type -> proxy_pool.controlplane.v1.WatchSnapshotsRequest - 15, // 40: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:input_type -> proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest - 16, // 41: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:input_type -> proxy_pool.controlplane.v1.OutcomeBatch - 19, // 42: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:input_type -> proxy_pool.controlplane.v1.ReportRuntimeRequest - 22, // 43: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:input_type -> proxy_pool.controlplane.v1.StreamCheckTasksRequest - 24, // 44: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:input_type -> proxy_pool.controlplane.v1.ObservationBatch - 6, // 45: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:output_type -> proxy_pool.controlplane.v1.RegisterWorkerResponse - 8, // 46: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:output_type -> proxy_pool.controlplane.v1.SnapshotEnvelope - 33, // 47: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:output_type -> google.protobuf.Empty - 18, // 48: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:output_type -> proxy_pool.controlplane.v1.ReportOutcomesResponse - 21, // 49: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:output_type -> proxy_pool.controlplane.v1.ReportRuntimeResponse - 23, // 50: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:output_type -> proxy_pool.controlplane.v1.CheckTask - 26, // 51: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:output_type -> proxy_pool.controlplane.v1.ReportObservationsResponse - 45, // [45:52] is the sub-list for method output_type - 38, // [38:45] is the sub-list for method input_type - 38, // [38:38] is the sub-list for extension type_name - 38, // [38:38] is the sub-list for extension extendee - 0, // [0:38] is the sub-list for field type_name + 32, // 16: proxy_pool.controlplane.v1.RoutingRule.wait_timeout:type_name -> google.protobuf.Duration + 2, // 17: proxy_pool.controlplane.v1.RoutingRule.action:type_name -> proxy_pool.controlplane.v1.RoutingAction + 0, // 18: proxy_pool.controlplane.v1.RoutingStrategy.type:type_name -> proxy_pool.controlplane.v1.StrategyType + 30, // 19: proxy_pool.controlplane.v1.RoutingStrategy.weights:type_name -> proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry + 3, // 20: proxy_pool.controlplane.v1.OwnedProxy.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol + 33, // 21: proxy_pool.controlplane.v1.OwnedProxy.expires_at:type_name -> google.protobuf.Timestamp + 31, // 22: proxy_pool.controlplane.v1.OwnedProxy.tags:type_name -> proxy_pool.controlplane.v1.OwnedProxy.TagsEntry + 33, // 23: proxy_pool.controlplane.v1.OwnedProxy.usable_until:type_name -> google.protobuf.Timestamp + 18, // 24: proxy_pool.controlplane.v1.OutcomeBatch.outcomes:type_name -> proxy_pool.controlplane.v1.ProxyOutcome + 4, // 25: proxy_pool.controlplane.v1.ProxyOutcome.stage:type_name -> proxy_pool.controlplane.v1.OutcomeStage + 32, // 26: proxy_pool.controlplane.v1.ProxyOutcome.latency:type_name -> google.protobuf.Duration + 33, // 27: proxy_pool.controlplane.v1.ProxyOutcome.observed_at:type_name -> google.protobuf.Timestamp + 21, // 28: proxy_pool.controlplane.v1.ReportRuntimeRequest.counters:type_name -> proxy_pool.controlplane.v1.ProxyRuntime + 33, // 29: proxy_pool.controlplane.v1.ReportRuntimeRequest.observed_at:type_name -> google.protobuf.Timestamp + 5, // 30: proxy_pool.controlplane.v1.StreamCheckTasksRequest.supported_levels:type_name -> proxy_pool.controlplane.v1.CheckLevel + 3, // 31: proxy_pool.controlplane.v1.CheckTask.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol + 5, // 32: proxy_pool.controlplane.v1.CheckTask.level:type_name -> proxy_pool.controlplane.v1.CheckLevel + 32, // 33: proxy_pool.controlplane.v1.CheckTask.timeout:type_name -> google.protobuf.Duration + 33, // 34: proxy_pool.controlplane.v1.CheckTask.deadline:type_name -> google.protobuf.Timestamp + 26, // 35: proxy_pool.controlplane.v1.ObservationBatch.observations:type_name -> proxy_pool.controlplane.v1.HealthObservation + 5, // 36: proxy_pool.controlplane.v1.HealthObservation.level:type_name -> proxy_pool.controlplane.v1.CheckLevel + 32, // 37: proxy_pool.controlplane.v1.HealthObservation.latency:type_name -> google.protobuf.Duration + 33, // 38: proxy_pool.controlplane.v1.HealthObservation.observed_at:type_name -> google.protobuf.Timestamp + 6, // 39: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:input_type -> proxy_pool.controlplane.v1.RegisterWorkerRequest + 8, // 40: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:input_type -> proxy_pool.controlplane.v1.WatchSnapshotsRequest + 16, // 41: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:input_type -> proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest + 17, // 42: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:input_type -> proxy_pool.controlplane.v1.OutcomeBatch + 20, // 43: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:input_type -> proxy_pool.controlplane.v1.ReportRuntimeRequest + 23, // 44: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:input_type -> proxy_pool.controlplane.v1.StreamCheckTasksRequest + 25, // 45: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:input_type -> proxy_pool.controlplane.v1.ObservationBatch + 7, // 46: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:output_type -> proxy_pool.controlplane.v1.RegisterWorkerResponse + 9, // 47: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:output_type -> proxy_pool.controlplane.v1.SnapshotEnvelope + 34, // 48: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:output_type -> google.protobuf.Empty + 19, // 49: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:output_type -> proxy_pool.controlplane.v1.ReportOutcomesResponse + 22, // 50: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:output_type -> proxy_pool.controlplane.v1.ReportRuntimeResponse + 24, // 51: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:output_type -> proxy_pool.controlplane.v1.CheckTask + 27, // 52: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:output_type -> proxy_pool.controlplane.v1.ReportObservationsResponse + 46, // [46:53] is the sub-list for method output_type + 39, // [39:46] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name } func init() { file_controlplane_v1_controlplane_proto_init() } @@ -2549,7 +2617,7 @@ func file_controlplane_v1_controlplane_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_controlplane_proto_rawDesc), len(file_controlplane_v1_controlplane_proto_rawDesc)), - NumEnums: 5, + NumEnums: 6, NumMessages: 26, NumExtensions: 0, NumServices: 2, diff --git a/internal/config/config.go b/internal/config/config.go index d0449fd..1c11e05 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -214,6 +214,7 @@ type Routing struct { Name string `yaml:"name"` Enabled bool `yaml:"enabled"` Purpose string `yaml:"purpose"` + Action string `yaml:"action"` Match RoutingMatch `yaml:"match"` Upstreams []string `yaml:"upstreams"` Strategy Strategy `yaml:"strategy"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9fa241f..776a5aa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -488,6 +488,22 @@ func TestValidateGatewayStickySession(t *testing.T) { } } +func TestValidateStaticGatewayRoutingAction(t *testing.T) { + cfg := mustLoadValidConfig(t) + cfg.Routing[0] = Routing{ + Name: "direct-api", Enabled: true, Purpose: "gateway", Action: "direct", + Match: RoutingMatch{HostRegex: "^api\\.example\\.test$"}, + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(static direct routing) = %v", err) + } + + cfg.Routing[0].Purpose = "extract" + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "only supported on gateway") { + t.Fatalf("Validate(extract direct routing) error = %v", err) + } +} + func TestValidateGatewayTransport(t *testing.T) { t.Parallel() diff --git a/internal/config/validate.go b/internal/config/validate.go index 17d6587..d3ec013 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -423,6 +423,28 @@ func validateRouting(index int, route Routing, upstreams map[string]Upstream, se return fmt.Errorf("validate %s pathRegex: %w", scope, err) } } + action := routingAction(route.Action) + if err := validateEnum(scope+" action", action, "proxy", "direct", "reject"); err != nil { + return err + } + if action != "proxy" { + if route.Purpose != "gateway" { + return fmt.Errorf("validate %s action: only supported on gateway", scope) + } + if len(route.Upstreams) != 0 { + return fmt.Errorf("validate %s action: direct and reject routes must not reference upstreams", scope) + } + if routingStrategyConfigured(route.Strategy) { + return fmt.Errorf("validate %s action: direct and reject routes must not configure strategy", scope) + } + if route.OnUnavailable.Action != "" || route.OnUnavailable.WaitTimeout != 0 { + return fmt.Errorf("validate %s action: direct and reject routes must not configure onUnavailable", scope) + } + if len(route.Check.Targets) != 0 { + return fmt.Errorf("validate %s action: direct and reject routes must not configure check targets", scope) + } + return nil + } for _, upstream := range route.Upstreams { if _, ok := upstreams[upstream]; !ok { return fmt.Errorf("validate %s: upstream %q does not exist", scope, upstream) @@ -446,6 +468,20 @@ func validateRouting(index int, route Routing, upstreams map[string]Upstream, se return nil } +func routingAction(value string) string { + if value == "" { + return "proxy" + } + return value +} + +func routingStrategyConfigured(strategy Strategy) bool { + return strategy.Type != "" || + strategy.SwitchAfterEmptyFetch != 0 || + strategy.EndBehavior != "" || + len(strategy.Weights) != 0 +} + func validateStrategy(scope string, upstreams []string, strategy Strategy) error { if err := validateEnum(scope+" strategy.type", strategy.Type, "sequential", "random", "roundRobin", "weighted", "leastConnections"); err != nil { diff --git a/internal/controller/worker/routing_snapshot_source.go b/internal/controller/worker/routing_snapshot_source.go index 28279ad..895b663 100644 --- a/internal/controller/worker/routing_snapshot_source.go +++ b/internal/controller/worker/routing_snapshot_source.go @@ -98,29 +98,42 @@ func buildGatewayRouting(configuration *config.Config, state *adminstate.Snapsho if !hasState && routingStates != nil { return nil, fmt.Errorf("routing %q is missing from admin state", configured.Name) } + routeAction, err := wireRoutingAction(configured.Action) + if err != nil { + return nil, fmt.Errorf("routing %q action: %w", configured.Name, err) + } + enabled := configured.Enabled && (routingStates == nil || state.Enabled) + if routeAction != controlplanev1.RoutingAction_ROUTING_ACTION_PROXY { + rules = append(rules, &controlplanev1.RoutingRule{ + Name: configured.Name, Enabled: enabled, HostRegex: configured.Match.HostRegex, + Methods: append([]string(nil), configured.Match.Methods...), PathRegex: configured.Match.PathRegex, + Headers: cloneRoutingHeaders(configured.Match.Headers), Action: routeAction, + }) + continue + } upstreams := enabledRoutingUpstreams(configured.Upstreams, upstreamEnabled) current := configuredCurrentUpstream(configured, state, hasState) strategy, err := wireRoutingStrategy(configured.Strategy, current, upstreams) if err != nil { return nil, fmt.Errorf("routing %q strategy: %w", configured.Name, err) } - enabled := configured.Enabled && (routingStates == nil || state.Enabled) && len(upstreams) > 0 + enabled = enabled && len(upstreams) > 0 if strategy.GetType() == controlplanev1.StrategyType_STRATEGY_TYPE_SEQUENTIAL && !containsUpstream(upstreams, current) { enabled = false } - action, err := wireUnavailableAction(configured.OnUnavailable.Action) + unavailableAction, err := wireUnavailableAction(configured.OnUnavailable.Action) if err != nil { return nil, fmt.Errorf("routing %q unavailable action: %w", configured.Name, err) } var waitTimeout *durationpb.Duration - if action == controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_WAIT { + if unavailableAction == controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_WAIT { waitTimeout = durationpb.New(configured.OnUnavailable.WaitTimeout.Value()) } rules = append(rules, &controlplanev1.RoutingRule{ Name: configured.Name, Enabled: enabled, HostRegex: configured.Match.HostRegex, Methods: append([]string(nil), configured.Match.Methods...), PathRegex: configured.Match.PathRegex, Headers: cloneRoutingHeaders(configured.Match.Headers), Upstreams: upstreams, Strategy: strategy, - OnUnavailable: action, WaitTimeout: waitTimeout, + OnUnavailable: unavailableAction, WaitTimeout: waitTimeout, Action: controlplanev1.RoutingAction_ROUTING_ACTION_PROXY, }) } return rules, nil @@ -232,6 +245,19 @@ func wireUnavailableAction(action string) (controlplanev1.UnavailableAction, err } } +func wireRoutingAction(action string) (controlplanev1.RoutingAction, error) { + switch action { + case "", "proxy": + return controlplanev1.RoutingAction_ROUTING_ACTION_PROXY, nil + case "direct": + return controlplanev1.RoutingAction_ROUTING_ACTION_DIRECT, nil + case "reject": + return controlplanev1.RoutingAction_ROUTING_ACTION_REJECT, nil + default: + return controlplanev1.RoutingAction_ROUTING_ACTION_UNSPECIFIED, fmt.Errorf("unsupported action %q", action) + } +} + func cloneRoutingHeaders(source map[string]string) map[string]string { if source == nil { return nil diff --git a/internal/controller/worker/routing_snapshot_source_test.go b/internal/controller/worker/routing_snapshot_source_test.go index 048eb9d..4896bf0 100644 --- a/internal/controller/worker/routing_snapshot_source_test.go +++ b/internal/controller/worker/routing_snapshot_source_test.go @@ -114,6 +114,27 @@ func TestGatewayRoutingSourceDisablesSequentialRuleWhenCurrentUpstreamIsUnavaila } } +func TestGatewayRoutingSourceBuildsStaticDirectRuleWithoutUpstreams(t *testing.T) { + configuration := &config.Config{Routing: []config.Routing{{ + Name: "direct-api", Enabled: true, Purpose: "gateway", Action: "direct", + Match: config.RoutingMatch{HostRegex: "^api\\.example\\.test$"}, + }}} + source, err := NewGatewayRoutingSource(staticGatewayRoutingConfiguration{configuration: configuration, revision: 1}) + if err != nil { + t.Fatalf("NewGatewayRoutingSource(): %v", err) + } + rules, err := source.Read(context.Background()) + if err != nil { + t.Fatalf("Read(): %v", err) + } + if len(rules) != 1 || !rules[0].GetEnabled() || + rules[0].GetAction() != controlplanev1.RoutingAction_ROUTING_ACTION_DIRECT || + len(rules[0].GetUpstreams()) != 0 || rules[0].GetStrategy() != nil || + rules[0].GetOnUnavailable() != controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_UNSPECIFIED { + t.Fatalf("rules = %+v", rules) + } +} + type staticGatewayRoutingConfiguration struct { configuration *config.Config revision uint64 diff --git a/internal/gateway/controlplane/watcher.go b/internal/gateway/controlplane/watcher.go index a921d7f..bfe2b91 100644 --- a/internal/gateway/controlplane/watcher.go +++ b/internal/gateway/controlplane/watcher.go @@ -187,6 +187,26 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er if !rule.GetEnabled() { continue } + routeAction, err := wireRoutingAction(rule.GetAction()) + if err != nil { + return nil, err + } + if routeAction != routingDomain.ActionProxy { + if len(rule.GetUpstreams()) != 0 || rule.GetStrategy() != nil || + rule.GetOnUnavailable() != controlplanev1.UnavailableAction_UNAVAILABLE_ACTION_UNSPECIFIED || + rule.GetWaitTimeout() != nil { + return nil, ErrInvalidSnapshotWatcher + } + result = append(result, routingDomain.Rule{ + Name: rule.GetName(), + Match: routingDomain.Match{ + HostRegex: rule.GetHostRegex(), Methods: append([]string(nil), rule.GetMethods()...), + PathRegex: rule.GetPathRegex(), Headers: cloneRoutingHeaders(rule.GetHeaders()), + }, + Action: routeAction, + }) + continue + } upstreams, err := wireRoutingUpstreams(rule.GetUpstreams()) if err != nil { return nil, err @@ -195,11 +215,11 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er if err != nil { return nil, err } - action, err := wireUnavailableAction(rule.GetOnUnavailable()) + unavailableAction, err := wireUnavailableAction(rule.GetOnUnavailable()) if err != nil { return nil, err } - waitTimeout, err := wireWaitTimeout(action, rule.GetWaitTimeout()) + waitTimeout, err := wireWaitTimeout(unavailableAction, rule.GetWaitTimeout()) if err != nil { return nil, err } @@ -209,8 +229,8 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er HostRegex: rule.GetHostRegex(), Methods: append([]string(nil), rule.GetMethods()...), PathRegex: rule.GetPathRegex(), Headers: cloneRoutingHeaders(rule.GetHeaders()), }, - Upstreams: upstreams, Action: routingDomain.ActionProxy, Strategy: strategy, - OnUnavailable: action, WaitTimeout: waitTimeout, + Upstreams: upstreams, Action: routeAction, Strategy: strategy, + OnUnavailable: unavailableAction, WaitTimeout: waitTimeout, }) } return result, nil @@ -286,6 +306,19 @@ func wireUnavailableAction(action controlplanev1.UnavailableAction) (routingDoma } } +func wireRoutingAction(action controlplanev1.RoutingAction) (routingDomain.Action, error) { + switch action { + case controlplanev1.RoutingAction_ROUTING_ACTION_UNSPECIFIED, controlplanev1.RoutingAction_ROUTING_ACTION_PROXY: + return routingDomain.ActionProxy, nil + case controlplanev1.RoutingAction_ROUTING_ACTION_DIRECT: + return routingDomain.ActionDirect, nil + case controlplanev1.RoutingAction_ROUTING_ACTION_REJECT: + return routingDomain.ActionReject, nil + default: + return "", fmt.Errorf("%w: unsupported routing action", ErrInvalidSnapshotWatcher) + } +} + func wireWaitTimeout(action routingDomain.OnUnavailableAction, source *durationpb.Duration) (time.Duration, error) { if source == nil { if action == routingDomain.OnUnavailableWait { diff --git a/internal/gateway/controlplane/watcher_test.go b/internal/gateway/controlplane/watcher_test.go index a2d178c..0cd9377 100644 --- a/internal/gateway/controlplane/watcher_test.go +++ b/internal/gateway/controlplane/watcher_test.go @@ -141,6 +141,30 @@ func TestSnapshotWatcherRejectsWaitRoutingWithoutTimeout(t *testing.T) { } } +func TestWireRoutingAcceptsStaticDirectAction(t *testing.T) { + rules, err := wireRouting([]*controlplanev1.RoutingRule{{ + Name: "direct-api", Enabled: true, HostRegex: "^api\\.example\\.test$", + Action: controlplanev1.RoutingAction_ROUTING_ACTION_DIRECT, + }}) + if err != nil { + t.Fatalf("wireRouting(): %v", err) + } + if len(rules) != 1 || rules[0].Action != routing.ActionDirect || len(rules[0].Upstreams) != 0 || + rules[0].Strategy.Type != "" || rules[0].OnUnavailable != "" { + t.Fatalf("rules = %+v", rules) + } +} + +func TestWireRoutingRejectsStaticDirectActionWithProxySettings(t *testing.T) { + _, err := wireRouting([]*controlplanev1.RoutingRule{{ + Name: "direct-api", Enabled: true, HostRegex: "^api\\.example\\.test$", Upstreams: []string{"provider-a"}, + Action: controlplanev1.RoutingAction_ROUTING_ACTION_DIRECT, + }}) + if err == nil { + t.Fatal("wireRouting() error = nil") + } +} + type snapshotClientStub struct { stream SnapshotStream watch *controlplanev1.WatchSnapshotsRequest diff --git a/internal/gateway/dispatch/dispatcher.go b/internal/gateway/dispatch/dispatcher.go index 96f9803..00ec1cc 100644 --- a/internal/gateway/dispatch/dispatcher.go +++ b/internal/gateway/dispatch/dispatcher.go @@ -19,6 +19,7 @@ var ( type Request struct { RoutingName string + Action routing.Action Strategy routing.Strategy OnUnavailable routing.OnUnavailableAction WaitTimeout time.Duration diff --git a/internal/gateway/server/handler.go b/internal/gateway/server/handler.go index a6aef82..d341849 100644 --- a/internal/gateway/server/handler.go +++ b/internal/gateway/server/handler.go @@ -219,6 +219,10 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ writeGatewayError(writer, err) return } + if route.Action == routing.ActionDirect { + handler.connectDirect(writer, request, target) + return + } binding, err := handler.prepareStickySession(request, &route) if err != nil { writeGatewayError(writer, err) @@ -245,6 +249,10 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ writeGatewayError(writer, err) return } + if route.Action == routing.ActionDirect { + handler.forwardHTTPDirect(writer, request, target) + return + } binding, err := handler.prepareStickySession(request, &route) if err != nil { writeGatewayError(writer, err) @@ -272,12 +280,7 @@ func (handler *Handler) connect( if err != nil { lastErr = err if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect { - direct, directErr := handler.directTransport() - if directErr != nil { - lastErr = directErr - break - } - upstream, directErr := direct.OpenDirectTunnel(request.Context(), target.DialAddress()) + upstream, directErr := handler.openDirectTunnel(request.Context(), target.DialAddress()) if directErr != nil { lastErr = directErr break @@ -326,6 +329,15 @@ func (handler *Handler) connect( writeGatewayError(writer, fmt.Errorf("establish gateway CONNECT: %w", lastErr)) } +func (handler *Handler) connectDirect(writer http.ResponseWriter, request *http.Request, target policy.Authority) { + upstream, err := handler.openDirectTunnel(request.Context(), target.DialAddress()) + if err != nil { + writeGatewayError(writer, fmt.Errorf("establish direct CONNECT: %w", err)) + return + } + handler.serveTunnel(writer, request, nil, upstream, "", "") +} + func (handler *Handler) writeConnectError(writer http.ResponseWriter, responseError *transportDomain.ProxyResponseError) { header := responseError.Header.Clone() removeHopByHop(header) @@ -517,12 +529,7 @@ func (handler *Handler) forwardHTTP( if err != nil { lastErr = err if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect { - direct, directErr := handler.directTransport() - if directErr != nil { - lastErr = directErr - break - } - response, directErr := direct.RoundTripDirect(request.Context(), attemptRequest) + response, directErr := handler.roundTripDirect(request.Context(), attemptRequest) if directErr != nil { lastErr = directErr break @@ -580,6 +587,22 @@ func (handler *Handler) forwardHTTP( writeGatewayError(writer, fmt.Errorf("forward gateway request: %w", lastErr)) } +func (handler *Handler) forwardHTTPDirect(writer http.ResponseWriter, request *http.Request, target policy.Authority) { + attemptRequest, err := requestForAttempt(request, 0) + if err != nil { + writeGatewayError(writer, fmt.Errorf("prepare direct request: %w", err)) + return + } + pinHTTPDestination(attemptRequest, target) + removeHopByHop(attemptRequest.Header) + response, err := handler.roundTripDirect(request.Context(), attemptRequest) + if err != nil { + writeGatewayError(writer, fmt.Errorf("forward direct request: %w", err)) + return + } + handler.writeResponse(writer, response) +} + func (handler *Handler) recordOutcome( proxyID, routingName string, stage outcomeDomain.Stage, @@ -688,6 +711,22 @@ func (handler *Handler) directTransport() (DirectTransport, error) { return direct, nil } +func (handler *Handler) openDirectTunnel(ctx context.Context, target string) (net.Conn, error) { + direct, err := handler.directTransport() + if err != nil { + return nil, err + } + return direct.OpenDirectTunnel(ctx, target) +} + +func (handler *Handler) roundTripDirect(ctx context.Context, request *http.Request) (*http.Response, error) { + direct, err := handler.directTransport() + if err != nil { + return nil, err + } + return direct.RoundTripDirect(ctx, request) +} + func pinHTTPDestination(request *http.Request, target policy.Authority) { if request == nil || request.URL == nil || (!target.ResolvedIP.IsValid() && !target.LiteralIP.IsValid()) { return diff --git a/internal/gateway/server/handler_test.go b/internal/gateway/server/handler_test.go index 22201a3..e28638b 100644 --- a/internal/gateway/server/handler_test.go +++ b/internal/gateway/server/handler_test.go @@ -12,6 +12,7 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "testing" "time" @@ -117,7 +118,7 @@ func TestHandlerReportsLocalRequestAndTunnelLifecycles(t *testing.T) { } } -func TestHandlerRejectsGatewayRoutingOutsideCredentialPolicy(t *testing.T) { +func TestHandlerRejectsStaticDirectRouteOutsideCredentialPolicy(t *testing.T) { t.Parallel() authentication, err := httpsecurity.New(httpsecurity.Config{ @@ -137,7 +138,7 @@ func TestHandlerRejectsGatewayRoutingOutsideCredentialPolicy(t *testing.T) { return policy.Authority{Host: "example.test", Port: 80}, nil }}, Router: RouteFunc(func(*http.Request) (dispatch.Request, error) { - return dispatch.Request{RoutingName: "catalog", Upstreams: []string{"provider-a"}}, nil + return dispatch.Request{RoutingName: "catalog", Action: routing.ActionDirect}, nil }), Dispatcher: DispatcherFunc(func(dispatch.Request) (*dispatch.Lease, error) { t.Fatal("dispatcher must not run for a route outside the credential policy") @@ -159,6 +160,37 @@ func TestHandlerRejectsGatewayRoutingOutsideCredentialPolicy(t *testing.T) { } } +func TestHandlerEvaluatesTargetPolicyBeforeStaticDirectRoute(t *testing.T) { + t.Parallel() + + handler, err := New(Config{}, Dependencies{ + Targets: fakeTargets{evaluateURL: func(context.Context, string) (policy.Authority, error) { + return policy.Authority{}, policy.ErrTargetDenied + }}, + Router: RouteFunc(func(*http.Request) (dispatch.Request, error) { + t.Fatal("router must not run before target policy") + return dispatch.Request{}, nil + }), + Dispatcher: DispatcherFunc(func(dispatch.Request) (*dispatch.Lease, error) { + t.Fatal("dispatcher must not run for a denied target") + return nil, nil + }), + Transport: &fakeTransport{directRoundTrip: func(context.Context, *http.Request) (*http.Response, error) { + t.Fatal("direct transport must not run for a denied target") + return nil, nil + }}, + }) + if err != nil { + t.Fatalf("New(): %v", err) + } + + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://blocked.example/resource", nil)) + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.Code) + } +} + func TestHandlerReleasesCredentialConcurrencyAfterRequest(t *testing.T) { t.Parallel() @@ -584,6 +616,91 @@ func TestHandlerUsesDirectFallbackAfterTargetPolicy(t *testing.T) { } } +func TestHandlerUsesStaticDirectRouteForHTTP(t *testing.T) { + t.Parallel() + + var dispatcherCalls atomic.Int64 + transport := &fakeTransport{directRoundTrip: func(_ context.Context, request *http.Request) (*http.Response, error) { + if request.URL.Host != "198.51.100.10:80" || request.Host != "example.test" { + t.Fatalf("direct request = URL %q Host %q", request.URL.Host, request.Host) + } + return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: http.NoBody}, nil + }} + handler, err := New(Config{StickySession: StickySessionConfig{ + Header: "X-Proxy-Session", TTL: time.Minute, MaxEntries: 10, + }}, Dependencies{ + Targets: fakeTargets{evaluateURL: func(context.Context, string) (policy.Authority, error) { + return policy.Authority{Host: "example.test", Port: 80, ResolvedIP: netip.MustParseAddr("198.51.100.10")}, nil + }}, + Router: RouteFunc(func(*http.Request) (dispatch.Request, error) { + return dispatch.Request{RoutingName: "direct-api", Action: routing.ActionDirect}, nil + }), + Dispatcher: DispatcherFunc(func(dispatch.Request) (*dispatch.Lease, error) { + dispatcherCalls.Add(1) + return nil, dispatch.ErrNoCandidate + }), + Transport: transport, + }) + if err != nil { + t.Fatalf("New(): %v", err) + } + recorder := &outcomeRecorder{} + handler.outcomes = recorder + + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://example.test/resource", nil)) + + if response.Code != http.StatusNoContent || dispatcherCalls.Load() != 0 || len(recorder.Events()) != 0 { + t.Fatalf("response = %d, dispatcher calls = %d, outcomes = %+v", response.Code, dispatcherCalls.Load(), recorder.Events()) + } +} + +func TestHandlerUsesStaticDirectRouteForCONNECT(t *testing.T) { + t.Parallel() + + var dispatcherCalls atomic.Int64 + var directCalls atomic.Int64 + transport := &fakeTransport{ + directTunnel: func(_ context.Context, target string) (net.Conn, error) { + if target != "198.51.100.10:443" { + t.Fatalf("direct target = %q", target) + } + directCalls.Add(1) + upstream, peer := net.Pipe() + _ = peer.Close() + return upstream, nil + }, + relay: func(context.Context, net.Conn, net.Conn) error { return nil }, + } + handler, err := New(Config{}, Dependencies{ + Targets: fakeTargets{evaluateConnect: func(context.Context, string) (policy.Authority, error) { + return policy.Authority{Host: "example.test", Port: 443, ResolvedIP: netip.MustParseAddr("198.51.100.10")}, nil + }}, + Router: RouteFunc(func(*http.Request) (dispatch.Request, error) { + return dispatch.Request{RoutingName: "direct-connect", Action: routing.ActionDirect}, nil + }), + Dispatcher: DispatcherFunc(func(dispatch.Request) (*dispatch.Lease, error) { + dispatcherCalls.Add(1) + return nil, dispatch.ErrNoCandidate + }), + Transport: transport, + }) + if err != nil { + t.Fatalf("New(): %v", err) + } + recorder := &outcomeRecorder{} + handler.outcomes = recorder + gateway := httptest.NewServer(handler) + defer gateway.Close() + + response := sendConnect(t, gateway.URL, "example.test:443") + defer response.Body.Close() + if response.StatusCode != http.StatusOK || directCalls.Load() != 1 || dispatcherCalls.Load() != 0 || len(recorder.Events()) != 0 { + t.Fatalf("response = %d, direct calls = %d, dispatcher calls = %d, outcomes = %+v", + response.StatusCode, directCalls.Load(), dispatcherCalls.Load(), recorder.Events()) + } +} + func TestHandlerWaitsOnlyForWaitRoutingAction(t *testing.T) { dispatcher := &waitRecordingDispatcher{} handler := &Handler{dispatcher: dispatcher} diff --git a/internal/gateway/server/routing.go b/internal/gateway/server/routing.go index 1ddd729..6bf292f 100644 --- a/internal/gateway/server/routing.go +++ b/internal/gateway/server/routing.go @@ -98,13 +98,13 @@ func routeWithMatcher(request *http.Request, match routeMatcher) (dispatch.Reque switch matched.Action { case routing.ActionProxy: return dispatch.Request{ - RoutingName: matched.Name, Upstreams: append([]string(nil), matched.Upstreams...), + RoutingName: matched.Name, Action: matched.Action, Upstreams: append([]string(nil), matched.Upstreams...), Strategy: matched.Strategy, OnUnavailable: matched.OnUnavailable, WaitTimeout: matched.WaitTimeout, }, nil case routing.ActionReject: return dispatch.Request{}, fmt.Errorf("%w: %s", ErrRouteRejected, matched.Name) case routing.ActionDirect: - return dispatch.Request{}, fmt.Errorf("%w: %s", ErrDirectRouteUnsupported, matched.Name) + return dispatch.Request{RoutingName: matched.Name, Action: matched.Action}, nil default: return dispatch.Request{}, fmt.Errorf("%w: %s has action %q", ErrRouteRejected, matched.Name, matched.Action) } diff --git a/internal/gateway/server/routing_test.go b/internal/gateway/server/routing_test.go index 693ffd7..80d7671 100644 --- a/internal/gateway/server/routing_test.go +++ b/internal/gateway/server/routing_test.go @@ -36,6 +36,25 @@ func TestRulesRouterReturnsMatchedUpstreams(t *testing.T) { } } +func TestRulesRouterReturnsStaticDirectRoute(t *testing.T) { + t.Parallel() + + rules, err := routing.Compile([]routing.Rule{{ + Name: "direct-api", Match: routing.Match{HostRegex: "^api\\.example\\.test$"}, + Action: routing.ActionDirect, + }}) + if err != nil { + t.Fatalf("routing.Compile() error = %v", err) + } + result, err := NewRulesRouter(rules).Route(httptest.NewRequest(http.MethodGet, "http://api.example.test/v1/items", nil)) + if err != nil { + t.Fatalf("Route() error = %v", err) + } + if result.RoutingName != "direct-api" || result.Action != routing.ActionDirect || len(result.Upstreams) != 0 { + t.Fatalf("Route() = %+v", result) + } +} + func TestRulesRouterRejectsExplicitRejectAndMissingRoute(t *testing.T) { t.Parallel()