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; RoutingStrategy strategy = 8;
UnavailableAction on_unavailable = 9; UnavailableAction on_unavailable = 9;
google.protobuf.Duration wait_timeout = 10; google.protobuf.Duration wait_timeout = 10;
RoutingAction action = 11;
} }
message RoutingStrategy { message RoutingStrategy {
@ -123,6 +124,13 @@ enum UnavailableAction {
UNAVAILABLE_ACTION_DIRECT = 3; UNAVAILABLE_ACTION_DIRECT = 3;
} }
enum RoutingAction {
ROUTING_ACTION_UNSPECIFIED = 0;
ROUTING_ACTION_PROXY = 1;
ROUTING_ACTION_DIRECT = 2;
ROUTING_ACTION_REJECT = 3;
}
message OwnedProxy { message OwnedProxy {
string id = 1; string id = 1;
string upstream = 2; string upstream = 2;

View File

@ -444,6 +444,7 @@ routing:
- name: api-post - name: api-post
enabled: true enabled: true
purpose: gateway purpose: gateway
action: proxy
match: match:
hostRegex: '^api\\.example\\.com$' hostRegex: '^api\\.example\\.com$'
methods: [POST] methods: [POST]
@ -463,6 +464,10 @@ routing:
- Routing 列表有序,首条匹配后停止。 - Routing 列表有序,首条匹配后停止。
- `purpose``gateway``extract` - `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`、 - `strategy.type` 支持 `sequential`、`random`、`roundRobin`、`weighted`、
`leastConnections` `leastConnections`
- `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。 - `weighted` 使用 `weights` 映射,键必须引用本 Routing 的 Upstream。
@ -470,6 +475,10 @@ routing:
`switchAfterEmptyFetch``endBehavior` 省略时默认为 `stop`,也可显式设置 `switchAfterEmptyFetch``endBehavior` 省略时默认为 `stop`,也可显式设置
`loop``stayLast` `loop``stayLast`
- `onUnavailable.action``reject`、`wait` 或 `direct`;默认建议 `reject` - `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且一个 - `check.targets` 是 Routing 级 HTTP/HTTPS 探测目标。每个 Routing 最多 16 个 URL且一个
启用 Upstream 被其引用的 TARGET Profile 总数最多 64 个。每个 Profile 的身份是 启用 Upstream 被其引用的 TARGET Profile 总数最多 64 个。每个 Profile 的身份是
`(routing.name, target URL)`;失败只影响该 Profile不改变 Proxy 的全局健康状态。 `(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} 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 type ProxyProtocol int32
const ( const (
@ -170,11 +222,11 @@ func (x ProxyProtocol) String() string {
} }
func (ProxyProtocol) Descriptor() protoreflect.EnumDescriptor { 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 { 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 { func (x ProxyProtocol) Number() protoreflect.EnumNumber {
@ -183,7 +235,7 @@ func (x ProxyProtocol) Number() protoreflect.EnumNumber {
// Deprecated: Use ProxyProtocol.Descriptor instead. // Deprecated: Use ProxyProtocol.Descriptor instead.
func (ProxyProtocol) EnumDescriptor() ([]byte, []int) { 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 type OutcomeStage int32
@ -225,11 +277,11 @@ func (x OutcomeStage) String() string {
} }
func (OutcomeStage) Descriptor() protoreflect.EnumDescriptor { 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 { 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 { func (x OutcomeStage) Number() protoreflect.EnumNumber {
@ -238,7 +290,7 @@ func (x OutcomeStage) Number() protoreflect.EnumNumber {
// Deprecated: Use OutcomeStage.Descriptor instead. // Deprecated: Use OutcomeStage.Descriptor instead.
func (OutcomeStage) EnumDescriptor() ([]byte, []int) { 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 type CheckLevel int32
@ -277,11 +329,11 @@ func (x CheckLevel) String() string {
} }
func (CheckLevel) Descriptor() protoreflect.EnumDescriptor { 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 { 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 { func (x CheckLevel) Number() protoreflect.EnumNumber {
@ -290,7 +342,7 @@ func (x CheckLevel) Number() protoreflect.EnumNumber {
// Deprecated: Use CheckLevel.Descriptor instead. // Deprecated: Use CheckLevel.Descriptor instead.
func (CheckLevel) EnumDescriptor() ([]byte, []int) { 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 { type RegisterWorkerRequest struct {
@ -885,6 +937,7 @@ type RoutingRule struct {
Strategy *RoutingStrategy `protobuf:"bytes,8,opt,name=strategy,proto3" json:"strategy,omitempty"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -989,6 +1042,13 @@ func (x *RoutingRule) GetWaitTimeout() *durationpb.Duration {
return nil return nil
} }
func (x *RoutingRule) GetAction() RoutingAction {
if x != nil {
return x.Action
}
return RoutingAction_ROUTING_ACTION_UNSPECIFIED
}
type RoutingStrategy struct { type RoutingStrategy struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Type StrategyType `protobuf:"varint,1,opt,name=type,proto3,enum=proxy_pool.controlplane.v1.StrategyType" json:"type,omitempty"` 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" + "\x10upserted_routing\x18\x06 \x03(\v2'.proxy_pool.controlplane.v1.RoutingRuleR\x0fupsertedRouting\x122\n" +
"\x15removed_routing_names\x18\a \x03(\tR\x13removedRoutingNames\x12Q\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" + "\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" + "\vRoutingRule\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
"\aenabled\x18\x02 \x01(\bR\aenabled\x12\x1d\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" + "\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" + "\x0eon_unavailable\x18\t \x01(\x0e2-.proxy_pool.controlplane.v1.UnavailableActionR\ronUnavailable\x12<\n" +
"\fwait_timeout\x18\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" + "\fHeadersEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x8a\x02\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" + "\x1eUNAVAILABLE_ACTION_UNSPECIFIED\x10\x00\x12\x1d\n" +
"\x19UNAVAILABLE_ACTION_REJECT\x10\x01\x12\x1b\n" + "\x19UNAVAILABLE_ACTION_REJECT\x10\x01\x12\x1b\n" +
"\x17UNAVAILABLE_ACTION_WAIT\x10\x02\x12\x1d\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" + "\rProxyProtocol\x12\x1e\n" +
"\x1aPROXY_PROTOCOL_UNSPECIFIED\x10\x00\x12\x17\n" + "\x1aPROXY_PROTOCOL_UNSPECIFIED\x10\x00\x12\x17\n" +
"\x13PROXY_PROTOCOL_HTTP\x10\x01\x12\x18\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 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_msgTypes = make([]protoimpl.MessageInfo, 26)
var file_controlplane_v1_controlplane_proto_goTypes = []any{ var file_controlplane_v1_controlplane_proto_goTypes = []any{
(StrategyType)(0), // 0: proxy_pool.controlplane.v1.StrategyType (StrategyType)(0), // 0: proxy_pool.controlplane.v1.StrategyType
(UnavailableAction)(0), // 1: proxy_pool.controlplane.v1.UnavailableAction (UnavailableAction)(0), // 1: proxy_pool.controlplane.v1.UnavailableAction
(ProxyProtocol)(0), // 2: proxy_pool.controlplane.v1.ProxyProtocol (RoutingAction)(0), // 2: proxy_pool.controlplane.v1.RoutingAction
(OutcomeStage)(0), // 3: proxy_pool.controlplane.v1.OutcomeStage (ProxyProtocol)(0), // 3: proxy_pool.controlplane.v1.ProxyProtocol
(CheckLevel)(0), // 4: proxy_pool.controlplane.v1.CheckLevel (OutcomeStage)(0), // 4: proxy_pool.controlplane.v1.OutcomeStage
(*RegisterWorkerRequest)(nil), // 5: proxy_pool.controlplane.v1.RegisterWorkerRequest (CheckLevel)(0), // 5: proxy_pool.controlplane.v1.CheckLevel
(*RegisterWorkerResponse)(nil), // 6: proxy_pool.controlplane.v1.RegisterWorkerResponse (*RegisterWorkerRequest)(nil), // 6: proxy_pool.controlplane.v1.RegisterWorkerRequest
(*WatchSnapshotsRequest)(nil), // 7: proxy_pool.controlplane.v1.WatchSnapshotsRequest (*RegisterWorkerResponse)(nil), // 7: proxy_pool.controlplane.v1.RegisterWorkerResponse
(*SnapshotEnvelope)(nil), // 8: proxy_pool.controlplane.v1.SnapshotEnvelope (*WatchSnapshotsRequest)(nil), // 8: proxy_pool.controlplane.v1.WatchSnapshotsRequest
(*WorkerSnapshot)(nil), // 9: proxy_pool.controlplane.v1.WorkerSnapshot (*SnapshotEnvelope)(nil), // 9: proxy_pool.controlplane.v1.SnapshotEnvelope
(*SnapshotCredential)(nil), // 10: proxy_pool.controlplane.v1.SnapshotCredential (*WorkerSnapshot)(nil), // 10: proxy_pool.controlplane.v1.WorkerSnapshot
(*SnapshotDelta)(nil), // 11: proxy_pool.controlplane.v1.SnapshotDelta (*SnapshotCredential)(nil), // 11: proxy_pool.controlplane.v1.SnapshotCredential
(*RoutingRule)(nil), // 12: proxy_pool.controlplane.v1.RoutingRule (*SnapshotDelta)(nil), // 12: proxy_pool.controlplane.v1.SnapshotDelta
(*RoutingStrategy)(nil), // 13: proxy_pool.controlplane.v1.RoutingStrategy (*RoutingRule)(nil), // 13: proxy_pool.controlplane.v1.RoutingRule
(*OwnedProxy)(nil), // 14: proxy_pool.controlplane.v1.OwnedProxy (*RoutingStrategy)(nil), // 14: proxy_pool.controlplane.v1.RoutingStrategy
(*AcknowledgeSnapshotRequest)(nil), // 15: proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest (*OwnedProxy)(nil), // 15: proxy_pool.controlplane.v1.OwnedProxy
(*OutcomeBatch)(nil), // 16: proxy_pool.controlplane.v1.OutcomeBatch (*AcknowledgeSnapshotRequest)(nil), // 16: proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest
(*ProxyOutcome)(nil), // 17: proxy_pool.controlplane.v1.ProxyOutcome (*OutcomeBatch)(nil), // 17: proxy_pool.controlplane.v1.OutcomeBatch
(*ReportOutcomesResponse)(nil), // 18: proxy_pool.controlplane.v1.ReportOutcomesResponse (*ProxyOutcome)(nil), // 18: proxy_pool.controlplane.v1.ProxyOutcome
(*ReportRuntimeRequest)(nil), // 19: proxy_pool.controlplane.v1.ReportRuntimeRequest (*ReportOutcomesResponse)(nil), // 19: proxy_pool.controlplane.v1.ReportOutcomesResponse
(*ProxyRuntime)(nil), // 20: proxy_pool.controlplane.v1.ProxyRuntime (*ReportRuntimeRequest)(nil), // 20: proxy_pool.controlplane.v1.ReportRuntimeRequest
(*ReportRuntimeResponse)(nil), // 21: proxy_pool.controlplane.v1.ReportRuntimeResponse (*ProxyRuntime)(nil), // 21: proxy_pool.controlplane.v1.ProxyRuntime
(*StreamCheckTasksRequest)(nil), // 22: proxy_pool.controlplane.v1.StreamCheckTasksRequest (*ReportRuntimeResponse)(nil), // 22: proxy_pool.controlplane.v1.ReportRuntimeResponse
(*CheckTask)(nil), // 23: proxy_pool.controlplane.v1.CheckTask (*StreamCheckTasksRequest)(nil), // 23: proxy_pool.controlplane.v1.StreamCheckTasksRequest
(*ObservationBatch)(nil), // 24: proxy_pool.controlplane.v1.ObservationBatch (*CheckTask)(nil), // 24: proxy_pool.controlplane.v1.CheckTask
(*HealthObservation)(nil), // 25: proxy_pool.controlplane.v1.HealthObservation (*ObservationBatch)(nil), // 25: proxy_pool.controlplane.v1.ObservationBatch
(*ReportObservationsResponse)(nil), // 26: proxy_pool.controlplane.v1.ReportObservationsResponse (*HealthObservation)(nil), // 26: proxy_pool.controlplane.v1.HealthObservation
nil, // 27: proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry (*ReportObservationsResponse)(nil), // 27: proxy_pool.controlplane.v1.ReportObservationsResponse
nil, // 28: proxy_pool.controlplane.v1.RoutingRule.HeadersEntry nil, // 28: proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry
nil, // 29: proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry nil, // 29: proxy_pool.controlplane.v1.RoutingRule.HeadersEntry
nil, // 30: proxy_pool.controlplane.v1.OwnedProxy.TagsEntry nil, // 30: proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry
(*durationpb.Duration)(nil), // 31: google.protobuf.Duration nil, // 31: proxy_pool.controlplane.v1.OwnedProxy.TagsEntry
(*timestamppb.Timestamp)(nil), // 32: google.protobuf.Timestamp (*durationpb.Duration)(nil), // 32: google.protobuf.Duration
(*emptypb.Empty)(nil), // 33: google.protobuf.Empty (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp
(*emptypb.Empty)(nil), // 34: google.protobuf.Empty
} }
var file_controlplane_v1_controlplane_proto_depIdxs = []int32{ var file_controlplane_v1_controlplane_proto_depIdxs = []int32{
27, // 0: proxy_pool.controlplane.v1.RegisterWorkerRequest.labels:type_name -> proxy_pool.controlplane.v1.RegisterWorkerRequest.LabelsEntry 28, // 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 32, // 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 32, // 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 10, // 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 12, // 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 33, // 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 33, // 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 13, // 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 15, // 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 11, // 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 33, // 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 13, // 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 15, // 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 29, // 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 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 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 32, // 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 2, // 17: proxy_pool.controlplane.v1.RoutingRule.action:type_name -> proxy_pool.controlplane.v1.RoutingAction
29, // 18: proxy_pool.controlplane.v1.RoutingStrategy.weights:type_name -> proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry 0, // 18: proxy_pool.controlplane.v1.RoutingStrategy.type:type_name -> proxy_pool.controlplane.v1.StrategyType
2, // 19: proxy_pool.controlplane.v1.OwnedProxy.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol 30, // 19: proxy_pool.controlplane.v1.RoutingStrategy.weights:type_name -> proxy_pool.controlplane.v1.RoutingStrategy.WeightsEntry
32, // 20: proxy_pool.controlplane.v1.OwnedProxy.expires_at:type_name -> google.protobuf.Timestamp 3, // 20: proxy_pool.controlplane.v1.OwnedProxy.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol
30, // 21: proxy_pool.controlplane.v1.OwnedProxy.tags:type_name -> proxy_pool.controlplane.v1.OwnedProxy.TagsEntry 33, // 21: proxy_pool.controlplane.v1.OwnedProxy.expires_at:type_name -> google.protobuf.Timestamp
32, // 22: proxy_pool.controlplane.v1.OwnedProxy.usable_until:type_name -> google.protobuf.Timestamp 31, // 22: proxy_pool.controlplane.v1.OwnedProxy.tags:type_name -> proxy_pool.controlplane.v1.OwnedProxy.TagsEntry
17, // 23: proxy_pool.controlplane.v1.OutcomeBatch.outcomes:type_name -> proxy_pool.controlplane.v1.ProxyOutcome 33, // 23: proxy_pool.controlplane.v1.OwnedProxy.usable_until:type_name -> google.protobuf.Timestamp
3, // 24: proxy_pool.controlplane.v1.ProxyOutcome.stage:type_name -> proxy_pool.controlplane.v1.OutcomeStage 18, // 24: proxy_pool.controlplane.v1.OutcomeBatch.outcomes:type_name -> proxy_pool.controlplane.v1.ProxyOutcome
31, // 25: proxy_pool.controlplane.v1.ProxyOutcome.latency:type_name -> google.protobuf.Duration 4, // 25: proxy_pool.controlplane.v1.ProxyOutcome.stage:type_name -> proxy_pool.controlplane.v1.OutcomeStage
32, // 26: proxy_pool.controlplane.v1.ProxyOutcome.observed_at:type_name -> google.protobuf.Timestamp 32, // 26: proxy_pool.controlplane.v1.ProxyOutcome.latency:type_name -> google.protobuf.Duration
20, // 27: proxy_pool.controlplane.v1.ReportRuntimeRequest.counters:type_name -> proxy_pool.controlplane.v1.ProxyRuntime 33, // 27: proxy_pool.controlplane.v1.ProxyOutcome.observed_at:type_name -> google.protobuf.Timestamp
32, // 28: proxy_pool.controlplane.v1.ReportRuntimeRequest.observed_at:type_name -> google.protobuf.Timestamp 21, // 28: proxy_pool.controlplane.v1.ReportRuntimeRequest.counters:type_name -> proxy_pool.controlplane.v1.ProxyRuntime
4, // 29: proxy_pool.controlplane.v1.StreamCheckTasksRequest.supported_levels:type_name -> proxy_pool.controlplane.v1.CheckLevel 33, // 29: proxy_pool.controlplane.v1.ReportRuntimeRequest.observed_at:type_name -> google.protobuf.Timestamp
2, // 30: proxy_pool.controlplane.v1.CheckTask.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol 5, // 30: proxy_pool.controlplane.v1.StreamCheckTasksRequest.supported_levels:type_name -> proxy_pool.controlplane.v1.CheckLevel
4, // 31: proxy_pool.controlplane.v1.CheckTask.level:type_name -> proxy_pool.controlplane.v1.CheckLevel 3, // 31: proxy_pool.controlplane.v1.CheckTask.protocol:type_name -> proxy_pool.controlplane.v1.ProxyProtocol
31, // 32: proxy_pool.controlplane.v1.CheckTask.timeout:type_name -> google.protobuf.Duration 5, // 32: proxy_pool.controlplane.v1.CheckTask.level:type_name -> proxy_pool.controlplane.v1.CheckLevel
32, // 33: proxy_pool.controlplane.v1.CheckTask.deadline:type_name -> google.protobuf.Timestamp 32, // 33: proxy_pool.controlplane.v1.CheckTask.timeout:type_name -> google.protobuf.Duration
25, // 34: proxy_pool.controlplane.v1.ObservationBatch.observations:type_name -> proxy_pool.controlplane.v1.HealthObservation 33, // 34: proxy_pool.controlplane.v1.CheckTask.deadline:type_name -> google.protobuf.Timestamp
4, // 35: proxy_pool.controlplane.v1.HealthObservation.level:type_name -> proxy_pool.controlplane.v1.CheckLevel 26, // 35: proxy_pool.controlplane.v1.ObservationBatch.observations:type_name -> proxy_pool.controlplane.v1.HealthObservation
31, // 36: proxy_pool.controlplane.v1.HealthObservation.latency:type_name -> google.protobuf.Duration 5, // 36: proxy_pool.controlplane.v1.HealthObservation.level:type_name -> proxy_pool.controlplane.v1.CheckLevel
32, // 37: proxy_pool.controlplane.v1.HealthObservation.observed_at:type_name -> google.protobuf.Timestamp 32, // 37: proxy_pool.controlplane.v1.HealthObservation.latency:type_name -> google.protobuf.Duration
5, // 38: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:input_type -> proxy_pool.controlplane.v1.RegisterWorkerRequest 33, // 38: proxy_pool.controlplane.v1.HealthObservation.observed_at:type_name -> google.protobuf.Timestamp
7, // 39: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:input_type -> proxy_pool.controlplane.v1.WatchSnapshotsRequest 6, // 39: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:input_type -> proxy_pool.controlplane.v1.RegisterWorkerRequest
15, // 40: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:input_type -> proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest 8, // 40: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:input_type -> proxy_pool.controlplane.v1.WatchSnapshotsRequest
16, // 41: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:input_type -> proxy_pool.controlplane.v1.OutcomeBatch 16, // 41: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:input_type -> proxy_pool.controlplane.v1.AcknowledgeSnapshotRequest
19, // 42: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:input_type -> proxy_pool.controlplane.v1.ReportRuntimeRequest 17, // 42: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:input_type -> proxy_pool.controlplane.v1.OutcomeBatch
22, // 43: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:input_type -> proxy_pool.controlplane.v1.StreamCheckTasksRequest 20, // 43: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:input_type -> proxy_pool.controlplane.v1.ReportRuntimeRequest
24, // 44: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:input_type -> proxy_pool.controlplane.v1.ObservationBatch 23, // 44: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:input_type -> proxy_pool.controlplane.v1.StreamCheckTasksRequest
6, // 45: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:output_type -> proxy_pool.controlplane.v1.RegisterWorkerResponse 25, // 45: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:input_type -> proxy_pool.controlplane.v1.ObservationBatch
8, // 46: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:output_type -> proxy_pool.controlplane.v1.SnapshotEnvelope 7, // 46: proxy_pool.controlplane.v1.WorkerControlPlane.RegisterWorker:output_type -> proxy_pool.controlplane.v1.RegisterWorkerResponse
33, // 47: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:output_type -> google.protobuf.Empty 9, // 47: proxy_pool.controlplane.v1.WorkerControlPlane.WatchSnapshots:output_type -> proxy_pool.controlplane.v1.SnapshotEnvelope
18, // 48: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:output_type -> proxy_pool.controlplane.v1.ReportOutcomesResponse 34, // 48: proxy_pool.controlplane.v1.WorkerControlPlane.AcknowledgeSnapshot:output_type -> google.protobuf.Empty
21, // 49: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:output_type -> proxy_pool.controlplane.v1.ReportRuntimeResponse 19, // 49: proxy_pool.controlplane.v1.WorkerControlPlane.ReportOutcomes:output_type -> proxy_pool.controlplane.v1.ReportOutcomesResponse
23, // 50: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:output_type -> proxy_pool.controlplane.v1.CheckTask 22, // 50: proxy_pool.controlplane.v1.WorkerControlPlane.ReportRuntime:output_type -> proxy_pool.controlplane.v1.ReportRuntimeResponse
26, // 51: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:output_type -> proxy_pool.controlplane.v1.ReportObservationsResponse 24, // 51: proxy_pool.controlplane.v1.CheckerControlPlane.StreamCheckTasks:output_type -> proxy_pool.controlplane.v1.CheckTask
45, // [45:52] is the sub-list for method output_type 27, // 52: proxy_pool.controlplane.v1.CheckerControlPlane.ReportObservations:output_type -> proxy_pool.controlplane.v1.ReportObservationsResponse
38, // [38:45] is the sub-list for method input_type 46, // [46:53] is the sub-list for method output_type
38, // [38:38] is the sub-list for extension type_name 39, // [39:46] is the sub-list for method input_type
38, // [38:38] is the sub-list for extension extendee 39, // [39:39] is the sub-list for extension type_name
0, // [0:38] is the sub-list for field 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() } func init() { file_controlplane_v1_controlplane_proto_init() }
@ -2549,7 +2617,7 @@ func file_controlplane_v1_controlplane_proto_init() {
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_controlplane_proto_rawDesc), len(file_controlplane_v1_controlplane_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_controlplane_v1_controlplane_proto_rawDesc), len(file_controlplane_v1_controlplane_proto_rawDesc)),
NumEnums: 5, NumEnums: 6,
NumMessages: 26, NumMessages: 26,
NumExtensions: 0, NumExtensions: 0,
NumServices: 2, NumServices: 2,

View File

@ -214,6 +214,7 @@ type Routing struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Purpose string `yaml:"purpose"` Purpose string `yaml:"purpose"`
Action string `yaml:"action"`
Match RoutingMatch `yaml:"match"` Match RoutingMatch `yaml:"match"`
Upstreams []string `yaml:"upstreams"` Upstreams []string `yaml:"upstreams"`
Strategy Strategy `yaml:"strategy"` 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) { func TestValidateGatewayTransport(t *testing.T) {
t.Parallel() 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) 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 { for _, upstream := range route.Upstreams {
if _, ok := upstreams[upstream]; !ok { if _, ok := upstreams[upstream]; !ok {
return fmt.Errorf("validate %s: upstream %q does not exist", scope, upstream) 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 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 { func validateStrategy(scope string, upstreams []string, strategy Strategy) error {
if err := validateEnum(scope+" strategy.type", strategy.Type, if err := validateEnum(scope+" strategy.type", strategy.Type,
"sequential", "random", "roundRobin", "weighted", "leastConnections"); err != nil { "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 { if !hasState && routingStates != nil {
return nil, fmt.Errorf("routing %q is missing from admin state", configured.Name) 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) upstreams := enabledRoutingUpstreams(configured.Upstreams, upstreamEnabled)
current := configuredCurrentUpstream(configured, state, hasState) current := configuredCurrentUpstream(configured, state, hasState)
strategy, err := wireRoutingStrategy(configured.Strategy, current, upstreams) strategy, err := wireRoutingStrategy(configured.Strategy, current, upstreams)
if err != nil { if err != nil {
return nil, fmt.Errorf("routing %q strategy: %w", configured.Name, err) 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) { if strategy.GetType() == controlplanev1.StrategyType_STRATEGY_TYPE_SEQUENTIAL && !containsUpstream(upstreams, current) {
enabled = false enabled = false
} }
action, err := wireUnavailableAction(configured.OnUnavailable.Action) unavailableAction, err := wireUnavailableAction(configured.OnUnavailable.Action)
if err != nil { if err != nil {
return nil, fmt.Errorf("routing %q unavailable action: %w", configured.Name, err) return nil, fmt.Errorf("routing %q unavailable action: %w", configured.Name, err)
} }
var waitTimeout *durationpb.Duration 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()) waitTimeout = durationpb.New(configured.OnUnavailable.WaitTimeout.Value())
} }
rules = append(rules, &controlplanev1.RoutingRule{ rules = append(rules, &controlplanev1.RoutingRule{
Name: configured.Name, Enabled: enabled, HostRegex: configured.Match.HostRegex, Name: configured.Name, Enabled: enabled, HostRegex: configured.Match.HostRegex,
Methods: append([]string(nil), configured.Match.Methods...), PathRegex: configured.Match.PathRegex, Methods: append([]string(nil), configured.Match.Methods...), PathRegex: configured.Match.PathRegex,
Headers: cloneRoutingHeaders(configured.Match.Headers), Upstreams: upstreams, Strategy: strategy, 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 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 { func cloneRoutingHeaders(source map[string]string) map[string]string {
if source == nil { if source == nil {
return 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 { type staticGatewayRoutingConfiguration struct {
configuration *config.Config configuration *config.Config
revision uint64 revision uint64

View File

@ -187,6 +187,26 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er
if !rule.GetEnabled() { if !rule.GetEnabled() {
continue 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()) upstreams, err := wireRoutingUpstreams(rule.GetUpstreams())
if err != nil { if err != nil {
return nil, err return nil, err
@ -195,11 +215,11 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
action, err := wireUnavailableAction(rule.GetOnUnavailable()) unavailableAction, err := wireUnavailableAction(rule.GetOnUnavailable())
if err != nil { if err != nil {
return nil, err return nil, err
} }
waitTimeout, err := wireWaitTimeout(action, rule.GetWaitTimeout()) waitTimeout, err := wireWaitTimeout(unavailableAction, rule.GetWaitTimeout())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -209,8 +229,8 @@ func wireRouting(source []*controlplanev1.RoutingRule) ([]routingDomain.Rule, er
HostRegex: rule.GetHostRegex(), Methods: append([]string(nil), rule.GetMethods()...), HostRegex: rule.GetHostRegex(), Methods: append([]string(nil), rule.GetMethods()...),
PathRegex: rule.GetPathRegex(), Headers: cloneRoutingHeaders(rule.GetHeaders()), PathRegex: rule.GetPathRegex(), Headers: cloneRoutingHeaders(rule.GetHeaders()),
}, },
Upstreams: upstreams, Action: routingDomain.ActionProxy, Strategy: strategy, Upstreams: upstreams, Action: routeAction, Strategy: strategy,
OnUnavailable: action, WaitTimeout: waitTimeout, OnUnavailable: unavailableAction, WaitTimeout: waitTimeout,
}) })
} }
return result, nil 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) { func wireWaitTimeout(action routingDomain.OnUnavailableAction, source *durationpb.Duration) (time.Duration, error) {
if source == nil { if source == nil {
if action == routingDomain.OnUnavailableWait { 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 { type snapshotClientStub struct {
stream SnapshotStream stream SnapshotStream
watch *controlplanev1.WatchSnapshotsRequest watch *controlplanev1.WatchSnapshotsRequest

View File

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

View File

@ -219,6 +219,10 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
writeGatewayError(writer, err) writeGatewayError(writer, err)
return return
} }
if route.Action == routing.ActionDirect {
handler.connectDirect(writer, request, target)
return
}
binding, err := handler.prepareStickySession(request, &route) binding, err := handler.prepareStickySession(request, &route)
if err != nil { if err != nil {
writeGatewayError(writer, err) writeGatewayError(writer, err)
@ -245,6 +249,10 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
writeGatewayError(writer, err) writeGatewayError(writer, err)
return return
} }
if route.Action == routing.ActionDirect {
handler.forwardHTTPDirect(writer, request, target)
return
}
binding, err := handler.prepareStickySession(request, &route) binding, err := handler.prepareStickySession(request, &route)
if err != nil { if err != nil {
writeGatewayError(writer, err) writeGatewayError(writer, err)
@ -272,12 +280,7 @@ func (handler *Handler) connect(
if err != nil { if err != nil {
lastErr = err lastErr = err
if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect { if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect {
direct, directErr := handler.directTransport() upstream, directErr := handler.openDirectTunnel(request.Context(), target.DialAddress())
if directErr != nil {
lastErr = directErr
break
}
upstream, directErr := direct.OpenDirectTunnel(request.Context(), target.DialAddress())
if directErr != nil { if directErr != nil {
lastErr = directErr lastErr = directErr
break break
@ -326,6 +329,15 @@ func (handler *Handler) connect(
writeGatewayError(writer, fmt.Errorf("establish gateway CONNECT: %w", lastErr)) 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) { func (handler *Handler) writeConnectError(writer http.ResponseWriter, responseError *transportDomain.ProxyResponseError) {
header := responseError.Header.Clone() header := responseError.Header.Clone()
removeHopByHop(header) removeHopByHop(header)
@ -517,12 +529,7 @@ func (handler *Handler) forwardHTTP(
if err != nil { if err != nil {
lastErr = err lastErr = err
if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect { if errors.Is(err, dispatch.ErrNoCandidate) && route.OnUnavailable == routing.OnUnavailableDirect {
direct, directErr := handler.directTransport() response, directErr := handler.roundTripDirect(request.Context(), attemptRequest)
if directErr != nil {
lastErr = directErr
break
}
response, directErr := direct.RoundTripDirect(request.Context(), attemptRequest)
if directErr != nil { if directErr != nil {
lastErr = directErr lastErr = directErr
break break
@ -580,6 +587,22 @@ func (handler *Handler) forwardHTTP(
writeGatewayError(writer, fmt.Errorf("forward gateway request: %w", lastErr)) 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( func (handler *Handler) recordOutcome(
proxyID, routingName string, proxyID, routingName string,
stage outcomeDomain.Stage, stage outcomeDomain.Stage,
@ -688,6 +711,22 @@ func (handler *Handler) directTransport() (DirectTransport, error) {
return direct, nil 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) { func pinHTTPDestination(request *http.Request, target policy.Authority) {
if request == nil || request.URL == nil || (!target.ResolvedIP.IsValid() && !target.LiteralIP.IsValid()) { if request == nil || request.URL == nil || (!target.ResolvedIP.IsValid() && !target.LiteralIP.IsValid()) {
return return

View File

@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@ -117,7 +118,7 @@ func TestHandlerReportsLocalRequestAndTunnelLifecycles(t *testing.T) {
} }
} }
func TestHandlerRejectsGatewayRoutingOutsideCredentialPolicy(t *testing.T) { func TestHandlerRejectsStaticDirectRouteOutsideCredentialPolicy(t *testing.T) {
t.Parallel() t.Parallel()
authentication, err := httpsecurity.New(httpsecurity.Config{ authentication, err := httpsecurity.New(httpsecurity.Config{
@ -137,7 +138,7 @@ func TestHandlerRejectsGatewayRoutingOutsideCredentialPolicy(t *testing.T) {
return policy.Authority{Host: "example.test", Port: 80}, nil return policy.Authority{Host: "example.test", Port: 80}, nil
}}, }},
Router: RouteFunc(func(*http.Request) (dispatch.Request, error) { 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) { Dispatcher: DispatcherFunc(func(dispatch.Request) (*dispatch.Lease, error) {
t.Fatal("dispatcher must not run for a route outside the credential policy") 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) { func TestHandlerReleasesCredentialConcurrencyAfterRequest(t *testing.T) {
t.Parallel() 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) { func TestHandlerWaitsOnlyForWaitRoutingAction(t *testing.T) {
dispatcher := &waitRecordingDispatcher{} dispatcher := &waitRecordingDispatcher{}
handler := &Handler{dispatcher: dispatcher} handler := &Handler{dispatcher: dispatcher}

View File

@ -98,13 +98,13 @@ func routeWithMatcher(request *http.Request, match routeMatcher) (dispatch.Reque
switch matched.Action { switch matched.Action {
case routing.ActionProxy: case routing.ActionProxy:
return dispatch.Request{ 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, Strategy: matched.Strategy, OnUnavailable: matched.OnUnavailable, WaitTimeout: matched.WaitTimeout,
}, nil }, nil
case routing.ActionReject: case routing.ActionReject:
return dispatch.Request{}, fmt.Errorf("%w: %s", ErrRouteRejected, matched.Name) return dispatch.Request{}, fmt.Errorf("%w: %s", ErrRouteRejected, matched.Name)
case routing.ActionDirect: case routing.ActionDirect:
return dispatch.Request{}, fmt.Errorf("%w: %s", ErrDirectRouteUnsupported, matched.Name) return dispatch.Request{RoutingName: matched.Name, Action: matched.Action}, nil
default: default:
return dispatch.Request{}, fmt.Errorf("%w: %s has action %q", ErrRouteRejected, matched.Name, matched.Action) 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) { func TestRulesRouterRejectsExplicitRejectAndMissingRoute(t *testing.T) {
t.Parallel() t.Parallel()