feat: support static gateway direct routes

This commit is contained in:
youfak 2026-08-07 16:16:00 +08:00
parent d9d3a3d6c0
commit 3bcc7298a2
15 changed files with 544 additions and 126 deletions

View File

@ -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;

View File

@ -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 的全局健康状态。

View File

@ -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,

View File

@ -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"`

View File

@ -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()

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -19,6 +19,7 @@ var (
type Request struct {
RoutingName string
Action routing.Action
Strategy routing.Strategy
OnUnavailable routing.OnUnavailableAction
WaitTimeout time.Duration

View File

@ -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

View File

@ -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}

View File

@ -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)
}

View File

@ -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()