diff --git a/api/go/bridge/v1/interceptor.pb.go b/api/go/bridge/v1/interceptor.pb.go index 1e818c5..298aec7 100644 --- a/api/go/bridge/v1/interceptor.pb.go +++ b/api/go/bridge/v1/interceptor.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.12 // protoc (unknown) // source: bridge/v1/interceptor.proto @@ -21,6 +21,71 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type TunnelState int32 + +const ( + TunnelState_TUNNEL_STATE_UNSPECIFIED TunnelState = 0 + // No stream holds the tunnel yet, or the last one ended and the next is + // opening. + TunnelState_TUNNEL_STATE_CONNECTING TunnelState = 1 + // A stream holds the tunnel: the deployment's conns reach the app. + TunnelState_TUNNEL_STATE_LIVE TunnelState = 2 + // The relay refused the client token, or there is none yet. Waiting for + // SetTunnelToken. + TunnelState_TUNNEL_STATE_NEEDS_TOKEN TunnelState = 3 + // Another client holds the tunnel. Retrying until it lets go. + TunnelState_TUNNEL_STATE_TAKEN TunnelState = 4 + // The relay refused the tunnel for good; last_error says why. + TunnelState_TUNNEL_STATE_FAILED TunnelState = 5 +) + +// Enum value maps for TunnelState. +var ( + TunnelState_name = map[int32]string{ + 0: "TUNNEL_STATE_UNSPECIFIED", + 1: "TUNNEL_STATE_CONNECTING", + 2: "TUNNEL_STATE_LIVE", + 3: "TUNNEL_STATE_NEEDS_TOKEN", + 4: "TUNNEL_STATE_TAKEN", + 5: "TUNNEL_STATE_FAILED", + } + TunnelState_value = map[string]int32{ + "TUNNEL_STATE_UNSPECIFIED": 0, + "TUNNEL_STATE_CONNECTING": 1, + "TUNNEL_STATE_LIVE": 2, + "TUNNEL_STATE_NEEDS_TOKEN": 3, + "TUNNEL_STATE_TAKEN": 4, + "TUNNEL_STATE_FAILED": 5, + } +) + +func (x TunnelState) Enum() *TunnelState { + p := new(TunnelState) + *p = x + return p +} + +func (x TunnelState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TunnelState) Descriptor() protoreflect.EnumDescriptor { + return file_bridge_v1_interceptor_proto_enumTypes[0].Descriptor() +} + +func (TunnelState) Type() protoreflect.EnumType { + return &file_bridge_v1_interceptor_proto_enumTypes[0] +} + +func (x TunnelState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TunnelState.Descriptor instead. +func (TunnelState) EnumDescriptor() ([]byte, []int) { + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{0} +} + type ProbeHealth int32 const ( @@ -60,11 +125,11 @@ func (x ProbeHealth) String() string { } func (ProbeHealth) Descriptor() protoreflect.EnumDescriptor { - return file_bridge_v1_interceptor_proto_enumTypes[0].Descriptor() + return file_bridge_v1_interceptor_proto_enumTypes[1].Descriptor() } func (ProbeHealth) Type() protoreflect.EnumType { - return &file_bridge_v1_interceptor_proto_enumTypes[0] + return &file_bridge_v1_interceptor_proto_enumTypes[1] } func (x ProbeHealth) Number() protoreflect.EnumNumber { @@ -73,7 +138,7 @@ func (x ProbeHealth) Number() protoreflect.EnumNumber { // Deprecated: Use ProbeHealth.Descriptor instead. func (ProbeHealth) EnumDescriptor() ([]byte, []int) { - return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{0} + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{1} } type GetStatusRequest struct { @@ -125,7 +190,10 @@ type GetStatusResponse struct { Readiness *ProbeStatus `protobuf:"bytes,3,opt,name=readiness,proto3" json:"readiness,omitempty"` // Startup probe result against the local app. Unset if no startup // probe was configured on the source. - Startup *ProbeStatus `protobuf:"bytes,4,opt,name=startup,proto3" json:"startup,omitempty"` + Startup *ProbeStatus `protobuf:"bytes,4,opt,name=startup,proto3" json:"startup,omitempty"` + // The dev tunnel's stream to its relay. Unset unless + // --tunnel-protocol=dev-tunnel. + Tunnel *TunnelStatus `protobuf:"bytes,5,opt,name=tunnel,proto3" json:"tunnel,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -188,6 +256,219 @@ func (x *GetStatusResponse) GetStartup() *ProbeStatus { return nil } +func (x *GetStatusResponse) GetTunnel() *TunnelStatus { + if x != nil { + return x.Tunnel + } + return nil +} + +// TunnelStatus is the state of a dev tunnel's stream to api-dev-tunnel, and +// the conns it carries. +type TunnelStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + State TunnelState `protobuf:"varint,1,opt,name=state,proto3,enum=bridge.v1.TunnelState" json:"state,omitempty"` + // Why the last stream ended, or the relay refused one. Empty before any + // has. + LastError string `protobuf:"bytes,2,opt,name=last_error,proto3" json:"last_error,omitempty"` + // Unix seconds since a stream has held the tunnel. Zero unless LIVE. + LiveSinceUnixSeconds int64 `protobuf:"varint,3,opt,name=live_since_unix_seconds,proto3" json:"live_since_unix_seconds,omitempty"` + // Streams opened after the first. + Reconnects int64 `protobuf:"varint,4,opt,name=reconnects,proto3" json:"reconnects,omitempty"` + // Conns open now from the deployment to the app (ingress), and from the + // app out through the deployment (egress). + IngressConns int64 `protobuf:"varint,5,opt,name=ingress_conns,proto3" json:"ingress_conns,omitempty"` + EgressConns int64 `protobuf:"varint,6,opt,name=egress_conns,proto3" json:"egress_conns,omitempty"` + // Conns opened since the intercept started. + IngressConnsTotal int64 `protobuf:"varint,7,opt,name=ingress_conns_total,proto3" json:"ingress_conns_total,omitempty"` + EgressConnsTotal int64 `protobuf:"varint,8,opt,name=egress_conns_total,proto3" json:"egress_conns_total,omitempty"` + // Payload bytes sent to and received from the relay. + BytesSent int64 `protobuf:"varint,9,opt,name=bytes_sent,proto3" json:"bytes_sent,omitempty"` + BytesReceived int64 `protobuf:"varint,10,opt,name=bytes_received,proto3" json:"bytes_received,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelStatus) Reset() { + *x = TunnelStatus{} + mi := &file_bridge_v1_interceptor_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelStatus) ProtoMessage() {} + +func (x *TunnelStatus) ProtoReflect() protoreflect.Message { + mi := &file_bridge_v1_interceptor_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelStatus.ProtoReflect.Descriptor instead. +func (*TunnelStatus) Descriptor() ([]byte, []int) { + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{2} +} + +func (x *TunnelStatus) GetState() TunnelState { + if x != nil { + return x.State + } + return TunnelState_TUNNEL_STATE_UNSPECIFIED +} + +func (x *TunnelStatus) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +func (x *TunnelStatus) GetLiveSinceUnixSeconds() int64 { + if x != nil { + return x.LiveSinceUnixSeconds + } + return 0 +} + +func (x *TunnelStatus) GetReconnects() int64 { + if x != nil { + return x.Reconnects + } + return 0 +} + +func (x *TunnelStatus) GetIngressConns() int64 { + if x != nil { + return x.IngressConns + } + return 0 +} + +func (x *TunnelStatus) GetEgressConns() int64 { + if x != nil { + return x.EgressConns + } + return 0 +} + +func (x *TunnelStatus) GetIngressConnsTotal() int64 { + if x != nil { + return x.IngressConnsTotal + } + return 0 +} + +func (x *TunnelStatus) GetEgressConnsTotal() int64 { + if x != nil { + return x.EgressConnsTotal + } + return 0 +} + +func (x *TunnelStatus) GetBytesSent() int64 { + if x != nil { + return x.BytesSent + } + return 0 +} + +func (x *TunnelStatus) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +type SetTunnelTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetTunnelTokenRequest) Reset() { + *x = SetTunnelTokenRequest{} + mi := &file_bridge_v1_interceptor_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetTunnelTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetTunnelTokenRequest) ProtoMessage() {} + +func (x *SetTunnelTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_bridge_v1_interceptor_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetTunnelTokenRequest.ProtoReflect.Descriptor instead. +func (*SetTunnelTokenRequest) Descriptor() ([]byte, []int) { + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{3} +} + +func (x *SetTunnelTokenRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type SetTunnelTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetTunnelTokenResponse) Reset() { + *x = SetTunnelTokenResponse{} + mi := &file_bridge_v1_interceptor_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetTunnelTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetTunnelTokenResponse) ProtoMessage() {} + +func (x *SetTunnelTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_bridge_v1_interceptor_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetTunnelTokenResponse.ProtoReflect.Descriptor instead. +func (*SetTunnelTokenResponse) Descriptor() ([]byte, []int) { + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{4} +} + // ProbeStatus reflects the current health view from a single configured // probe — what the source deployment's probe would have reported if it had // been run against the developer's local app. @@ -212,7 +493,7 @@ type ProbeStatus struct { func (x *ProbeStatus) Reset() { *x = ProbeStatus{} - mi := &file_bridge_v1_interceptor_proto_msgTypes[2] + mi := &file_bridge_v1_interceptor_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -224,7 +505,7 @@ func (x *ProbeStatus) String() string { func (*ProbeStatus) ProtoMessage() {} func (x *ProbeStatus) ProtoReflect() protoreflect.Message { - mi := &file_bridge_v1_interceptor_proto_msgTypes[2] + mi := &file_bridge_v1_interceptor_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -237,7 +518,7 @@ func (x *ProbeStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use ProbeStatus.ProtoReflect.Descriptor instead. func (*ProbeStatus) Descriptor() ([]byte, []int) { - return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{2} + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{5} } func (x *ProbeStatus) GetHealth() ProbeHealth { @@ -290,7 +571,7 @@ type ProbeRequest struct { func (x *ProbeRequest) Reset() { *x = ProbeRequest{} - mi := &file_bridge_v1_interceptor_proto_msgTypes[3] + mi := &file_bridge_v1_interceptor_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -302,7 +583,7 @@ func (x *ProbeRequest) String() string { func (*ProbeRequest) ProtoMessage() {} func (x *ProbeRequest) ProtoReflect() protoreflect.Message { - mi := &file_bridge_v1_interceptor_proto_msgTypes[3] + mi := &file_bridge_v1_interceptor_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -315,7 +596,7 @@ func (x *ProbeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProbeRequest.ProtoReflect.Descriptor instead. func (*ProbeRequest) Descriptor() ([]byte, []int) { - return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{3} + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{6} } // ProbeResponse reports the outcome of a synchronous run of each @@ -332,7 +613,7 @@ type ProbeResponse struct { func (x *ProbeResponse) Reset() { *x = ProbeResponse{} - mi := &file_bridge_v1_interceptor_proto_msgTypes[4] + mi := &file_bridge_v1_interceptor_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -344,7 +625,7 @@ func (x *ProbeResponse) String() string { func (*ProbeResponse) ProtoMessage() {} func (x *ProbeResponse) ProtoReflect() protoreflect.Message { - mi := &file_bridge_v1_interceptor_proto_msgTypes[4] + mi := &file_bridge_v1_interceptor_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -357,7 +638,7 @@ func (x *ProbeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProbeResponse.ProtoReflect.Descriptor instead. func (*ProbeResponse) Descriptor() ([]byte, []int) { - return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{4} + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{7} } func (x *ProbeResponse) GetLiveness() *ProbeCheckResult { @@ -397,7 +678,7 @@ type ProbeCheckResult struct { func (x *ProbeCheckResult) Reset() { *x = ProbeCheckResult{} - mi := &file_bridge_v1_interceptor_proto_msgTypes[5] + mi := &file_bridge_v1_interceptor_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -409,7 +690,7 @@ func (x *ProbeCheckResult) String() string { func (*ProbeCheckResult) ProtoMessage() {} func (x *ProbeCheckResult) ProtoReflect() protoreflect.Message { - mi := &file_bridge_v1_interceptor_proto_msgTypes[5] + mi := &file_bridge_v1_interceptor_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -422,7 +703,7 @@ func (x *ProbeCheckResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ProbeCheckResult.ProtoReflect.Descriptor instead. func (*ProbeCheckResult) Descriptor() ([]byte, []int) { - return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{5} + return file_bridge_v1_interceptor_proto_rawDescGZIP(), []int{8} } func (x *ProbeCheckResult) GetPassed() bool { @@ -451,12 +732,34 @@ var File_bridge_v1_interceptor_proto protoreflect.FileDescriptor const file_bridge_v1_interceptor_proto_rawDesc = "" + "\n" + "\x1bbridge/v1/interceptor.proto\x12\tbridge.v1\"\x12\n" + - "\x10GetStatusRequest\"\xc5\x01\n" + + "\x10GetStatusRequest\"\xf6\x01\n" + "\x11GetStatusResponse\x12\x14\n" + "\x05ready\x18\x01 \x01(\bR\x05ready\x122\n" + "\bliveness\x18\x02 \x01(\v2\x16.bridge.v1.ProbeStatusR\bliveness\x124\n" + "\treadiness\x18\x03 \x01(\v2\x16.bridge.v1.ProbeStatusR\treadiness\x120\n" + - "\astartup\x18\x04 \x01(\v2\x16.bridge.v1.ProbeStatusR\astartup\"\x9f\x02\n" + + "\astartup\x18\x04 \x01(\v2\x16.bridge.v1.ProbeStatusR\astartup\x12/\n" + + "\x06tunnel\x18\x05 \x01(\v2\x17.bridge.v1.TunnelStatusR\x06tunnel\"\xaa\x03\n" + + "\fTunnelStatus\x12,\n" + + "\x05state\x18\x01 \x01(\x0e2\x16.bridge.v1.TunnelStateR\x05state\x12\x1e\n" + + "\n" + + "last_error\x18\x02 \x01(\tR\n" + + "last_error\x128\n" + + "\x17live_since_unix_seconds\x18\x03 \x01(\x03R\x17live_since_unix_seconds\x12\x1e\n" + + "\n" + + "reconnects\x18\x04 \x01(\x03R\n" + + "reconnects\x12$\n" + + "\ringress_conns\x18\x05 \x01(\x03R\ringress_conns\x12\"\n" + + "\fegress_conns\x18\x06 \x01(\x03R\fegress_conns\x120\n" + + "\x13ingress_conns_total\x18\a \x01(\x03R\x13ingress_conns_total\x12.\n" + + "\x12egress_conns_total\x18\b \x01(\x03R\x12egress_conns_total\x12\x1e\n" + + "\n" + + "bytes_sent\x18\t \x01(\x03R\n" + + "bytes_sent\x12&\n" + + "\x0ebytes_received\x18\n" + + " \x01(\x03R\x0ebytes_received\"-\n" + + "\x15SetTunnelTokenRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"\x18\n" + + "\x16SetTunnelTokenResponse\"\x9f\x02\n" + "\vProbeStatus\x12.\n" + "\x06health\x18\x01 \x01(\x0e2\x16.bridge.v1.ProbeHealthR\x06health\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x128\n" + @@ -472,15 +775,23 @@ const file_bridge_v1_interceptor_proto_rawDesc = "" + "\x10ProbeCheckResult\x12\x16\n" + "\x06passed\x18\x01 \x01(\bR\x06passed\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x128\n" + - "\x17checked_at_unix_seconds\x18\x03 \x01(\x03R\x17checked_at_unix_seconds*{\n" + + "\x17checked_at_unix_seconds\x18\x03 \x01(\x03R\x17checked_at_unix_seconds*\xae\x01\n" + + "\vTunnelState\x12\x1c\n" + + "\x18TUNNEL_STATE_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17TUNNEL_STATE_CONNECTING\x10\x01\x12\x15\n" + + "\x11TUNNEL_STATE_LIVE\x10\x02\x12\x1c\n" + + "\x18TUNNEL_STATE_NEEDS_TOKEN\x10\x03\x12\x16\n" + + "\x12TUNNEL_STATE_TAKEN\x10\x04\x12\x17\n" + + "\x13TUNNEL_STATE_FAILED\x10\x05*{\n" + "\vProbeHealth\x12\x1c\n" + "\x18PROBE_HEALTH_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14PROBE_HEALTH_PENDING\x10\x01\x12\x18\n" + "\x14PROBE_HEALTH_HEALTHY\x10\x02\x12\x1a\n" + - "\x16PROBE_HEALTH_UNHEALTHY\x10\x032\x98\x01\n" + + "\x16PROBE_HEALTH_UNHEALTHY\x10\x032\xef\x01\n" + "\x12InterceptorService\x12F\n" + "\tGetStatus\x12\x1b.bridge.v1.GetStatusRequest\x1a\x1c.bridge.v1.GetStatusResponse\x12:\n" + - "\x05Probe\x12\x17.bridge.v1.ProbeRequest\x1a\x18.bridge.v1.ProbeResponseB\x9a\x01\n" + + "\x05Probe\x12\x17.bridge.v1.ProbeRequest\x1a\x18.bridge.v1.ProbeResponse\x12U\n" + + "\x0eSetTunnelToken\x12 .bridge.v1.SetTunnelTokenRequest\x1a!.bridge.v1.SetTunnelTokenResponseB\x9a\x01\n" + "\rcom.bridge.v1B\x10InterceptorProtoP\x01Z2github.com/vercel/bridge/api/go/bridge/v1;bridgev1\xa2\x02\x03BXX\xaa\x02\tBridge.V1\xca\x02\tBridge\\V1\xe2\x02\x15Bridge\\V1\\GPBMetadata\xea\x02\n" + "Bridge::V1b\x06proto3" @@ -496,34 +807,42 @@ func file_bridge_v1_interceptor_proto_rawDescGZIP() []byte { return file_bridge_v1_interceptor_proto_rawDescData } -var file_bridge_v1_interceptor_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_bridge_v1_interceptor_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_bridge_v1_interceptor_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_bridge_v1_interceptor_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_bridge_v1_interceptor_proto_goTypes = []any{ - (ProbeHealth)(0), // 0: bridge.v1.ProbeHealth - (*GetStatusRequest)(nil), // 1: bridge.v1.GetStatusRequest - (*GetStatusResponse)(nil), // 2: bridge.v1.GetStatusResponse - (*ProbeStatus)(nil), // 3: bridge.v1.ProbeStatus - (*ProbeRequest)(nil), // 4: bridge.v1.ProbeRequest - (*ProbeResponse)(nil), // 5: bridge.v1.ProbeResponse - (*ProbeCheckResult)(nil), // 6: bridge.v1.ProbeCheckResult + (TunnelState)(0), // 0: bridge.v1.TunnelState + (ProbeHealth)(0), // 1: bridge.v1.ProbeHealth + (*GetStatusRequest)(nil), // 2: bridge.v1.GetStatusRequest + (*GetStatusResponse)(nil), // 3: bridge.v1.GetStatusResponse + (*TunnelStatus)(nil), // 4: bridge.v1.TunnelStatus + (*SetTunnelTokenRequest)(nil), // 5: bridge.v1.SetTunnelTokenRequest + (*SetTunnelTokenResponse)(nil), // 6: bridge.v1.SetTunnelTokenResponse + (*ProbeStatus)(nil), // 7: bridge.v1.ProbeStatus + (*ProbeRequest)(nil), // 8: bridge.v1.ProbeRequest + (*ProbeResponse)(nil), // 9: bridge.v1.ProbeResponse + (*ProbeCheckResult)(nil), // 10: bridge.v1.ProbeCheckResult } var file_bridge_v1_interceptor_proto_depIdxs = []int32{ - 3, // 0: bridge.v1.GetStatusResponse.liveness:type_name -> bridge.v1.ProbeStatus - 3, // 1: bridge.v1.GetStatusResponse.readiness:type_name -> bridge.v1.ProbeStatus - 3, // 2: bridge.v1.GetStatusResponse.startup:type_name -> bridge.v1.ProbeStatus - 0, // 3: bridge.v1.ProbeStatus.health:type_name -> bridge.v1.ProbeHealth - 6, // 4: bridge.v1.ProbeResponse.liveness:type_name -> bridge.v1.ProbeCheckResult - 6, // 5: bridge.v1.ProbeResponse.readiness:type_name -> bridge.v1.ProbeCheckResult - 6, // 6: bridge.v1.ProbeResponse.startup:type_name -> bridge.v1.ProbeCheckResult - 1, // 7: bridge.v1.InterceptorService.GetStatus:input_type -> bridge.v1.GetStatusRequest - 4, // 8: bridge.v1.InterceptorService.Probe:input_type -> bridge.v1.ProbeRequest - 2, // 9: bridge.v1.InterceptorService.GetStatus:output_type -> bridge.v1.GetStatusResponse - 5, // 10: bridge.v1.InterceptorService.Probe:output_type -> bridge.v1.ProbeResponse - 9, // [9:11] is the sub-list for method output_type - 7, // [7:9] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 7, // 0: bridge.v1.GetStatusResponse.liveness:type_name -> bridge.v1.ProbeStatus + 7, // 1: bridge.v1.GetStatusResponse.readiness:type_name -> bridge.v1.ProbeStatus + 7, // 2: bridge.v1.GetStatusResponse.startup:type_name -> bridge.v1.ProbeStatus + 4, // 3: bridge.v1.GetStatusResponse.tunnel:type_name -> bridge.v1.TunnelStatus + 0, // 4: bridge.v1.TunnelStatus.state:type_name -> bridge.v1.TunnelState + 1, // 5: bridge.v1.ProbeStatus.health:type_name -> bridge.v1.ProbeHealth + 10, // 6: bridge.v1.ProbeResponse.liveness:type_name -> bridge.v1.ProbeCheckResult + 10, // 7: bridge.v1.ProbeResponse.readiness:type_name -> bridge.v1.ProbeCheckResult + 10, // 8: bridge.v1.ProbeResponse.startup:type_name -> bridge.v1.ProbeCheckResult + 2, // 9: bridge.v1.InterceptorService.GetStatus:input_type -> bridge.v1.GetStatusRequest + 8, // 10: bridge.v1.InterceptorService.Probe:input_type -> bridge.v1.ProbeRequest + 5, // 11: bridge.v1.InterceptorService.SetTunnelToken:input_type -> bridge.v1.SetTunnelTokenRequest + 3, // 12: bridge.v1.InterceptorService.GetStatus:output_type -> bridge.v1.GetStatusResponse + 9, // 13: bridge.v1.InterceptorService.Probe:output_type -> bridge.v1.ProbeResponse + 6, // 14: bridge.v1.InterceptorService.SetTunnelToken:output_type -> bridge.v1.SetTunnelTokenResponse + 12, // [12:15] is the sub-list for method output_type + 9, // [9:12] is the sub-list for method input_type + 9, // [9:9] is the sub-list for extension type_name + 9, // [9:9] is the sub-list for extension extendee + 0, // [0:9] is the sub-list for field type_name } func init() { file_bridge_v1_interceptor_proto_init() } @@ -536,8 +855,8 @@ func file_bridge_v1_interceptor_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_bridge_v1_interceptor_proto_rawDesc), len(file_bridge_v1_interceptor_proto_rawDesc)), - NumEnums: 1, - NumMessages: 6, + NumEnums: 2, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, diff --git a/api/go/bridge/v1/interceptor_grpc.pb.go b/api/go/bridge/v1/interceptor_grpc.pb.go index 6e6869e..d949772 100644 --- a/api/go/bridge/v1/interceptor_grpc.pb.go +++ b/api/go/bridge/v1/interceptor_grpc.pb.go @@ -19,8 +19,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - InterceptorService_GetStatus_FullMethodName = "/bridge.v1.InterceptorService/GetStatus" - InterceptorService_Probe_FullMethodName = "/bridge.v1.InterceptorService/Probe" + InterceptorService_GetStatus_FullMethodName = "/bridge.v1.InterceptorService/GetStatus" + InterceptorService_Probe_FullMethodName = "/bridge.v1.InterceptorService/Probe" + InterceptorService_SetTunnelToken_FullMethodName = "/bridge.v1.InterceptorService/SetTunnelToken" ) // InterceptorServiceClient is the client API for InterceptorService service. @@ -43,6 +44,11 @@ type InterceptorServiceClient interface { // immediate check passed. Useful as a "is the app healthy right now?" // primitive that ignores any stale state from a previous dev session. Probe(ctx context.Context, in *ProbeRequest, opts ...grpc.CallOption) (*ProbeResponse, error) + // SetTunnelToken gives an intercept on a dev tunnel + // (--tunnel-protocol=dev-tunnel) its client token, from create-session. One + // started without a token, or whose token the relay refused + // (TUNNEL_STATE_NEEDS_TOKEN), connects with it. + SetTunnelToken(ctx context.Context, in *SetTunnelTokenRequest, opts ...grpc.CallOption) (*SetTunnelTokenResponse, error) } type interceptorServiceClient struct { @@ -73,6 +79,16 @@ func (c *interceptorServiceClient) Probe(ctx context.Context, in *ProbeRequest, return out, nil } +func (c *interceptorServiceClient) SetTunnelToken(ctx context.Context, in *SetTunnelTokenRequest, opts ...grpc.CallOption) (*SetTunnelTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetTunnelTokenResponse) + err := c.cc.Invoke(ctx, InterceptorService_SetTunnelToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // InterceptorServiceServer is the server API for InterceptorService service. // All implementations must embed UnimplementedInterceptorServiceServer // for forward compatibility. @@ -93,6 +109,11 @@ type InterceptorServiceServer interface { // immediate check passed. Useful as a "is the app healthy right now?" // primitive that ignores any stale state from a previous dev session. Probe(context.Context, *ProbeRequest) (*ProbeResponse, error) + // SetTunnelToken gives an intercept on a dev tunnel + // (--tunnel-protocol=dev-tunnel) its client token, from create-session. One + // started without a token, or whose token the relay refused + // (TUNNEL_STATE_NEEDS_TOKEN), connects with it. + SetTunnelToken(context.Context, *SetTunnelTokenRequest) (*SetTunnelTokenResponse, error) mustEmbedUnimplementedInterceptorServiceServer() } @@ -109,6 +130,9 @@ func (UnimplementedInterceptorServiceServer) GetStatus(context.Context, *GetStat func (UnimplementedInterceptorServiceServer) Probe(context.Context, *ProbeRequest) (*ProbeResponse, error) { return nil, status.Error(codes.Unimplemented, "method Probe not implemented") } +func (UnimplementedInterceptorServiceServer) SetTunnelToken(context.Context, *SetTunnelTokenRequest) (*SetTunnelTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetTunnelToken not implemented") +} func (UnimplementedInterceptorServiceServer) mustEmbedUnimplementedInterceptorServiceServer() {} func (UnimplementedInterceptorServiceServer) testEmbeddedByValue() {} @@ -166,6 +190,24 @@ func _InterceptorService_Probe_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _InterceptorService_SetTunnelToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetTunnelTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InterceptorServiceServer).SetTunnelToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: InterceptorService_SetTunnelToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InterceptorServiceServer).SetTunnelToken(ctx, req.(*SetTunnelTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + // InterceptorService_ServiceDesc is the grpc.ServiceDesc for InterceptorService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -181,6 +223,10 @@ var InterceptorService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Probe", Handler: _InterceptorService_Probe_Handler, }, + { + MethodName: "SetTunnelToken", + Handler: _InterceptorService_SetTunnelToken_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "bridge/v1/interceptor.proto", diff --git a/api/go/vercel/api/dev_tunnel/v1/tunnel.pb.go b/api/go/vercel/api/dev_tunnel/v1/tunnel.pb.go new file mode 100644 index 0000000..0da3bb4 --- /dev/null +++ b/api/go/vercel/api/dev_tunnel/v1/tunnel.pb.go @@ -0,0 +1,1165 @@ +// Vendored from vercel/api, services/api-dev-tunnel/proto/tunnel/v1/tunnel.proto. +// Edit it there and copy it here. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: vercel/api/dev_tunnel/v1/tunnel.proto + +package devtunnel + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Protocol int32 + +const ( + Protocol_PROTOCOL_UNSPECIFIED Protocol = 0 + Protocol_PROTOCOL_TCP Protocol = 1 +) + +// Enum value maps for Protocol. +var ( + Protocol_name = map[int32]string{ + 0: "PROTOCOL_UNSPECIFIED", + 1: "PROTOCOL_TCP", + } + Protocol_value = map[string]int32{ + "PROTOCOL_UNSPECIFIED": 0, + "PROTOCOL_TCP": 1, + } +) + +func (x Protocol) Enum() *Protocol { + p := new(Protocol) + *p = x + return p +} + +func (x Protocol) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Protocol) Descriptor() protoreflect.EnumDescriptor { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_enumTypes[0].Descriptor() +} + +func (Protocol) Type() protoreflect.EnumType { + return &file_vercel_api_dev_tunnel_v1_tunnel_proto_enumTypes[0] +} + +func (x Protocol) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Protocol.Descriptor instead. +func (Protocol) EnumDescriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{0} +} + +// CLI to service. +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ConnectRequest_Data + // *ConnectRequest_Close + // *ConnectRequest_Reset_ + // *ConnectRequest_Heartbeat + // *ConnectRequest_Window + // *ConnectRequest_Open + Frame isConnectRequest_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{0} +} + +func (x *ConnectRequest) GetFrame() isConnectRequest_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ConnectRequest) GetData() *Data { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ConnectRequest) GetClose() *Close { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Close); ok { + return x.Close + } + } + return nil +} + +func (x *ConnectRequest) GetReset_() *Reset { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Reset_); ok { + return x.Reset_ + } + } + return nil +} + +func (x *ConnectRequest) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *ConnectRequest) GetWindow() *Window { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Window); ok { + return x.Window + } + } + return nil +} + +func (x *ConnectRequest) GetOpen() *Open { + if x != nil { + if x, ok := x.Frame.(*ConnectRequest_Open); ok { + return x.Open + } + } + return nil +} + +type isConnectRequest_Frame interface { + isConnectRequest_Frame() +} + +type ConnectRequest_Data struct { + Data *Data `protobuf:"bytes,1,opt,name=data,proto3,oneof"` +} + +type ConnectRequest_Close struct { + Close *Close `protobuf:"bytes,2,opt,name=close,proto3,oneof"` +} + +type ConnectRequest_Reset_ struct { + Reset_ *Reset `protobuf:"bytes,3,opt,name=reset,proto3,oneof"` +} + +type ConnectRequest_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,4,opt,name=heartbeat,proto3,oneof"` +} + +type ConnectRequest_Window struct { + Window *Window `protobuf:"bytes,5,opt,name=window,proto3,oneof"` +} + +type ConnectRequest_Open struct { + Open *Open `protobuf:"bytes,6,opt,name=open,proto3,oneof"` +} + +func (*ConnectRequest_Data) isConnectRequest_Frame() {} + +func (*ConnectRequest_Close) isConnectRequest_Frame() {} + +func (*ConnectRequest_Reset_) isConnectRequest_Frame() {} + +func (*ConnectRequest_Heartbeat) isConnectRequest_Frame() {} + +func (*ConnectRequest_Window) isConnectRequest_Frame() {} + +func (*ConnectRequest_Open) isConnectRequest_Frame() {} + +// Service to CLI. +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *ConnectResponse_Open + // *ConnectResponse_Data + // *ConnectResponse_Close + // *ConnectResponse_Reset_ + // *ConnectResponse_Heartbeat + // *ConnectResponse_Window + // *ConnectResponse_Reconnect + Frame isConnectResponse_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{1} +} + +func (x *ConnectResponse) GetFrame() isConnectResponse_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *ConnectResponse) GetOpen() *Open { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Open); ok { + return x.Open + } + } + return nil +} + +func (x *ConnectResponse) GetData() *Data { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ConnectResponse) GetClose() *Close { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Close); ok { + return x.Close + } + } + return nil +} + +func (x *ConnectResponse) GetReset_() *Reset { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Reset_); ok { + return x.Reset_ + } + } + return nil +} + +func (x *ConnectResponse) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *ConnectResponse) GetWindow() *Window { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Window); ok { + return x.Window + } + } + return nil +} + +func (x *ConnectResponse) GetReconnect() *Reconnect { + if x != nil { + if x, ok := x.Frame.(*ConnectResponse_Reconnect); ok { + return x.Reconnect + } + } + return nil +} + +type isConnectResponse_Frame interface { + isConnectResponse_Frame() +} + +type ConnectResponse_Open struct { + Open *Open `protobuf:"bytes,1,opt,name=open,proto3,oneof"` +} + +type ConnectResponse_Data struct { + Data *Data `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ConnectResponse_Close struct { + Close *Close `protobuf:"bytes,3,opt,name=close,proto3,oneof"` +} + +type ConnectResponse_Reset_ struct { + Reset_ *Reset `protobuf:"bytes,4,opt,name=reset,proto3,oneof"` +} + +type ConnectResponse_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,5,opt,name=heartbeat,proto3,oneof"` +} + +type ConnectResponse_Window struct { + Window *Window `protobuf:"bytes,6,opt,name=window,proto3,oneof"` +} + +type ConnectResponse_Reconnect struct { + Reconnect *Reconnect `protobuf:"bytes,7,opt,name=reconnect,proto3,oneof"` +} + +func (*ConnectResponse_Open) isConnectResponse_Frame() {} + +func (*ConnectResponse_Data) isConnectResponse_Frame() {} + +func (*ConnectResponse_Close) isConnectResponse_Frame() {} + +func (*ConnectResponse_Reset_) isConnectResponse_Frame() {} + +func (*ConnectResponse_Heartbeat) isConnectResponse_Frame() {} + +func (*ConnectResponse_Window) isConnectResponse_Frame() {} + +func (*ConnectResponse_Reconnect) isConnectResponse_Frame() {} + +// A new conn. From the service, the CLI connects to `port` on the developer's +// machine, or resets the conn if it doesn't serve that port. From the client, +// the deployment connects to `host` and `port`, resolving `host` as its own +// code would, and the service resets the conn if it can't. The service numbers +// its conns from 1; a client numbers its own from 2^63, so the two never meet. +type Open struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId uint64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty"` + Protocol Protocol `protobuf:"varint,2,opt,name=protocol,proto3,enum=vercel.api.dev_tunnel.v1.Protocol" json:"protocol,omitempty"` + Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` + // A hostname or IP address. Only a client's Open has one. + Host string `protobuf:"bytes,4,opt,name=host,proto3" json:"host,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Open) Reset() { + *x = Open{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Open) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Open) ProtoMessage() {} + +func (x *Open) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Open.ProtoReflect.Descriptor instead. +func (*Open) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{2} +} + +func (x *Open) GetConnId() uint64 { + if x != nil { + return x.ConnId + } + return 0 +} + +func (x *Open) GetProtocol() Protocol { + if x != nil { + return x.Protocol + } + return Protocol_PROTOCOL_UNSPECIFIED +} + +func (x *Open) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *Open) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +// No more than the conn's window allows (see Window). +type Data struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId uint64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty"` + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Data) Reset() { + *x = Data{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Data) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Data) ProtoMessage() {} + +func (x *Data) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Data.ProtoReflect.Descriptor instead. +func (*Data) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{3} +} + +func (x *Data) GetConnId() uint64 { + if x != nil { + return x.ConnId + } + return 0 +} + +func (x *Data) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +// The sender has nothing more to send (TCP half-close). A conn is over once +// both sides have closed, or either has reset. +type Close struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId uint64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Close) Reset() { + *x = Close{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Close) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Close) ProtoMessage() {} + +func (x *Close) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Close.ProtoReflect.Descriptor instead. +func (*Close) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{4} +} + +func (x *Close) GetConnId() uint64 { + if x != nil { + return x.ConnId + } + return 0 +} + +// Abandons the conn in both directions, e.g. connection refused. +type Reset struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId uint64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty"` + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Reset) Reset() { + *x = Reset{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Reset) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Reset) ProtoMessage() {} + +func (x *Reset) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Reset.ProtoReflect.Descriptor instead. +func (*Reset) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{5} +} + +func (x *Reset) GetConnId() uint64 { + if x != nil { + return x.ConnId + } + return 0 +} + +func (x *Reset) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// Lets the peer send `bytes` more of a conn's Data, as the sender of this +// consumes what it received. Each side starts each conn able to send 256 KiB. +// A client that sends past its window, or grants back more than it was sent, +// has the conn reset. +type Window struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConnId uint64 `protobuf:"varint,1,opt,name=conn_id,json=connId,proto3" json:"conn_id,omitempty"` + Bytes uint32 `protobuf:"varint,2,opt,name=bytes,proto3" json:"bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Window) Reset() { + *x = Window{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Window) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Window) ProtoMessage() {} + +func (x *Window) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Window.ProtoReflect.Descriptor instead. +func (*Window) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{6} +} + +func (x *Window) GetConnId() uint64 { + if x != nil { + return x.ConnId + } + return 0 +} + +func (x *Window) GetBytes() uint32 { + if x != nil { + return x.Bytes + } + return 0 +} + +// Sent both ways every 30s, inside the ALB's idle timeout. The service drops a +// client it hasn't heard from in three. The service also sends one as soon as +// a Connect stream holds the tunnel, so the CLI knows it's live. +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{7} +} + +// This pod is shutting down. The CLI opens a new stream now, and keeps this one +// until the service ends it: it still carries the conns already open on it. +type Reconnect struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Reconnect) Reset() { + *x = Reconnect{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Reconnect) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Reconnect) ProtoMessage() {} + +func (x *Reconnect) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Reconnect.ProtoReflect.Descriptor instead. +func (*Reconnect) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{8} +} + +type DialRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *DialRequest_Target + // *DialRequest_Payload + Frame isDialRequest_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DialRequest) Reset() { + *x = DialRequest{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DialRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DialRequest) ProtoMessage() {} + +func (x *DialRequest) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DialRequest.ProtoReflect.Descriptor instead. +func (*DialRequest) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{9} +} + +func (x *DialRequest) GetFrame() isDialRequest_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *DialRequest) GetTarget() *DialTarget { + if x != nil { + if x, ok := x.Frame.(*DialRequest_Target); ok { + return x.Target + } + } + return nil +} + +func (x *DialRequest) GetPayload() []byte { + if x != nil { + if x, ok := x.Frame.(*DialRequest_Payload); ok { + return x.Payload + } + } + return nil +} + +type isDialRequest_Frame interface { + isDialRequest_Frame() +} + +type DialRequest_Target struct { + // First message only. + Target *DialTarget `protobuf:"bytes,1,opt,name=target,proto3,oneof"` +} + +type DialRequest_Payload struct { + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3,oneof"` +} + +func (*DialRequest_Target) isDialRequest_Frame() {} + +func (*DialRequest_Payload) isDialRequest_Frame() {} + +type DialTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + Protocol Protocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=vercel.api.dev_tunnel.v1.Protocol" json:"protocol,omitempty"` + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DialTarget) Reset() { + *x = DialTarget{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DialTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DialTarget) ProtoMessage() {} + +func (x *DialTarget) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DialTarget.ProtoReflect.Descriptor instead. +func (*DialTarget) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{10} +} + +func (x *DialTarget) GetProtocol() Protocol { + if x != nil { + return x.Protocol + } + return Protocol_PROTOCOL_UNSPECIFIED +} + +func (x *DialTarget) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +type DialResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Frame: + // + // *DialResponse_Payload + // *DialResponse_End + Frame isDialResponse_Frame `protobuf_oneof:"frame"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DialResponse) Reset() { + *x = DialResponse{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DialResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DialResponse) ProtoMessage() {} + +func (x *DialResponse) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DialResponse.ProtoReflect.Descriptor instead. +func (*DialResponse) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{11} +} + +func (x *DialResponse) GetFrame() isDialResponse_Frame { + if x != nil { + return x.Frame + } + return nil +} + +func (x *DialResponse) GetPayload() []byte { + if x != nil { + if x, ok := x.Frame.(*DialResponse_Payload); ok { + return x.Payload + } + } + return nil +} + +func (x *DialResponse) GetEnd() *DialEnd { + if x != nil { + if x, ok := x.Frame.(*DialResponse_End); ok { + return x.End + } + } + return nil +} + +type isDialResponse_Frame interface { + isDialResponse_Frame() +} + +type DialResponse_Payload struct { + Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3,oneof"` +} + +type DialResponse_End struct { + // The developer's side has nothing more to send. Sent once; more payload + // may still go the other way. + End *DialEnd `protobuf:"bytes,2,opt,name=end,proto3,oneof"` +} + +func (*DialResponse_Payload) isDialResponse_Frame() {} + +func (*DialResponse_End) isDialResponse_Frame() {} + +type DialEnd struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DialEnd) Reset() { + *x = DialEnd{} + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DialEnd) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DialEnd) ProtoMessage() {} + +func (x *DialEnd) ProtoReflect() protoreflect.Message { + mi := &file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DialEnd.ProtoReflect.Descriptor instead. +func (*DialEnd) Descriptor() ([]byte, []int) { + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP(), []int{12} +} + +var File_vercel_api_dev_tunnel_v1_tunnel_proto protoreflect.FileDescriptor + +const file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDesc = "" + + "\n" + + "%vercel/api/dev_tunnel/v1/tunnel.proto\x12\x18vercel.api.dev_tunnel.v1\"\xf8\x02\n" + + "\x0eConnectRequest\x124\n" + + "\x04data\x18\x01 \x01(\v2\x1e.vercel.api.dev_tunnel.v1.DataH\x00R\x04data\x127\n" + + "\x05close\x18\x02 \x01(\v2\x1f.vercel.api.dev_tunnel.v1.CloseH\x00R\x05close\x127\n" + + "\x05reset\x18\x03 \x01(\v2\x1f.vercel.api.dev_tunnel.v1.ResetH\x00R\x05reset\x12C\n" + + "\theartbeat\x18\x04 \x01(\v2#.vercel.api.dev_tunnel.v1.HeartbeatH\x00R\theartbeat\x12:\n" + + "\x06window\x18\x05 \x01(\v2 .vercel.api.dev_tunnel.v1.WindowH\x00R\x06window\x124\n" + + "\x04open\x18\x06 \x01(\v2\x1e.vercel.api.dev_tunnel.v1.OpenH\x00R\x04openB\a\n" + + "\x05frame\"\xbe\x03\n" + + "\x0fConnectResponse\x124\n" + + "\x04open\x18\x01 \x01(\v2\x1e.vercel.api.dev_tunnel.v1.OpenH\x00R\x04open\x124\n" + + "\x04data\x18\x02 \x01(\v2\x1e.vercel.api.dev_tunnel.v1.DataH\x00R\x04data\x127\n" + + "\x05close\x18\x03 \x01(\v2\x1f.vercel.api.dev_tunnel.v1.CloseH\x00R\x05close\x127\n" + + "\x05reset\x18\x04 \x01(\v2\x1f.vercel.api.dev_tunnel.v1.ResetH\x00R\x05reset\x12C\n" + + "\theartbeat\x18\x05 \x01(\v2#.vercel.api.dev_tunnel.v1.HeartbeatH\x00R\theartbeat\x12:\n" + + "\x06window\x18\x06 \x01(\v2 .vercel.api.dev_tunnel.v1.WindowH\x00R\x06window\x12C\n" + + "\treconnect\x18\a \x01(\v2#.vercel.api.dev_tunnel.v1.ReconnectH\x00R\treconnectB\a\n" + + "\x05frame\"\x87\x01\n" + + "\x04Open\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x04R\x06connId\x12>\n" + + "\bprotocol\x18\x02 \x01(\x0e2\".vercel.api.dev_tunnel.v1.ProtocolR\bprotocol\x12\x12\n" + + "\x04port\x18\x03 \x01(\rR\x04port\x12\x12\n" + + "\x04host\x18\x04 \x01(\tR\x04host\"9\n" + + "\x04Data\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x04R\x06connId\x12\x18\n" + + "\apayload\x18\x02 \x01(\fR\apayload\" \n" + + "\x05Close\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x04R\x06connId\"8\n" + + "\x05Reset\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x04R\x06connId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\"7\n" + + "\x06Window\x12\x17\n" + + "\aconn_id\x18\x01 \x01(\x04R\x06connId\x12\x14\n" + + "\x05bytes\x18\x02 \x01(\rR\x05bytes\"\v\n" + + "\tHeartbeat\"\v\n" + + "\tReconnect\"r\n" + + "\vDialRequest\x12>\n" + + "\x06target\x18\x01 \x01(\v2$.vercel.api.dev_tunnel.v1.DialTargetH\x00R\x06target\x12\x1a\n" + + "\apayload\x18\x02 \x01(\fH\x00R\apayloadB\a\n" + + "\x05frame\"`\n" + + "\n" + + "DialTarget\x12>\n" + + "\bprotocol\x18\x01 \x01(\x0e2\".vercel.api.dev_tunnel.v1.ProtocolR\bprotocol\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\"j\n" + + "\fDialResponse\x12\x1a\n" + + "\apayload\x18\x01 \x01(\fH\x00R\apayload\x125\n" + + "\x03end\x18\x02 \x01(\v2!.vercel.api.dev_tunnel.v1.DialEndH\x00R\x03endB\a\n" + + "\x05frame\"\t\n" + + "\aDialEnd*6\n" + + "\bProtocol\x12\x18\n" + + "\x14PROTOCOL_UNSPECIFIED\x10\x00\x12\x10\n" + + "\fPROTOCOL_TCP\x10\x012\xce\x01\n" + + "\rTunnelService\x12b\n" + + "\aConnect\x12(.vercel.api.dev_tunnel.v1.ConnectRequest\x1a).vercel.api.dev_tunnel.v1.ConnectResponse(\x010\x01\x12Y\n" + + "\x04Dial\x12%.vercel.api.dev_tunnel.v1.DialRequest\x1a&.vercel.api.dev_tunnel.v1.DialResponse(\x010\x01B\xee\x01\n" + + "\x1ccom.vercel.api.dev_tunnel.v1B\vTunnelProtoP\x01ZBgithub.com/vercel/bridge/api/go/vercel/api/dev_tunnel/v1;devtunnel\xa2\x02\x03VAD\xaa\x02\x17Vercel.Api.DevTunnel.V1\xca\x02\x17Vercel\\Api\\DevTunnel\\V1\xe2\x02#Vercel\\Api\\DevTunnel\\V1\\GPBMetadata\xea\x02\x1aVercel::Api::DevTunnel::V1b\x06proto3" + +var ( + file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescOnce sync.Once + file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescData []byte +) + +func file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescGZIP() []byte { + file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescOnce.Do(func() { + file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDesc), len(file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDesc))) + }) + return file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDescData +} + +var file_vercel_api_dev_tunnel_v1_tunnel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_vercel_api_dev_tunnel_v1_tunnel_proto_goTypes = []any{ + (Protocol)(0), // 0: vercel.api.dev_tunnel.v1.Protocol + (*ConnectRequest)(nil), // 1: vercel.api.dev_tunnel.v1.ConnectRequest + (*ConnectResponse)(nil), // 2: vercel.api.dev_tunnel.v1.ConnectResponse + (*Open)(nil), // 3: vercel.api.dev_tunnel.v1.Open + (*Data)(nil), // 4: vercel.api.dev_tunnel.v1.Data + (*Close)(nil), // 5: vercel.api.dev_tunnel.v1.Close + (*Reset)(nil), // 6: vercel.api.dev_tunnel.v1.Reset + (*Window)(nil), // 7: vercel.api.dev_tunnel.v1.Window + (*Heartbeat)(nil), // 8: vercel.api.dev_tunnel.v1.Heartbeat + (*Reconnect)(nil), // 9: vercel.api.dev_tunnel.v1.Reconnect + (*DialRequest)(nil), // 10: vercel.api.dev_tunnel.v1.DialRequest + (*DialTarget)(nil), // 11: vercel.api.dev_tunnel.v1.DialTarget + (*DialResponse)(nil), // 12: vercel.api.dev_tunnel.v1.DialResponse + (*DialEnd)(nil), // 13: vercel.api.dev_tunnel.v1.DialEnd +} +var file_vercel_api_dev_tunnel_v1_tunnel_proto_depIdxs = []int32{ + 4, // 0: vercel.api.dev_tunnel.v1.ConnectRequest.data:type_name -> vercel.api.dev_tunnel.v1.Data + 5, // 1: vercel.api.dev_tunnel.v1.ConnectRequest.close:type_name -> vercel.api.dev_tunnel.v1.Close + 6, // 2: vercel.api.dev_tunnel.v1.ConnectRequest.reset:type_name -> vercel.api.dev_tunnel.v1.Reset + 8, // 3: vercel.api.dev_tunnel.v1.ConnectRequest.heartbeat:type_name -> vercel.api.dev_tunnel.v1.Heartbeat + 7, // 4: vercel.api.dev_tunnel.v1.ConnectRequest.window:type_name -> vercel.api.dev_tunnel.v1.Window + 3, // 5: vercel.api.dev_tunnel.v1.ConnectRequest.open:type_name -> vercel.api.dev_tunnel.v1.Open + 3, // 6: vercel.api.dev_tunnel.v1.ConnectResponse.open:type_name -> vercel.api.dev_tunnel.v1.Open + 4, // 7: vercel.api.dev_tunnel.v1.ConnectResponse.data:type_name -> vercel.api.dev_tunnel.v1.Data + 5, // 8: vercel.api.dev_tunnel.v1.ConnectResponse.close:type_name -> vercel.api.dev_tunnel.v1.Close + 6, // 9: vercel.api.dev_tunnel.v1.ConnectResponse.reset:type_name -> vercel.api.dev_tunnel.v1.Reset + 8, // 10: vercel.api.dev_tunnel.v1.ConnectResponse.heartbeat:type_name -> vercel.api.dev_tunnel.v1.Heartbeat + 7, // 11: vercel.api.dev_tunnel.v1.ConnectResponse.window:type_name -> vercel.api.dev_tunnel.v1.Window + 9, // 12: vercel.api.dev_tunnel.v1.ConnectResponse.reconnect:type_name -> vercel.api.dev_tunnel.v1.Reconnect + 0, // 13: vercel.api.dev_tunnel.v1.Open.protocol:type_name -> vercel.api.dev_tunnel.v1.Protocol + 11, // 14: vercel.api.dev_tunnel.v1.DialRequest.target:type_name -> vercel.api.dev_tunnel.v1.DialTarget + 0, // 15: vercel.api.dev_tunnel.v1.DialTarget.protocol:type_name -> vercel.api.dev_tunnel.v1.Protocol + 13, // 16: vercel.api.dev_tunnel.v1.DialResponse.end:type_name -> vercel.api.dev_tunnel.v1.DialEnd + 1, // 17: vercel.api.dev_tunnel.v1.TunnelService.Connect:input_type -> vercel.api.dev_tunnel.v1.ConnectRequest + 10, // 18: vercel.api.dev_tunnel.v1.TunnelService.Dial:input_type -> vercel.api.dev_tunnel.v1.DialRequest + 2, // 19: vercel.api.dev_tunnel.v1.TunnelService.Connect:output_type -> vercel.api.dev_tunnel.v1.ConnectResponse + 12, // 20: vercel.api.dev_tunnel.v1.TunnelService.Dial:output_type -> vercel.api.dev_tunnel.v1.DialResponse + 19, // [19:21] is the sub-list for method output_type + 17, // [17:19] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name +} + +func init() { file_vercel_api_dev_tunnel_v1_tunnel_proto_init() } +func file_vercel_api_dev_tunnel_v1_tunnel_proto_init() { + if File_vercel_api_dev_tunnel_v1_tunnel_proto != nil { + return + } + file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[0].OneofWrappers = []any{ + (*ConnectRequest_Data)(nil), + (*ConnectRequest_Close)(nil), + (*ConnectRequest_Reset_)(nil), + (*ConnectRequest_Heartbeat)(nil), + (*ConnectRequest_Window)(nil), + (*ConnectRequest_Open)(nil), + } + file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[1].OneofWrappers = []any{ + (*ConnectResponse_Open)(nil), + (*ConnectResponse_Data)(nil), + (*ConnectResponse_Close)(nil), + (*ConnectResponse_Reset_)(nil), + (*ConnectResponse_Heartbeat)(nil), + (*ConnectResponse_Window)(nil), + (*ConnectResponse_Reconnect)(nil), + } + file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[9].OneofWrappers = []any{ + (*DialRequest_Target)(nil), + (*DialRequest_Payload)(nil), + } + file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes[11].OneofWrappers = []any{ + (*DialResponse_Payload)(nil), + (*DialResponse_End)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDesc), len(file_vercel_api_dev_tunnel_v1_tunnel_proto_rawDesc)), + NumEnums: 1, + NumMessages: 13, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_vercel_api_dev_tunnel_v1_tunnel_proto_goTypes, + DependencyIndexes: file_vercel_api_dev_tunnel_v1_tunnel_proto_depIdxs, + EnumInfos: file_vercel_api_dev_tunnel_v1_tunnel_proto_enumTypes, + MessageInfos: file_vercel_api_dev_tunnel_v1_tunnel_proto_msgTypes, + }.Build() + File_vercel_api_dev_tunnel_v1_tunnel_proto = out.File + file_vercel_api_dev_tunnel_v1_tunnel_proto_goTypes = nil + file_vercel_api_dev_tunnel_v1_tunnel_proto_depIdxs = nil +} diff --git a/api/go/vercel/api/dev_tunnel/v1/tunnel_grpc.pb.go b/api/go/vercel/api/dev_tunnel/v1/tunnel_grpc.pb.go new file mode 100644 index 0000000..8b661e7 --- /dev/null +++ b/api/go/vercel/api/dev_tunnel/v1/tunnel_grpc.pb.go @@ -0,0 +1,210 @@ +// Vendored from vercel/api, services/api-dev-tunnel/proto/tunnel/v1/tunnel.proto. +// Edit it there and copy it here. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: vercel/api/dev_tunnel/v1/tunnel.proto + +package devtunnel + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + TunnelService_Connect_FullMethodName = "/vercel.api.dev_tunnel.v1.TunnelService/Connect" + TunnelService_Dial_FullMethodName = "/vercel.api.dev_tunnel.v1.TunnelService/Dial" +) + +// TunnelServiceClient is the client API for TunnelService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Relays TCP connections between the tunnel's deployment and the developer's +// machine. Payloads are opaque bytes; nothing here interprets them. +type TunnelServiceClient interface { + // The stream `vc dev --tunnel` holds open, carrying every conn the + // deployment dials and every conn the client opens through the deployment. + // Authenticated with the client token + // (`authorization: Bearer`), which the next create-session replaces: a CLI + // refused with UNAUTHENTICATED on a reconnect asks for a new one. + // + // One client per tunnel: another stream fails with ALREADY_EXISTS, unless it + // carries the same `x-vercel-dev-tunnel-instance` (1 to 64 letters, digits + // or dashes, one per CLI process). Then it replaces the old stream, which + // ends with ABORTED, so a CLI can reconnect while its old stream is still + // open on the service. The CLI sends a Heartbeat at least every 30s; a + // stream silent for three ends with DEADLINE_EXCEEDED, and one ending with + // UNAVAILABLE means reconnect. A CLI that stops reading the stream while + // the service has frames for it has it end with RESOURCE_EXHAUSTED. + // + // A pod shutting down sends Reconnect and refuses new streams with + // UNAVAILABLE. Once the CLI's new stream holds the tunnel elsewhere, the old + // one gets no new conns and ends with ABORTED when its open ones are done. + Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ConnectRequest, ConnectResponse], error) + // One conn from the deployment: a TCP connection to a port on the + // developer's machine. The first message is the target; the rest carry + // payload. Ending the request stream half-closes the conn, and `end` says the + // developer's side has; the call ends once both have, and fails if either + // side resets. With no client connected, it waits up to 5s for one, then + // fails with UNAVAILABLE. A tunnel carries at most 256 conns at once; more + // wait up to 30s for one to end, then fail with RESOURCE_EXHAUSTED, as do + // any past 256 waiting. Authenticated with the server token + // (`authorization: Bearer`). + Dial(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[DialRequest, DialResponse], error) +} + +type tunnelServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTunnelServiceClient(cc grpc.ClientConnInterface) TunnelServiceClient { + return &tunnelServiceClient{cc} +} + +func (c *tunnelServiceClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ConnectRequest, ConnectResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &TunnelService_ServiceDesc.Streams[0], TunnelService_Connect_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ConnectRequest, ConnectResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TunnelService_ConnectClient = grpc.BidiStreamingClient[ConnectRequest, ConnectResponse] + +func (c *tunnelServiceClient) Dial(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[DialRequest, DialResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &TunnelService_ServiceDesc.Streams[1], TunnelService_Dial_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[DialRequest, DialResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TunnelService_DialClient = grpc.BidiStreamingClient[DialRequest, DialResponse] + +// TunnelServiceServer is the server API for TunnelService service. +// All implementations must embed UnimplementedTunnelServiceServer +// for forward compatibility. +// +// Relays TCP connections between the tunnel's deployment and the developer's +// machine. Payloads are opaque bytes; nothing here interprets them. +type TunnelServiceServer interface { + // The stream `vc dev --tunnel` holds open, carrying every conn the + // deployment dials and every conn the client opens through the deployment. + // Authenticated with the client token + // (`authorization: Bearer`), which the next create-session replaces: a CLI + // refused with UNAUTHENTICATED on a reconnect asks for a new one. + // + // One client per tunnel: another stream fails with ALREADY_EXISTS, unless it + // carries the same `x-vercel-dev-tunnel-instance` (1 to 64 letters, digits + // or dashes, one per CLI process). Then it replaces the old stream, which + // ends with ABORTED, so a CLI can reconnect while its old stream is still + // open on the service. The CLI sends a Heartbeat at least every 30s; a + // stream silent for three ends with DEADLINE_EXCEEDED, and one ending with + // UNAVAILABLE means reconnect. A CLI that stops reading the stream while + // the service has frames for it has it end with RESOURCE_EXHAUSTED. + // + // A pod shutting down sends Reconnect and refuses new streams with + // UNAVAILABLE. Once the CLI's new stream holds the tunnel elsewhere, the old + // one gets no new conns and ends with ABORTED when its open ones are done. + Connect(grpc.BidiStreamingServer[ConnectRequest, ConnectResponse]) error + // One conn from the deployment: a TCP connection to a port on the + // developer's machine. The first message is the target; the rest carry + // payload. Ending the request stream half-closes the conn, and `end` says the + // developer's side has; the call ends once both have, and fails if either + // side resets. With no client connected, it waits up to 5s for one, then + // fails with UNAVAILABLE. A tunnel carries at most 256 conns at once; more + // wait up to 30s for one to end, then fail with RESOURCE_EXHAUSTED, as do + // any past 256 waiting. Authenticated with the server token + // (`authorization: Bearer`). + Dial(grpc.BidiStreamingServer[DialRequest, DialResponse]) error + mustEmbedUnimplementedTunnelServiceServer() +} + +// UnimplementedTunnelServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTunnelServiceServer struct{} + +func (UnimplementedTunnelServiceServer) Connect(grpc.BidiStreamingServer[ConnectRequest, ConnectResponse]) error { + return status.Error(codes.Unimplemented, "method Connect not implemented") +} +func (UnimplementedTunnelServiceServer) Dial(grpc.BidiStreamingServer[DialRequest, DialResponse]) error { + return status.Error(codes.Unimplemented, "method Dial not implemented") +} +func (UnimplementedTunnelServiceServer) mustEmbedUnimplementedTunnelServiceServer() {} +func (UnimplementedTunnelServiceServer) testEmbeddedByValue() {} + +// UnsafeTunnelServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TunnelServiceServer will +// result in compilation errors. +type UnsafeTunnelServiceServer interface { + mustEmbedUnimplementedTunnelServiceServer() +} + +func RegisterTunnelServiceServer(s grpc.ServiceRegistrar, srv TunnelServiceServer) { + // If the following call panics, it indicates UnimplementedTunnelServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TunnelService_ServiceDesc, srv) +} + +func _TunnelService_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TunnelServiceServer).Connect(&grpc.GenericServerStream[ConnectRequest, ConnectResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TunnelService_ConnectServer = grpc.BidiStreamingServer[ConnectRequest, ConnectResponse] + +func _TunnelService_Dial_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(TunnelServiceServer).Dial(&grpc.GenericServerStream[DialRequest, DialResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type TunnelService_DialServer = grpc.BidiStreamingServer[DialRequest, DialResponse] + +// TunnelService_ServiceDesc is the grpc.ServiceDesc for TunnelService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TunnelService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "vercel.api.dev_tunnel.v1.TunnelService", + HandlerType: (*TunnelServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Connect", + Handler: _TunnelService_Connect_Handler, + ServerStreams: true, + ClientStreams: true, + }, + { + StreamName: "Dial", + Handler: _TunnelService_Dial_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "vercel/api/dev_tunnel/v1/tunnel.proto", +} diff --git a/api/ts/bridge/v1/interceptor_pb.d.ts b/api/ts/bridge/v1/interceptor_pb.d.ts index fdebca1..224ccd1 100644 --- a/api/ts/bridge/v1/interceptor_pb.d.ts +++ b/api/ts/bridge/v1/interceptor_pb.d.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.0 with parameter "target=js+dts" +// @generated by protoc-gen-es v2.16.0 with parameter "target=js+dts" // @generated from file bridge/v1/interceptor.proto (package bridge.v1, syntax proto3) /* eslint-disable */ @@ -57,6 +57,14 @@ export declare type GetStatusResponse = Message<"bridge.v1.GetStatusResponse"> & * @generated from field: bridge.v1.ProbeStatus startup = 4; */ startup?: ProbeStatus | undefined; + + /** + * The dev tunnel's stream to its relay. Unset unless + * --tunnel-protocol=dev-tunnel. + * + * @generated from field: bridge.v1.TunnelStatus tunnel = 5; + */ + tunnel?: TunnelStatus | undefined; }; /** @@ -65,6 +73,112 @@ export declare type GetStatusResponse = Message<"bridge.v1.GetStatusResponse"> & */ export declare const GetStatusResponseSchema: GenMessage; +/** + * TunnelStatus is the state of a dev tunnel's stream to api-dev-tunnel, and + * the conns it carries. + * + * @generated from message bridge.v1.TunnelStatus + */ +export declare type TunnelStatus = Message<"bridge.v1.TunnelStatus"> & { + /** + * @generated from field: bridge.v1.TunnelState state = 1; + */ + state: TunnelState; + + /** + * Why the last stream ended, or the relay refused one. Empty before any + * has. + * + * @generated from field: string last_error = 2 [json_name = "last_error"]; + */ + lastError: string; + + /** + * Unix seconds since a stream has held the tunnel. Zero unless LIVE. + * + * @generated from field: int64 live_since_unix_seconds = 3 [json_name = "live_since_unix_seconds"]; + */ + liveSinceUnixSeconds: bigint; + + /** + * Streams opened after the first. + * + * @generated from field: int64 reconnects = 4; + */ + reconnects: bigint; + + /** + * Conns open now from the deployment to the app (ingress), and from the + * app out through the deployment (egress). + * + * @generated from field: int64 ingress_conns = 5 [json_name = "ingress_conns"]; + */ + ingressConns: bigint; + + /** + * @generated from field: int64 egress_conns = 6 [json_name = "egress_conns"]; + */ + egressConns: bigint; + + /** + * Conns opened since the intercept started. + * + * @generated from field: int64 ingress_conns_total = 7 [json_name = "ingress_conns_total"]; + */ + ingressConnsTotal: bigint; + + /** + * @generated from field: int64 egress_conns_total = 8 [json_name = "egress_conns_total"]; + */ + egressConnsTotal: bigint; + + /** + * Payload bytes sent to and received from the relay. + * + * @generated from field: int64 bytes_sent = 9 [json_name = "bytes_sent"]; + */ + bytesSent: bigint; + + /** + * @generated from field: int64 bytes_received = 10 [json_name = "bytes_received"]; + */ + bytesReceived: bigint; +}; + +/** + * Describes the message bridge.v1.TunnelStatus. + * Use `create(TunnelStatusSchema)` to create a new message. + */ +export declare const TunnelStatusSchema: GenMessage; + +/** + * @generated from message bridge.v1.SetTunnelTokenRequest + */ +export declare type SetTunnelTokenRequest = Message<"bridge.v1.SetTunnelTokenRequest"> & { + /** + * @generated from field: string token = 1; + */ + token: string; +}; + +/** + * Describes the message bridge.v1.SetTunnelTokenRequest. + * Use `create(SetTunnelTokenRequestSchema)` to create a new message. + */ +export declare const SetTunnelTokenRequestSchema: GenMessage; + +/** + * @generated from message bridge.v1.SetTunnelTokenResponse + */ +export declare type SetTunnelTokenResponse = Message<"bridge.v1.SetTunnelTokenResponse"> & { +}; + +/** + * Describes the message bridge.v1.SetTunnelTokenResponse. + * Use `create(SetTunnelTokenResponseSchema)` to create a new message. + */ +export declare const SetTunnelTokenResponseSchema: GenMessage; + /** * ProbeStatus reflects the current health view from a single configured * probe — what the source deployment's probe would have reported if it had @@ -200,6 +314,58 @@ export declare type ProbeCheckResult = Message<"bridge.v1.ProbeCheckResult"> & { */ export declare const ProbeCheckResultSchema: GenMessage; +/** + * @generated from enum bridge.v1.TunnelState + */ +export enum TunnelState { + /** + * @generated from enum value: TUNNEL_STATE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * No stream holds the tunnel yet, or the last one ended and the next is + * opening. + * + * @generated from enum value: TUNNEL_STATE_CONNECTING = 1; + */ + CONNECTING = 1, + + /** + * A stream holds the tunnel: the deployment's conns reach the app. + * + * @generated from enum value: TUNNEL_STATE_LIVE = 2; + */ + LIVE = 2, + + /** + * The relay refused the client token, or there is none yet. Waiting for + * SetTunnelToken. + * + * @generated from enum value: TUNNEL_STATE_NEEDS_TOKEN = 3; + */ + NEEDS_TOKEN = 3, + + /** + * Another client holds the tunnel. Retrying until it lets go. + * + * @generated from enum value: TUNNEL_STATE_TAKEN = 4; + */ + TAKEN = 4, + + /** + * The relay refused the tunnel for good; last_error says why. + * + * @generated from enum value: TUNNEL_STATE_FAILED = 5; + */ + FAILED = 5, +} + +/** + * Describes the enum bridge.v1.TunnelState. + */ +export declare const TunnelStateSchema: GenEnum; + /** * @generated from enum bridge.v1.ProbeHealth */ @@ -272,5 +438,18 @@ export declare const InterceptorService: GenService<{ input: typeof ProbeRequestSchema; output: typeof ProbeResponseSchema; }, + /** + * SetTunnelToken gives an intercept on a dev tunnel + * (--tunnel-protocol=dev-tunnel) its client token, from create-session. One + * started without a token, or whose token the relay refused + * (TUNNEL_STATE_NEEDS_TOKEN), connects with it. + * + * @generated from rpc bridge.v1.InterceptorService.SetTunnelToken + */ + setTunnelToken: { + methodKind: "unary"; + input: typeof SetTunnelTokenRequestSchema; + output: typeof SetTunnelTokenResponseSchema; + }, }>; diff --git a/api/ts/bridge/v1/interceptor_pb.js b/api/ts/bridge/v1/interceptor_pb.js index e47bb91..603f0cc 100644 --- a/api/ts/bridge/v1/interceptor_pb.js +++ b/api/ts/bridge/v1/interceptor_pb.js @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.0 with parameter "target=js+dts" +// @generated by protoc-gen-es v2.16.0 with parameter "target=js+dts" // @generated from file bridge/v1/interceptor.proto (package bridge.v1, syntax proto3) /* eslint-disable */ @@ -8,7 +8,7 @@ import { enumDesc, fileDesc, messageDesc, serviceDesc, tsEnum } from "@bufbuild/ * Describes the file bridge/v1/interceptor.proto. */ export const file_bridge_v1_interceptor = /*@__PURE__*/ - fileDesc("ChticmlkZ2UvdjEvaW50ZXJjZXB0b3IucHJvdG8SCWJyaWRnZS52MSISChBHZXRTdGF0dXNSZXF1ZXN0IqABChFHZXRTdGF0dXNSZXNwb25zZRINCgVyZWFkeRgBIAEoCBIoCghsaXZlbmVzcxgCIAEoCzIWLmJyaWRnZS52MS5Qcm9iZVN0YXR1cxIpCglyZWFkaW5lc3MYAyABKAsyFi5icmlkZ2UudjEuUHJvYmVTdGF0dXMSJwoHc3RhcnR1cBgEIAEoCzIWLmJyaWRnZS52MS5Qcm9iZVN0YXR1cyKOAgoLUHJvYmVTdGF0dXMSJgoGaGVhbHRoGAEgASgOMhYuYnJpZGdlLnYxLlByb2JlSGVhbHRoEg8KB21lc3NhZ2UYAiABKAkSOAoXbGFzdF9jaGVja191bml4X3NlY29uZHMYAyABKANSF2xhc3RfY2hlY2tfdW5peF9zZWNvbmRzEjQKFWNvbnNlY3V0aXZlX3N1Y2Nlc3NlcxgEIAEoBVIVY29uc2VjdXRpdmVfc3VjY2Vzc2VzEjIKFGNvbnNlY3V0aXZlX2ZhaWx1cmVzGAUgASgFUhRjb25zZWN1dGl2ZV9mYWlsdXJlcxIiCgx0b3RhbF9jaGVja3MYBiABKANSDHRvdGFsX2NoZWNrcyIOCgxQcm9iZVJlcXVlc3QinAEKDVByb2JlUmVzcG9uc2USLQoIbGl2ZW5lc3MYASABKAsyGy5icmlkZ2UudjEuUHJvYmVDaGVja1Jlc3VsdBIuCglyZWFkaW5lc3MYAiABKAsyGy5icmlkZ2UudjEuUHJvYmVDaGVja1Jlc3VsdBIsCgdzdGFydHVwGAMgASgLMhsuYnJpZGdlLnYxLlByb2JlQ2hlY2tSZXN1bHQiawoQUHJvYmVDaGVja1Jlc3VsdBIOCgZwYXNzZWQYASABKAgSDQoFZXJyb3IYAiABKAkSOAoXY2hlY2tlZF9hdF91bml4X3NlY29uZHMYAyABKANSF2NoZWNrZWRfYXRfdW5peF9zZWNvbmRzKnsKC1Byb2JlSGVhbHRoEhwKGFBST0JFX0hFQUxUSF9VTlNQRUNJRklFRBAAEhgKFFBST0JFX0hFQUxUSF9QRU5ESU5HEAESGAoUUFJPQkVfSEVBTFRIX0hFQUxUSFkQAhIaChZQUk9CRV9IRUFMVEhfVU5IRUFMVEhZEAMymAEKEkludGVyY2VwdG9yU2VydmljZRJGCglHZXRTdGF0dXMSGy5icmlkZ2UudjEuR2V0U3RhdHVzUmVxdWVzdBocLmJyaWRnZS52MS5HZXRTdGF0dXNSZXNwb25zZRI6CgVQcm9iZRIXLmJyaWRnZS52MS5Qcm9iZVJlcXVlc3QaGC5icmlkZ2UudjEuUHJvYmVSZXNwb25zZUKaAQoNY29tLmJyaWRnZS52MUIQSW50ZXJjZXB0b3JQcm90b1ABWjJnaXRodWIuY29tL3ZlcmNlbC9icmlkZ2UvYXBpL2dvL2JyaWRnZS92MTticmlkZ2V2MaICA0JYWKoCCUJyaWRnZS5WMcoCCUJyaWRnZVxWMeICFUJyaWRnZVxWMVxHUEJNZXRhZGF0YeoCCkJyaWRnZTo6VjFiBnByb3RvMw"); + fileDesc("ChticmlkZ2UvdjEvaW50ZXJjZXB0b3IucHJvdG8SCWJyaWRnZS52MSISChBHZXRTdGF0dXNSZXF1ZXN0IskBChFHZXRTdGF0dXNSZXNwb25zZRINCgVyZWFkeRgBIAEoCBIoCghsaXZlbmVzcxgCIAEoCzIWLmJyaWRnZS52MS5Qcm9iZVN0YXR1cxIpCglyZWFkaW5lc3MYAyABKAsyFi5icmlkZ2UudjEuUHJvYmVTdGF0dXMSJwoHc3RhcnR1cBgEIAEoCzIWLmJyaWRnZS52MS5Qcm9iZVN0YXR1cxInCgZ0dW5uZWwYBSABKAsyFy5icmlkZ2UudjEuVHVubmVsU3RhdHVzIpcDCgxUdW5uZWxTdGF0dXMSJQoFc3RhdGUYASABKA4yFi5icmlkZ2UudjEuVHVubmVsU3RhdGUSHgoKbGFzdF9lcnJvchgCIAEoCVIKbGFzdF9lcnJvchI4ChdsaXZlX3NpbmNlX3VuaXhfc2Vjb25kcxgDIAEoA1IXbGl2ZV9zaW5jZV91bml4X3NlY29uZHMSEgoKcmVjb25uZWN0cxgEIAEoAxIkCg1pbmdyZXNzX2Nvbm5zGAUgASgDUg1pbmdyZXNzX2Nvbm5zEiIKDGVncmVzc19jb25ucxgGIAEoA1IMZWdyZXNzX2Nvbm5zEjAKE2luZ3Jlc3NfY29ubnNfdG90YWwYByABKANSE2luZ3Jlc3NfY29ubnNfdG90YWwSLgoSZWdyZXNzX2Nvbm5zX3RvdGFsGAggASgDUhJlZ3Jlc3NfY29ubnNfdG90YWwSHgoKYnl0ZXNfc2VudBgJIAEoA1IKYnl0ZXNfc2VudBImCg5ieXRlc19yZWNlaXZlZBgKIAEoA1IOYnl0ZXNfcmVjZWl2ZWQiJgoVU2V0VHVubmVsVG9rZW5SZXF1ZXN0Eg0KBXRva2VuGAEgASgJIhgKFlNldFR1bm5lbFRva2VuUmVzcG9uc2UijgIKC1Byb2JlU3RhdHVzEiYKBmhlYWx0aBgBIAEoDjIWLmJyaWRnZS52MS5Qcm9iZUhlYWx0aBIPCgdtZXNzYWdlGAIgASgJEjgKF2xhc3RfY2hlY2tfdW5peF9zZWNvbmRzGAMgASgDUhdsYXN0X2NoZWNrX3VuaXhfc2Vjb25kcxI0ChVjb25zZWN1dGl2ZV9zdWNjZXNzZXMYBCABKAVSFWNvbnNlY3V0aXZlX3N1Y2Nlc3NlcxIyChRjb25zZWN1dGl2ZV9mYWlsdXJlcxgFIAEoBVIUY29uc2VjdXRpdmVfZmFpbHVyZXMSIgoMdG90YWxfY2hlY2tzGAYgASgDUgx0b3RhbF9jaGVja3MiDgoMUHJvYmVSZXF1ZXN0IpwBCg1Qcm9iZVJlc3BvbnNlEi0KCGxpdmVuZXNzGAEgASgLMhsuYnJpZGdlLnYxLlByb2JlQ2hlY2tSZXN1bHQSLgoJcmVhZGluZXNzGAIgASgLMhsuYnJpZGdlLnYxLlByb2JlQ2hlY2tSZXN1bHQSLAoHc3RhcnR1cBgDIAEoCzIbLmJyaWRnZS52MS5Qcm9iZUNoZWNrUmVzdWx0ImsKEFByb2JlQ2hlY2tSZXN1bHQSDgoGcGFzc2VkGAEgASgIEg0KBWVycm9yGAIgASgJEjgKF2NoZWNrZWRfYXRfdW5peF9zZWNvbmRzGAMgASgDUhdjaGVja2VkX2F0X3VuaXhfc2Vjb25kcyquAQoLVHVubmVsU3RhdGUSHAoYVFVOTkVMX1NUQVRFX1VOU1BFQ0lGSUVEEAASGwoXVFVOTkVMX1NUQVRFX0NPTk5FQ1RJTkcQARIVChFUVU5ORUxfU1RBVEVfTElWRRACEhwKGFRVTk5FTF9TVEFURV9ORUVEU19UT0tFThADEhYKElRVTk5FTF9TVEFURV9UQUtFThAEEhcKE1RVTk5FTF9TVEFURV9GQUlMRUQQBSp7CgtQcm9iZUhlYWx0aBIcChhQUk9CRV9IRUFMVEhfVU5TUEVDSUZJRUQQABIYChRQUk9CRV9IRUFMVEhfUEVORElORxABEhgKFFBST0JFX0hFQUxUSF9IRUFMVEhZEAISGgoWUFJPQkVfSEVBTFRIX1VOSEVBTFRIWRADMu8BChJJbnRlcmNlcHRvclNlcnZpY2USRgoJR2V0U3RhdHVzEhsuYnJpZGdlLnYxLkdldFN0YXR1c1JlcXVlc3QaHC5icmlkZ2UudjEuR2V0U3RhdHVzUmVzcG9uc2USOgoFUHJvYmUSFy5icmlkZ2UudjEuUHJvYmVSZXF1ZXN0GhguYnJpZGdlLnYxLlByb2JlUmVzcG9uc2USVQoOU2V0VHVubmVsVG9rZW4SIC5icmlkZ2UudjEuU2V0VHVubmVsVG9rZW5SZXF1ZXN0GiEuYnJpZGdlLnYxLlNldFR1bm5lbFRva2VuUmVzcG9uc2VCmgEKDWNvbS5icmlkZ2UudjFCEEludGVyY2VwdG9yUHJvdG9QAVoyZ2l0aHViLmNvbS92ZXJjZWwvYnJpZGdlL2FwaS9nby9icmlkZ2UvdjE7YnJpZGdldjGiAgNCWFiqAglCcmlkZ2UuVjHKAglCcmlkZ2VcVjHiAhVCcmlkZ2VcVjFcR1BCTWV0YWRhdGHqAgpCcmlkZ2U6OlYxYgZwcm90bzM"); /** * Describes the message bridge.v1.GetStatusRequest. @@ -24,39 +24,72 @@ export const GetStatusRequestSchema = /*@__PURE__*/ export const GetStatusResponseSchema = /*@__PURE__*/ messageDesc(file_bridge_v1_interceptor, 1); +/** + * Describes the message bridge.v1.TunnelStatus. + * Use `create(TunnelStatusSchema)` to create a new message. + */ +export const TunnelStatusSchema = /*@__PURE__*/ + messageDesc(file_bridge_v1_interceptor, 2); + +/** + * Describes the message bridge.v1.SetTunnelTokenRequest. + * Use `create(SetTunnelTokenRequestSchema)` to create a new message. + */ +export const SetTunnelTokenRequestSchema = /*@__PURE__*/ + messageDesc(file_bridge_v1_interceptor, 3); + +/** + * Describes the message bridge.v1.SetTunnelTokenResponse. + * Use `create(SetTunnelTokenResponseSchema)` to create a new message. + */ +export const SetTunnelTokenResponseSchema = /*@__PURE__*/ + messageDesc(file_bridge_v1_interceptor, 4); + /** * Describes the message bridge.v1.ProbeStatus. * Use `create(ProbeStatusSchema)` to create a new message. */ export const ProbeStatusSchema = /*@__PURE__*/ - messageDesc(file_bridge_v1_interceptor, 2); + messageDesc(file_bridge_v1_interceptor, 5); /** * Describes the message bridge.v1.ProbeRequest. * Use `create(ProbeRequestSchema)` to create a new message. */ export const ProbeRequestSchema = /*@__PURE__*/ - messageDesc(file_bridge_v1_interceptor, 3); + messageDesc(file_bridge_v1_interceptor, 6); /** * Describes the message bridge.v1.ProbeResponse. * Use `create(ProbeResponseSchema)` to create a new message. */ export const ProbeResponseSchema = /*@__PURE__*/ - messageDesc(file_bridge_v1_interceptor, 4); + messageDesc(file_bridge_v1_interceptor, 7); /** * Describes the message bridge.v1.ProbeCheckResult. * Use `create(ProbeCheckResultSchema)` to create a new message. */ export const ProbeCheckResultSchema = /*@__PURE__*/ - messageDesc(file_bridge_v1_interceptor, 5); + messageDesc(file_bridge_v1_interceptor, 8); + +/** + * Describes the enum bridge.v1.TunnelState. + */ +export const TunnelStateSchema = /*@__PURE__*/ + enumDesc(file_bridge_v1_interceptor, 0); + +/** + * @generated from enum bridge.v1.TunnelState + */ +export const TunnelState = /*@__PURE__*/ + tsEnum(TunnelStateSchema); /** * Describes the enum bridge.v1.ProbeHealth. */ export const ProbeHealthSchema = /*@__PURE__*/ - enumDesc(file_bridge_v1_interceptor, 0); + enumDesc(file_bridge_v1_interceptor, 1); /** * @generated from enum bridge.v1.ProbeHealth diff --git a/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.d.ts b/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.d.ts new file mode 100644 index 0000000..0832ced --- /dev/null +++ b/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.d.ts @@ -0,0 +1,458 @@ +// Vendored from vercel/api, services/api-dev-tunnel/proto/tunnel/v1/tunnel.proto. +// Edit it there and copy it here. + +// @generated by protoc-gen-es v2.16.0 with parameter "target=js+dts" +// @generated from file vercel/api/dev_tunnel/v1/tunnel.proto (package vercel.api.dev_tunnel.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file vercel/api/dev_tunnel/v1/tunnel.proto. + */ +export declare const file_vercel_api_dev_tunnel_v1_tunnel: GenFile; + +/** + * CLI to service. + * + * @generated from message vercel.api.dev_tunnel.v1.ConnectRequest + */ +export declare type ConnectRequest = Message<"vercel.api.dev_tunnel.v1.ConnectRequest"> & { + /** + * @generated from oneof vercel.api.dev_tunnel.v1.ConnectRequest.frame + */ + frame: { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Data data = 1; + */ + value: Data; + case: "data"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Close close = 2; + */ + value: Close; + case: "close"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Reset reset = 3; + */ + value: Reset; + case: "reset"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Heartbeat heartbeat = 4; + */ + value: Heartbeat; + case: "heartbeat"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Window window = 5; + */ + value: Window; + case: "window"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Open open = 6; + */ + value: Open; + case: "open"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.ConnectRequest. + * Use `create(ConnectRequestSchema)` to create a new message. + */ +export declare const ConnectRequestSchema: GenMessage; + +/** + * Service to CLI. + * + * @generated from message vercel.api.dev_tunnel.v1.ConnectResponse + */ +export declare type ConnectResponse = Message<"vercel.api.dev_tunnel.v1.ConnectResponse"> & { + /** + * @generated from oneof vercel.api.dev_tunnel.v1.ConnectResponse.frame + */ + frame: { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Open open = 1; + */ + value: Open; + case: "open"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Data data = 2; + */ + value: Data; + case: "data"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Close close = 3; + */ + value: Close; + case: "close"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Reset reset = 4; + */ + value: Reset; + case: "reset"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Heartbeat heartbeat = 5; + */ + value: Heartbeat; + case: "heartbeat"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Window window = 6; + */ + value: Window; + case: "window"; + } | { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Reconnect reconnect = 7; + */ + value: Reconnect; + case: "reconnect"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.ConnectResponse. + * Use `create(ConnectResponseSchema)` to create a new message. + */ +export declare const ConnectResponseSchema: GenMessage; + +/** + * A new conn. From the service, the CLI connects to `port` on the developer's + * machine, or resets the conn if it doesn't serve that port. From the client, + * the deployment connects to `host` and `port`, resolving `host` as its own + * code would, and the service resets the conn if it can't. The service numbers + * its conns from 1; a client numbers its own from 2^63, so the two never meet. + * + * @generated from message vercel.api.dev_tunnel.v1.Open + */ +export declare type Open = Message<"vercel.api.dev_tunnel.v1.Open"> & { + /** + * @generated from field: uint64 conn_id = 1; + */ + connId: bigint; + + /** + * @generated from field: vercel.api.dev_tunnel.v1.Protocol protocol = 2; + */ + protocol: Protocol; + + /** + * @generated from field: uint32 port = 3; + */ + port: number; + + /** + * A hostname or IP address. Only a client's Open has one. + * + * @generated from field: string host = 4; + */ + host: string; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Open. + * Use `create(OpenSchema)` to create a new message. + */ +export declare const OpenSchema: GenMessage; + +/** + * No more than the conn's window allows (see Window). + * + * @generated from message vercel.api.dev_tunnel.v1.Data + */ +export declare type Data = Message<"vercel.api.dev_tunnel.v1.Data"> & { + /** + * @generated from field: uint64 conn_id = 1; + */ + connId: bigint; + + /** + * @generated from field: bytes payload = 2; + */ + payload: Uint8Array; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Data. + * Use `create(DataSchema)` to create a new message. + */ +export declare const DataSchema: GenMessage; + +/** + * The sender has nothing more to send (TCP half-close). A conn is over once + * both sides have closed, or either has reset. + * + * @generated from message vercel.api.dev_tunnel.v1.Close + */ +export declare type Close = Message<"vercel.api.dev_tunnel.v1.Close"> & { + /** + * @generated from field: uint64 conn_id = 1; + */ + connId: bigint; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Close. + * Use `create(CloseSchema)` to create a new message. + */ +export declare const CloseSchema: GenMessage; + +/** + * Abandons the conn in both directions, e.g. connection refused. + * + * @generated from message vercel.api.dev_tunnel.v1.Reset + */ +export declare type Reset = Message<"vercel.api.dev_tunnel.v1.Reset"> & { + /** + * @generated from field: uint64 conn_id = 1; + */ + connId: bigint; + + /** + * @generated from field: string reason = 2; + */ + reason: string; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Reset. + * Use `create(ResetSchema)` to create a new message. + */ +export declare const ResetSchema: GenMessage; + +/** + * Lets the peer send `bytes` more of a conn's Data, as the sender of this + * consumes what it received. Each side starts each conn able to send 256 KiB. + * A client that sends past its window, or grants back more than it was sent, + * has the conn reset. + * + * @generated from message vercel.api.dev_tunnel.v1.Window + */ +export declare type Window = Message<"vercel.api.dev_tunnel.v1.Window"> & { + /** + * @generated from field: uint64 conn_id = 1; + */ + connId: bigint; + + /** + * @generated from field: uint32 bytes = 2; + */ + bytes: number; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Window. + * Use `create(WindowSchema)` to create a new message. + */ +export declare const WindowSchema: GenMessage; + +/** + * Sent both ways every 30s, inside the ALB's idle timeout. The service drops a + * client it hasn't heard from in three. The service also sends one as soon as + * a Connect stream holds the tunnel, so the CLI knows it's live. + * + * @generated from message vercel.api.dev_tunnel.v1.Heartbeat + */ +export declare type Heartbeat = Message<"vercel.api.dev_tunnel.v1.Heartbeat"> & { +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Heartbeat. + * Use `create(HeartbeatSchema)` to create a new message. + */ +export declare const HeartbeatSchema: GenMessage; + +/** + * This pod is shutting down. The CLI opens a new stream now, and keeps this one + * until the service ends it: it still carries the conns already open on it. + * + * @generated from message vercel.api.dev_tunnel.v1.Reconnect + */ +export declare type Reconnect = Message<"vercel.api.dev_tunnel.v1.Reconnect"> & { +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.Reconnect. + * Use `create(ReconnectSchema)` to create a new message. + */ +export declare const ReconnectSchema: GenMessage; + +/** + * @generated from message vercel.api.dev_tunnel.v1.DialRequest + */ +export declare type DialRequest = Message<"vercel.api.dev_tunnel.v1.DialRequest"> & { + /** + * @generated from oneof vercel.api.dev_tunnel.v1.DialRequest.frame + */ + frame: { + /** + * First message only. + * + * @generated from field: vercel.api.dev_tunnel.v1.DialTarget target = 1; + */ + value: DialTarget; + case: "target"; + } | { + /** + * @generated from field: bytes payload = 2; + */ + value: Uint8Array; + case: "payload"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialRequest. + * Use `create(DialRequestSchema)` to create a new message. + */ +export declare const DialRequestSchema: GenMessage; + +/** + * @generated from message vercel.api.dev_tunnel.v1.DialTarget + */ +export declare type DialTarget = Message<"vercel.api.dev_tunnel.v1.DialTarget"> & { + /** + * @generated from field: vercel.api.dev_tunnel.v1.Protocol protocol = 1; + */ + protocol: Protocol; + + /** + * @generated from field: uint32 port = 2; + */ + port: number; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialTarget. + * Use `create(DialTargetSchema)` to create a new message. + */ +export declare const DialTargetSchema: GenMessage; + +/** + * @generated from message vercel.api.dev_tunnel.v1.DialResponse + */ +export declare type DialResponse = Message<"vercel.api.dev_tunnel.v1.DialResponse"> & { + /** + * @generated from oneof vercel.api.dev_tunnel.v1.DialResponse.frame + */ + frame: { + /** + * @generated from field: bytes payload = 1; + */ + value: Uint8Array; + case: "payload"; + } | { + /** + * The developer's side has nothing more to send. Sent once; more payload + * may still go the other way. + * + * @generated from field: vercel.api.dev_tunnel.v1.DialEnd end = 2; + */ + value: DialEnd; + case: "end"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialResponse. + * Use `create(DialResponseSchema)` to create a new message. + */ +export declare const DialResponseSchema: GenMessage; + +/** + * @generated from message vercel.api.dev_tunnel.v1.DialEnd + */ +export declare type DialEnd = Message<"vercel.api.dev_tunnel.v1.DialEnd"> & { +}; + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialEnd. + * Use `create(DialEndSchema)` to create a new message. + */ +export declare const DialEndSchema: GenMessage; + +/** + * @generated from enum vercel.api.dev_tunnel.v1.Protocol + */ +export enum Protocol { + /** + * @generated from enum value: PROTOCOL_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: PROTOCOL_TCP = 1; + */ + TCP = 1, +} + +/** + * Describes the enum vercel.api.dev_tunnel.v1.Protocol. + */ +export declare const ProtocolSchema: GenEnum; + +/** + * Relays TCP connections between the tunnel's deployment and the developer's + * machine. Payloads are opaque bytes; nothing here interprets them. + * + * @generated from service vercel.api.dev_tunnel.v1.TunnelService + */ +export declare const TunnelService: GenService<{ + /** + * The stream `vc dev --tunnel` holds open, carrying every conn the + * deployment dials and every conn the client opens through the deployment. + * Authenticated with the client token + * (`authorization: Bearer`), which the next create-session replaces: a CLI + * refused with UNAUTHENTICATED on a reconnect asks for a new one. + * + * One client per tunnel: another stream fails with ALREADY_EXISTS, unless it + * carries the same `x-vercel-dev-tunnel-instance` (1 to 64 letters, digits + * or dashes, one per CLI process). Then it replaces the old stream, which + * ends with ABORTED, so a CLI can reconnect while its old stream is still + * open on the service. The CLI sends a Heartbeat at least every 30s; a + * stream silent for three ends with DEADLINE_EXCEEDED, and one ending with + * UNAVAILABLE means reconnect. A CLI that stops reading the stream while + * the service has frames for it has it end with RESOURCE_EXHAUSTED. + * + * A pod shutting down sends Reconnect and refuses new streams with + * UNAVAILABLE. Once the CLI's new stream holds the tunnel elsewhere, the old + * one gets no new conns and ends with ABORTED when its open ones are done. + * + * @generated from rpc vercel.api.dev_tunnel.v1.TunnelService.Connect + */ + connect: { + methodKind: "bidi_streaming"; + input: typeof ConnectRequestSchema; + output: typeof ConnectResponseSchema; + }, + /** + * One conn from the deployment: a TCP connection to a port on the + * developer's machine. The first message is the target; the rest carry + * payload. Ending the request stream half-closes the conn, and `end` says the + * developer's side has; the call ends once both have, and fails if either + * side resets. With no client connected, it waits up to 5s for one, then + * fails with UNAVAILABLE. A tunnel carries at most 256 conns at once; more + * wait up to 30s for one to end, then fail with RESOURCE_EXHAUSTED, as do + * any past 256 waiting. Authenticated with the server token + * (`authorization: Bearer`). + * + * @generated from rpc vercel.api.dev_tunnel.v1.TunnelService.Dial + */ + dial: { + methodKind: "bidi_streaming"; + input: typeof DialRequestSchema; + output: typeof DialResponseSchema; + }, +}>; + diff --git a/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.js b/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.js new file mode 100644 index 0000000..385350b --- /dev/null +++ b/api/ts/vercel/api/dev_tunnel/v1/tunnel_pb.js @@ -0,0 +1,127 @@ +// Vendored from vercel/api, services/api-dev-tunnel/proto/tunnel/v1/tunnel.proto. +// Edit it there and copy it here. + +// @generated by protoc-gen-es v2.16.0 with parameter "target=js+dts" +// @generated from file vercel/api/dev_tunnel/v1/tunnel.proto (package vercel.api.dev_tunnel.v1, syntax proto3) +/* eslint-disable */ + +import { enumDesc, fileDesc, messageDesc, serviceDesc, tsEnum } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file vercel/api/dev_tunnel/v1/tunnel.proto. + */ +export const file_vercel_api_dev_tunnel_v1_tunnel = /*@__PURE__*/ + fileDesc("CiV2ZXJjZWwvYXBpL2Rldl90dW5uZWwvdjEvdHVubmVsLnByb3RvEhh2ZXJjZWwuYXBpLmRldl90dW5uZWwudjEiywIKDkNvbm5lY3RSZXF1ZXN0Ei4KBGRhdGEYASABKAsyHi52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuRGF0YUgAEjAKBWNsb3NlGAIgASgLMh8udmVyY2VsLmFwaS5kZXZfdHVubmVsLnYxLkNsb3NlSAASMAoFcmVzZXQYAyABKAsyHy52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuUmVzZXRIABI4CgloZWFydGJlYXQYBCABKAsyIy52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuSGVhcnRiZWF0SAASMgoGd2luZG93GAUgASgLMiAudmVyY2VsLmFwaS5kZXZfdHVubmVsLnYxLldpbmRvd0gAEi4KBG9wZW4YBiABKAsyHi52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuT3BlbkgAQgcKBWZyYW1lIoYDCg9Db25uZWN0UmVzcG9uc2USLgoEb3BlbhgBIAEoCzIeLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5PcGVuSAASLgoEZGF0YRgCIAEoCzIeLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5EYXRhSAASMAoFY2xvc2UYAyABKAsyHy52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuQ2xvc2VIABIwCgVyZXNldBgEIAEoCzIfLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5SZXNldEgAEjgKCWhlYXJ0YmVhdBgFIAEoCzIjLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5IZWFydGJlYXRIABIyCgZ3aW5kb3cYBiABKAsyIC52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuV2luZG93SAASOAoJcmVjb25uZWN0GAcgASgLMiMudmVyY2VsLmFwaS5kZXZfdHVubmVsLnYxLlJlY29ubmVjdEgAQgcKBWZyYW1lImkKBE9wZW4SDwoHY29ubl9pZBgBIAEoBBI0Cghwcm90b2NvbBgCIAEoDjIiLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5Qcm90b2NvbBIMCgRwb3J0GAMgASgNEgwKBGhvc3QYBCABKAkiKAoERGF0YRIPCgdjb25uX2lkGAEgASgEEg8KB3BheWxvYWQYAiABKAwiGAoFQ2xvc2USDwoHY29ubl9pZBgBIAEoBCIoCgVSZXNldBIPCgdjb25uX2lkGAEgASgEEg4KBnJlYXNvbhgCIAEoCSIoCgZXaW5kb3cSDwoHY29ubl9pZBgBIAEoBBINCgVieXRlcxgCIAEoDSILCglIZWFydGJlYXQiCwoJUmVjb25uZWN0ImEKC0RpYWxSZXF1ZXN0EjYKBnRhcmdldBgBIAEoCzIkLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5EaWFsVGFyZ2V0SAASEQoHcGF5bG9hZBgCIAEoDEgAQgcKBWZyYW1lIlAKCkRpYWxUYXJnZXQSNAoIcHJvdG9jb2wYASABKA4yIi52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuUHJvdG9jb2wSDAoEcG9ydBgCIAEoDSJcCgxEaWFsUmVzcG9uc2USEQoHcGF5bG9hZBgBIAEoDEgAEjAKA2VuZBgCIAEoCzIhLnZlcmNlbC5hcGkuZGV2X3R1bm5lbC52MS5EaWFsRW5kSABCBwoFZnJhbWUiCQoHRGlhbEVuZCo2CghQcm90b2NvbBIYChRQUk9UT0NPTF9VTlNQRUNJRklFRBAAEhAKDFBST1RPQ09MX1RDUBABMs4BCg1UdW5uZWxTZXJ2aWNlEmIKB0Nvbm5lY3QSKC52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuQ29ubmVjdFJlcXVlc3QaKS52ZXJjZWwuYXBpLmRldl90dW5uZWwudjEuQ29ubmVjdFJlc3BvbnNlKAEwARJZCgREaWFsEiUudmVyY2VsLmFwaS5kZXZfdHVubmVsLnYxLkRpYWxSZXF1ZXN0GiYudmVyY2VsLmFwaS5kZXZfdHVubmVsLnYxLkRpYWxSZXNwb25zZSgBMAFC7gEKHGNvbS52ZXJjZWwuYXBpLmRldl90dW5uZWwudjFCC1R1bm5lbFByb3RvUAFaQmdpdGh1Yi5jb20vdmVyY2VsL2JyaWRnZS9hcGkvZ28vdmVyY2VsL2FwaS9kZXZfdHVubmVsL3YxO2RldnR1bm5lbKICA1ZBRKoCF1ZlcmNlbC5BcGkuRGV2VHVubmVsLlYxygIXVmVyY2VsXEFwaVxEZXZUdW5uZWxcVjHiAiNWZXJjZWxcQXBpXERldlR1bm5lbFxWMVxHUEJNZXRhZGF0YeoCGlZlcmNlbDo6QXBpOjpEZXZUdW5uZWw6OlYxYgZwcm90bzM"); + +/** + * Describes the message vercel.api.dev_tunnel.v1.ConnectRequest. + * Use `create(ConnectRequestSchema)` to create a new message. + */ +export const ConnectRequestSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 0); + +/** + * Describes the message vercel.api.dev_tunnel.v1.ConnectResponse. + * Use `create(ConnectResponseSchema)` to create a new message. + */ +export const ConnectResponseSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 1); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Open. + * Use `create(OpenSchema)` to create a new message. + */ +export const OpenSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 2); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Data. + * Use `create(DataSchema)` to create a new message. + */ +export const DataSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 3); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Close. + * Use `create(CloseSchema)` to create a new message. + */ +export const CloseSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 4); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Reset. + * Use `create(ResetSchema)` to create a new message. + */ +export const ResetSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 5); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Window. + * Use `create(WindowSchema)` to create a new message. + */ +export const WindowSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 6); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Heartbeat. + * Use `create(HeartbeatSchema)` to create a new message. + */ +export const HeartbeatSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 7); + +/** + * Describes the message vercel.api.dev_tunnel.v1.Reconnect. + * Use `create(ReconnectSchema)` to create a new message. + */ +export const ReconnectSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 8); + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialRequest. + * Use `create(DialRequestSchema)` to create a new message. + */ +export const DialRequestSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 9); + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialTarget. + * Use `create(DialTargetSchema)` to create a new message. + */ +export const DialTargetSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 10); + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialResponse. + * Use `create(DialResponseSchema)` to create a new message. + */ +export const DialResponseSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 11); + +/** + * Describes the message vercel.api.dev_tunnel.v1.DialEnd. + * Use `create(DialEndSchema)` to create a new message. + */ +export const DialEndSchema = /*@__PURE__*/ + messageDesc(file_vercel_api_dev_tunnel_v1_tunnel, 12); + +/** + * Describes the enum vercel.api.dev_tunnel.v1.Protocol. + */ +export const ProtocolSchema = /*@__PURE__*/ + enumDesc(file_vercel_api_dev_tunnel_v1_tunnel, 0); + +/** + * @generated from enum vercel.api.dev_tunnel.v1.Protocol + */ +export const Protocol = /*@__PURE__*/ + tsEnum(ProtocolSchema); + +/** + * Relays TCP connections between the tunnel's deployment and the developer's + * machine. Payloads are opaque bytes; nothing here interprets them. + * + * @generated from service vercel.api.dev_tunnel.v1.TunnelService + */ +export const TunnelService = /*@__PURE__*/ + serviceDesc(file_vercel_api_dev_tunnel_v1_tunnel, 0); + diff --git a/buf.gen.yaml b/buf.gen.yaml index f6dc66f..0f9730d 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -4,6 +4,10 @@ managed: override: - file_option: go_package_prefix value: github.com/vercel/bridge/api/go + # Vendored from vercel/api, which names it vercel.api.dev_tunnel.v1. + - file_option: go_package + path: vercel/api/dev_tunnel/v1/tunnel.proto + value: github.com/vercel/bridge/api/go/vercel/api/dev_tunnel/v1;devtunnel disable: - file_option: go_package_prefix module: buf.build/bufbuild/protovalidate diff --git a/pkg/commands/dev_test.go b/pkg/commands/dev_test.go index 5768556..92eb992 100644 --- a/pkg/commands/dev_test.go +++ b/pkg/commands/dev_test.go @@ -37,6 +37,10 @@ func (f *fakeInterceptor) Probe(_ context.Context, _ *bridgev1.ProbeRequest, _ . return v.(*bridgev1.ProbeResponse), nil } +func (f *fakeInterceptor) SetTunnelToken(_ context.Context, _ *bridgev1.SetTunnelTokenRequest, _ ...grpc.CallOption) (*bridgev1.SetTunnelTokenResponse, error) { + return &bridgev1.SetTunnelTokenResponse{}, nil +} + func passing() *bridgev1.ProbeCheckResult { return &bridgev1.ProbeCheckResult{Passed: true} } func failing() *bridgev1.ProbeCheckResult { return &bridgev1.ProbeCheckResult{Passed: false, Error: "boom"} } diff --git a/pkg/commands/intercept.go b/pkg/commands/intercept.go index 7d44a04..adb3740 100644 --- a/pkg/commands/intercept.go +++ b/pkg/commands/intercept.go @@ -4,17 +4,20 @@ import ( "context" "fmt" "log/slog" + "net" "os" "os/exec" "os/signal" "os/user" "strings" "syscall" + "time" "github.com/hanwen/go-fuse/v2/fuse" "github.com/urfave/cli/v3" bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" "github.com/vercel/bridge/pkg/conntrack" + "github.com/vercel/bridge/pkg/devtunnel" bridgedns "github.com/vercel/bridge/pkg/dns" "github.com/vercel/bridge/pkg/fsmount" "github.com/vercel/bridge/pkg/grpcutil" @@ -24,6 +27,13 @@ import ( "github.com/vercel/bridge/pkg/plumbing" "github.com/vercel/bridge/pkg/probe" "github.com/vercel/bridge/pkg/tunnel" + "github.com/vercel/bridge/pkg/tunnel/bridgestream" + "google.golang.org/grpc" +) + +const ( + tunnelProtocolBridge = "bridge" + tunnelProtocolDevTunnel = "dev-tunnel" ) func Intercept() *cli.Command { @@ -34,7 +44,7 @@ func Intercept() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "server-addr", - Usage: "Address of the bridge proxy server (e.g. localhost:9090 or k8spf:///pod.ns:9090)", + Usage: "Address of the bridge proxy server (e.g. localhost:9090 or k8spf:///pod.ns:9090), or the relay's URL with --tunnel-protocol=dev-tunnel", Sources: cli.EnvVars("BRIDGE_SERVER_ADDR"), Required: true, }, @@ -75,6 +85,22 @@ func Intercept() *cli.Command { Value: ":8080", Sources: cli.EnvVars(meta.EnvInterceptorAddr), }, + &cli.StringFlag{ + Name: "tunnel-protocol", + Usage: `What --server-addr speaks: "bridge" for a bridge proxy server, or "dev-tunnel" for api-dev-tunnel, the relay ` + "`vc dev --tunnel`" + ` uses`, + Value: tunnelProtocolBridge, + Sources: cli.EnvVars("BRIDGE_TUNNEL_PROTOCOL"), + }, + &cli.StringFlag{ + Name: "tunnel-token", + Usage: "The dev tunnel's client token, from create-session. Without one the intercept waits for InterceptorService.SetTunnelToken", + Sources: cli.EnvVars("BRIDGE_TUNNEL_TOKEN"), + }, + &cli.StringFlag{ + Name: "tunnel-instance", + Usage: "The dev tunnel client's instance, 1 to 64 letters, digits or dashes. A restarted intercept with the same one takes the tunnel back at once", + Sources: cli.EnvVars("BRIDGE_TUNNEL_INSTANCE"), + }, }, Action: runIntercept, } @@ -93,7 +119,11 @@ func doIntercept(ctx context.Context, c *cli.Command) error { proxyPort := c.Int("proxy-port") appPort := c.Int("app-port") dnsPort := c.Int("dns-port") - logger := slog.With("server_addr", serverAddr) + tunnelProtocol := c.String("tunnel-protocol") + logger := slog.With("server_addr", serverAddr, "tunnel_protocol", tunnelProtocol) + if tunnelProtocol != tunnelProtocolBridge && tunnelProtocol != tunnelProtocolDevTunnel { + return fmt.Errorf("unknown --tunnel-protocol %q: use %q or %q", tunnelProtocol, tunnelProtocolBridge, tunnelProtocolDevTunnel) + } // Parse forward-domains, handling comma-separated values from env vars var forwardDomains []string @@ -151,59 +181,52 @@ func doIntercept(ctx context.Context, c *cli.Command) error { logger.Info("No forward domains configured, DNS interception disabled") } - // Connect to the bridge proxy server via gRPC - builder := k8spf.NewBuilder(k8spf.BuilderConfig{}) - conn, err := grpcutil.NewClient(serverAddr, builder.DialOptions()...) - if err != nil { - return fmt.Errorf("failed to connect to bridge proxy: %w", err) - } - defer conn.Close() - client := bridgev1.NewBridgeProxyServiceClient(conn) - - // Fetch metadata from the proxy pod (env vars, CA cert/key). - metadata, err := client.GetMetadata(ctx, &bridgev1.GetMetadataRequest{}) - if err != nil { - slog.Warn("Failed to get metadata from proxy", "error", err) - } + // Read before /etc/resolv.conf points at the intercept's own DNS. + originalNS := readOriginalNameserver() - // Install the bridge CA certificate into the system trust store so that - // TLS connections to mocked services are trusted by the application. - if metadata != nil && len(metadata.CaCert) > 0 { - if err := installCACert(ctx, metadata.CaCert); err != nil { - slog.Warn("Failed to install CA certificate", "error", err) - } else { - slog.Info("Installed bridge CA certificate") + var ( + tun tunnel.Tunnel + dnsResolver hostResolver + metadata *bridgev1.GetMetadataResponse + mountServers []*fuseServer + devTunnel *devtunnel.Stream + ) + if tunnelProtocol == tunnelProtocolDevTunnel { + if len(mountPaths) > 0 { + logger.Warn("Ignoring mount paths: a dev tunnel has no file system to mount", "paths", mountPaths) } + var err error + devTunnel, err = devtunnel.New(devtunnel.Config{ + RelayURL: serverAddr, + Token: c.String("tunnel-token"), + Instance: c.String("tunnel-instance"), + Port: appPort, + RelayDialer: relayDialer(originalNS), + Logger: logger, + }) + if err != nil { + return err + } + devTunnelCtx, stopDevTunnel := context.WithCancel(ctx) + defer stopDevTunnel() + devTunnel.Start(devTunnelCtx) + tun = tunnel.New(plumbing.NewStaticPortDialer(appPort, nil), devTunnel) + tun.Start(ctx) + defer tun.Close() + dnsResolver = deploymentResolver{} + logger.Info("Dev tunnel starting", "app_port", appPort) + } else { + bridge, err := connectBridge(ctx, serverAddr, appPort, mountPaths) + if err != nil { + return err + } + defer bridge.conn.Close() + tun = bridge.tunnel + dnsResolver = &grpcDNSResolver{client: bridge.client} + metadata = bridge.metadata + mountServers = bridge.mounts } - // If no explicit mount paths were provided, use whatever the server - // advertises via GetMetadata. - if len(mountPaths) == 0 && metadata != nil { - mountPaths = append(mountPaths, metadata.GetMountRoots()...) - } - - // Set up FUSE mounts for the proxy pod's volume paths. Each mount uses - // the same gRPC connection that hosts the proxy service, so it doesn't - // require the tunnel. Failures are non-fatal: log and continue so the - // developer still gets network interception. - var mountServers []*fuseServer - if len(mountPaths) > 0 { - fsClient := bridgev1.NewBridgeFileSystemServiceClient(conn) - mountServers = startMounts(ctx, fsClient, mountPaths) - } - - // Open the shared tunnel stream. If app-port is set, ingress traffic from - // the server's --listen-ports will be forwarded to that local port. - stream, err := client.TunnelNetwork(ctx) - if err != nil { - return fmt.Errorf("failed to open tunnel stream: %w", err) - } - - dialer := plumbing.NewStaticPortDialer(appPort, nil) - tun := tunnel.New(dialer, stream) - tun.Start(ctx) - slog.Info("Tunnel connected", "app_port", appPort) - // Create connection tracking registry pool, err := ippool.New(proxyCIDR) if err != nil { @@ -215,9 +238,7 @@ func doIntercept(ctx context.Context, c *cli.Command) error { var dns *DNSComponent var originalResolvConf []byte if len(forwardDomains) > 0 { - // Read the original nameserver before we modify /etc/resolv.conf - originalNS := readOriginalNameserver() - exchangeClient := bridgedns.NewTunnelExchangeClient(forwardDomains, &grpcDNSResolver{client: client}, originalNS) + exchangeClient := bridgedns.NewTunnelExchangeClient(forwardDomains, dnsResolver, originalNS) dns, err = StartDNS(DNSConfig{ ListenPort: dnsPort, Client: exchangeClient, @@ -289,6 +310,9 @@ func doIntercept(ctx context.Context, c *cli.Command) error { return err } defer interceptSrv.Stop() + if devTunnel != nil { + interceptSrv.SetTunnel(devTunnel) + } // Spin up probe monitors using the source deployment's probes. Each // monitor runs in its own goroutine and is cancelled when ctx is done. @@ -329,6 +353,74 @@ func doIntercept(ctx context.Context, c *cli.Command) error { return nil } +// bridgeConn is the intercept's connection to a bridge proxy server. +type bridgeConn struct { + conn *grpc.ClientConn + client bridgev1.BridgeProxyServiceClient + metadata *bridgev1.GetMetadataResponse + mounts []*fuseServer + tunnel tunnel.Tunnel +} + +// connectBridge connects to a bridge proxy server, installs its CA, mounts +// its paths and opens the tunnel stream to it. +func connectBridge(ctx context.Context, serverAddr string, appPort int, mountPaths []string) (*bridgeConn, error) { + // Connect to the bridge proxy server via gRPC + builder := k8spf.NewBuilder(k8spf.BuilderConfig{}) + conn, err := grpcutil.NewClient(serverAddr, builder.DialOptions()...) + if err != nil { + return nil, fmt.Errorf("failed to connect to bridge proxy: %w", err) + } + client := bridgev1.NewBridgeProxyServiceClient(conn) + + // Fetch metadata from the proxy pod (env vars, CA cert/key). + metadata, err := client.GetMetadata(ctx, &bridgev1.GetMetadataRequest{}) + if err != nil { + slog.Warn("Failed to get metadata from proxy", "error", err) + } + + // Install the bridge CA certificate into the system trust store so that + // TLS connections to mocked services are trusted by the application. + if metadata != nil && len(metadata.CaCert) > 0 { + if err := installCACert(ctx, metadata.CaCert); err != nil { + slog.Warn("Failed to install CA certificate", "error", err) + } else { + slog.Info("Installed bridge CA certificate") + } + } + + // If no explicit mount paths were provided, use whatever the server + // advertises via GetMetadata. + if len(mountPaths) == 0 && metadata != nil { + mountPaths = append(mountPaths, metadata.GetMountRoots()...) + } + + // Set up FUSE mounts for the proxy pod's volume paths. Each mount uses + // the same gRPC connection that hosts the proxy service, so it doesn't + // require the tunnel. Failures are non-fatal: log and continue so the + // developer still gets network interception. + var mountServers []*fuseServer + if len(mountPaths) > 0 { + fsClient := bridgev1.NewBridgeFileSystemServiceClient(conn) + mountServers = startMounts(ctx, fsClient, mountPaths) + } + + // Open the shared tunnel stream. If app-port is set, ingress traffic from + // the server's --listen-ports will be forwarded to that local port. + stream, err := client.TunnelNetwork(ctx) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed to open tunnel stream: %w", err) + } + + dialer := plumbing.NewStaticPortDialer(appPort, nil) + tun := tunnel.New(dialer, bridgestream.New(stream)) + tun.Start(ctx) + slog.Info("Tunnel connected", "app_port", appPort) + + return &bridgeConn{conn: conn, client: client, metadata: metadata, mounts: mountServers, tunnel: tun}, nil +} + // installCACert writes the PEM-encoded CA certificate to the system trust // store and runs the appropriate update command. // Supports Debian/Ubuntu (update-ca-certificates) and RHEL/Alpine (update-ca-trust). @@ -432,6 +524,36 @@ func restoreResolvConf(original []byte) { } } +// hostResolver resolves the hostnames the intercept's DNS forwards. +type hostResolver interface { + ResolveDNS(ctx context.Context, hostname string) (*bridgedns.DNSResolveResult, error) +} + +// deploymentResolver answers for every forwarded hostname without resolving +// it: the deployment resolves it when it opens the conn, as its own code +// would. The address is a placeholder the conn never uses. +type deploymentResolver struct{} + +func (deploymentResolver) ResolveDNS(context.Context, string) (*bridgedns.DNSResolveResult, error) { + return &bridgedns.DNSResolveResult{Addresses: []string{"0.0.0.0"}}, nil +} + +// relayDialer reaches the relay without the intercept's own DNS, which would +// send the relay's hostname down the tunnel when every domain is forwarded. +// It asks nameserver over TCP, which the UDP :53 redirect leaves alone. +func relayDialer(nameserver string) *net.Dialer { + return &net.Dialer{ + Timeout: 10 * time.Second, + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", nameserver) + }, + }, + } +} + // grpcDNSResolver wraps a BridgeProxyServiceClient to satisfy the DNS resolver // interface used by TunnelExchangeClient. type grpcDNSResolver struct { @@ -516,4 +638,3 @@ func startMounts(ctx context.Context, client bridgev1.BridgeFileSystemServiceCli } return servers } - diff --git a/pkg/commands/intercept_server.go b/pkg/commands/intercept_server.go index 2784524..1157399 100644 --- a/pkg/commands/intercept_server.go +++ b/pkg/commands/intercept_server.go @@ -10,10 +10,13 @@ import ( "time" bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/pkg/devtunnel" "github.com/vercel/bridge/pkg/grpcutil" "github.com/vercel/bridge/pkg/probe" "google.golang.org/grpc" + "google.golang.org/grpc/codes" healthpb "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/status" ) // interceptServer exposes a gRPC server for the intercept process with a @@ -32,6 +35,7 @@ type interceptServer struct { ready atomic.Bool monitors atomic.Pointer[monitorSet] + tunnel atomic.Pointer[devtunnel.Stream] } // monitorSet is the immutable bundle stored in interceptServer.monitors. @@ -74,6 +78,12 @@ func (s *interceptServer) SetMonitors(liveness, readiness, startup probe.Monitor }) } +// SetTunnel publishes the dev tunnel GetStatus reports on and SetTunnelToken +// hands tokens to. +func (s *interceptServer) SetTunnel(t *devtunnel.Stream) { + s.tunnel.Store(t) +} + // Stop gracefully stops the gRPC server. func (s *interceptServer) Stop() { s.server.GracefulStop() @@ -107,9 +117,25 @@ func (s *interceptServer) GetStatus(_ context.Context, _ *bridgev1.GetStatusRequ resp.Startup = ms.startup.Status() } } + if t := s.tunnel.Load(); t != nil { + resp.Tunnel = t.Status() + } return resp, nil } +// SetTunnelToken hands the dev tunnel a client token. +func (s *interceptServer) SetTunnelToken(_ context.Context, req *bridgev1.SetTunnelTokenRequest) (*bridgev1.SetTunnelTokenResponse, error) { + t := s.tunnel.Load() + if t == nil { + return nil, status.Error(codes.FailedPrecondition, "this intercept isn't on a dev tunnel") + } + if req.GetToken() == "" { + return nil, status.Error(codes.InvalidArgument, "token is empty") + } + t.SetToken(req.GetToken()) + return &bridgev1.SetTunnelTokenResponse{}, nil +} + // Probe runs each configured probe synchronously and returns the per-probe // pass/fail of those single checks. Bypasses the threshold-based smoothing // surfaced by GetStatus — useful as an authoritative "is the app diff --git a/pkg/commands/intercept_server_test.go b/pkg/commands/intercept_server_test.go index 16f896a..c4f4c91 100644 --- a/pkg/commands/intercept_server_test.go +++ b/pkg/commands/intercept_server_test.go @@ -9,7 +9,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/pkg/devtunnel" "github.com/vercel/bridge/pkg/probe" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // newTestInterceptServer binds the interceptServer to a free local port and @@ -128,3 +131,26 @@ func TestInterceptServer_AllThreeMonitorsRouteIndependently(t *testing.T) { time.Sleep(20 * time.Millisecond) } } + +func TestInterceptServer_ReportsAndFeedsTheDevTunnel(t *testing.T) { + srv := newTestInterceptServer(t) + ctx := context.Background() + + _, err := srv.SetTunnelToken(ctx, &bridgev1.SetTunnelTokenRequest{Token: "tok"}) + assert.Equal(t, codes.FailedPrecondition, status.Code(err)) + resp, err := srv.GetStatus(ctx, &bridgev1.GetStatusRequest{}) + require.NoError(t, err) + assert.Nil(t, resp.GetTunnel()) + + tun, err := devtunnel.New(devtunnel.Config{RelayURL: "http://127.0.0.1:1", Port: 3000}) + require.NoError(t, err) + srv.SetTunnel(tun) + + resp, err = srv.GetStatus(ctx, &bridgev1.GetStatusRequest{}) + require.NoError(t, err) + assert.Equal(t, bridgev1.TunnelState_TUNNEL_STATE_CONNECTING, resp.GetTunnel().GetState()) + _, err = srv.SetTunnelToken(ctx, &bridgev1.SetTunnelTokenRequest{}) + assert.Equal(t, codes.InvalidArgument, status.Code(err)) + _, err = srv.SetTunnelToken(ctx, &bridgev1.SetTunnelTokenRequest{Token: "tok"}) + assert.NoError(t, err) +} diff --git a/pkg/devtunnel/call.go b/pkg/devtunnel/call.go new file mode 100644 index 0000000..0c57cc9 --- /dev/null +++ b/pkg/devtunnel/call.go @@ -0,0 +1,263 @@ +package devtunnel + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/puzpuzpuz/xsync/v3" + "github.com/vercel/bridge/api/go/vercel/api/dev_tunnel/v1" + "github.com/vercel/bridge/pkg/tunnel" + "google.golang.org/grpc/metadata" +) + +var heartbeat = &devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Heartbeat{Heartbeat: &devtunnel.Heartbeat{}}} + +// call is one Connect call. The relay ends every conn on it with it. +type call struct { + d *Stream + logger *slog.Logger + // The relay numbers each call's conns from 1, so the tunnel's id for one + // starts with this. + prefix string + ctx context.Context + cancel context.CancelCauseFunc + out outbox + // The tunnel's id for each conn on the call, by its id here. + ids *xsync.MapOf[uint64, string] + + // Set once the relay routes the tunnel's conns to this call. + held atomic.Bool + lastHeard atomic.Int64 + closed atomic.Bool +} + +func (d *Stream) newCall() *call { + ctx, cancel := context.WithCancelCause(d.ctx) + return &call{ + d: d, + logger: d.logger, + prefix: strconv.FormatUint(d.nextCall.Add(1), 10) + ".", + ctx: ctx, + cancel: cancel, + out: outbox{wake: make(chan struct{}, 1)}, + ids: xsync.NewMapOf[uint64, string](), + } +} + +// run holds the call until the relay ends it. moving is called if the relay +// says to reconnect elsewhere; the call keeps its conns until then. +func (c *call) run(token string, moving func()) error { + defer c.cancel(nil) + ctx := metadata.AppendToOutgoingContext(c.ctx, + "authorization", "Bearer "+token, + instanceHeader, c.d.instance, + ) + stream, err := c.d.rpc.Connect(ctx) + if err != nil { + return err + } + defer c.d.lost(c) + defer c.endConns() + + c.lastHeard.Store(time.Now().UnixNano()) + // The call starts with its first message. + c.send(heartbeat) + go func() { + for { + frame, ok := c.out.pop(c.ctx) + if !ok { + return + } + // A failed send ends the call, which Recv reports. + if err := stream.Send(frame); err != nil { + return + } + } + }() + go c.heartbeats() + + for { + msg, err := stream.Recv() + if err != nil { + if cause := context.Cause(c.ctx); errors.Is(cause, errSilent) { + return cause + } + if errors.Is(err, io.EOF) { + return nil + } + return err + } + c.lastHeard.Store(time.Now().UnixNano()) + c.handle(msg, moving) + } +} + +func (c *call) heartbeats() { + ticker := time.NewTicker(c.d.timings.heartbeat) + defer ticker.Stop() + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + if time.Since(time.Unix(0, c.lastHeard.Load())) > c.d.timings.silent { + c.logger.Info("Relay went silent, reconnecting") + c.cancel(errSilent) + return + } + c.send(heartbeat) + } + } +} + +// handle hands one of the relay's frames to the tunnel as a Message. +func (c *call) handle(msg *devtunnel.ConnectResponse, moving func()) { + d := c.d + switch f := msg.GetFrame().(type) { + case *devtunnel.ConnectResponse_Heartbeat: + if c.held.CompareAndSwap(false, true) { + d.holding(c) + } + case *devtunnel.ConnectResponse_Reconnect: + d.moving(c) + moving() + case *devtunnel.ConnectResponse_Open: + c.open(f.Open) + case *devtunnel.ConnectResponse_Data: + if id, _, ok := c.route(f.Data.GetConnId()); ok { + d.bytesReceived.Add(int64(len(f.Data.GetPayload()))) + d.deliver(&tunnel.Message{Kind: tunnel.MessageData, ConnID: id, Payload: f.Data.GetPayload()}) + } + case *devtunnel.ConnectResponse_Close: + if id, r, ok := c.route(f.Close.GetConnId()); ok { + r.peerClosed.Store(true) + d.deliver(&tunnel.Message{Kind: tunnel.MessageCloseWrite, ConnID: id}) + if r.closeSent.Load() { + d.forget(id, r) + } + } + case *devtunnel.ConnectResponse_Reset_: + if id, r, ok := c.route(f.Reset_.GetConnId()); ok && d.forget(id, r) { + c.logger.Debug("Relay reset the connection", "conn_id", id, "reason", f.Reset_.GetReason()) + d.deliver(&tunnel.Message{Kind: tunnel.MessageReset, ConnID: id, Reason: f.Reset_.GetReason()}) + } + case *devtunnel.ConnectResponse_Window: + if id, _, ok := c.route(f.Window.GetConnId()); ok { + d.deliver(&tunnel.Message{Kind: tunnel.MessageWindow, ConnID: id, Bytes: int(f.Window.GetBytes())}) + } + } +} + +// open hands one of the deployment's conns to the tunnel, which connects it +// to the app. +func (c *call) open(open *devtunnel.Open) { + wire := open.GetConnId() + port := c.d.cfg.Port + if open.GetProtocol() != devtunnel.Protocol_PROTOCOL_TCP || int(open.GetPort()) != port { + c.send(resetFrame(wire, fmt.Sprintf("bridge serves only port %d", port))) + return + } + id := c.prefix + strconv.FormatUint(wire, 10) + if !c.track(id, &route{c: c, wire: wire, dir: ingress}) { + return + } + c.d.deliver(&tunnel.Message{ + Kind: tunnel.MessageOpen, + ConnID: id, + Conn: &tunnel.ConnInfo{Dest: tunnel.Addr{Host: "127.0.0.1", Port: port}}, + }) +} + +// route returns the tunnel's id and route for the conn the relay calls wire, +// if it's open on this call. +func (c *call) route(wire uint64) (string, *route, bool) { + id, ok := c.ids.Load(wire) + if !ok { + return "", nil, false + } + r, ok := c.d.routes.Load(id) + if !ok || r.c != c { + return "", nil, false + } + return id, r, true +} + +// track routes id to this call, or reports false once the call has ended. +func (c *call) track(id string, r *route) bool { + if c.closed.Load() { + return false + } + // Counted before it's stored, so whichever forget removes it uncounts it. + c.d.counters(r.dir, 1) + c.d.routes.Store(id, r) + c.ids.Store(r.wire, id) + // endConns may have swept the routes before the Store. + if c.closed.Load() { + c.d.forget(id, r) + return false + } + return true +} + +// endConns resets every conn on the call, which the relay ended with it. +func (c *call) endConns() { + c.closed.Store(true) + c.d.routes.Range(func(id string, r *route) bool { + if r.c == c && c.d.forget(id, r) { + c.d.deliver(&tunnel.Message{Kind: tunnel.MessageReset, ConnID: id, Reason: "the tunnel's stream ended"}) + } + return true + }) +} + +func (c *call) send(frame *devtunnel.ConnectRequest) { + c.out.push(frame) +} + +// outbox queues frames for the relay in order. Data is bounded by each conn's +// window; the rest is small. +type outbox struct { + mu sync.Mutex + frames []*devtunnel.ConnectRequest + wake chan struct{} +} + +func (o *outbox) push(frame *devtunnel.ConnectRequest) { + o.mu.Lock() + o.frames = append(o.frames, frame) + o.mu.Unlock() + select { + case o.wake <- struct{}{}: + default: + } +} + +func (o *outbox) pop(ctx context.Context) (*devtunnel.ConnectRequest, bool) { + for { + o.mu.Lock() + if len(o.frames) > 0 { + frame := o.frames[0] + o.frames[0] = nil + o.frames = o.frames[1:] + o.mu.Unlock() + return frame, true + } + o.mu.Unlock() + select { + case <-o.wake: + case <-ctx.Done(): + return nil, false + } + } +} + +func resetFrame(id uint64, reason string) *devtunnel.ConnectRequest { + return &devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Reset_{Reset_: &devtunnel.Reset{ConnId: id, Reason: reason}}} +} diff --git a/pkg/devtunnel/stream.go b/pkg/devtunnel/stream.go new file mode 100644 index 0000000..c500a2c --- /dev/null +++ b/pkg/devtunnel/stream.go @@ -0,0 +1,599 @@ +// Package devtunnel carries a tunnel over a dev tunnel: the Connect stream to +// api-dev-tunnel that `vc dev --tunnel` holds otherwise. Stream translates the +// relay's frames to and from tunnel Messages, so the deployment's conns reach +// the app and the app's outbound conns leave through the deployment. +package devtunnel + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/url" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/puzpuzpuz/xsync/v3" + "github.com/segmentio/ksuid" + bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/api/go/vercel/api/dev_tunnel/v1" + "github.com/vercel/bridge/pkg/plumbing" + "github.com/vercel/bridge/pkg/tunnel" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" + "google.golang.org/grpc/status" +) + +const ( + // What each side of a conn may send before the other grants more. + connWindow = 256 << 10 + // A client numbers its conns from here; the relay numbers its own from 1. + clientConnIDFloor = uint64(1) << 63 + // The relay's frames waiting for the tunnel to take them. + queuedMessages = 256 + + instanceHeader = "x-vercel-dev-tunnel-instance" + + retryMin = 250 * time.Millisecond + retryMax = 5 * time.Second +) + +var instancePattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,64}$`) + +// Codes reconnecting can't fix. +var fatal = map[codes.Code]bool{ + codes.FailedPrecondition: true, + codes.InvalidArgument: true, + codes.PermissionDenied: true, + codes.Unimplemented: true, +} + +// Config configures a Stream. +type Config struct { + // RelayURL is api-dev-tunnel's URL, from create-session. + RelayURL string + // Token is the client token, from create-session. Without one the stream + // waits for SetToken. + Token string + // Instance tells the relay this process's reconnects from another + // client's, so a restart can take the tunnel back while its old stream is + // still open. Random if empty. + Instance string + // Port is the only port of the app the deployment's conns may reach. + Port int + // RelayDialer connects to the relay. Defaults to a net.Dialer. + RelayDialer plumbing.ContextDialer + Logger *slog.Logger +} + +// timings are the relay's protocol intervals, shortened by tests. +type timings struct { + // The relay needs a heartbeat at least every 30s. + heartbeat time.Duration + // The relay heartbeats every 30s; three missed and the stream is dead. + silent time.Duration + // The relay holds the deployment's conns this long for a client, and an + // outbound conn waits as long for a stream. + liveWait time.Duration +} + +var defaultTimings = timings{ + heartbeat: 15 * time.Second, + silent: 90 * time.Second, + liveWait: 5 * time.Second, +} + +// Stream holds a dev tunnel's Connect stream to its relay, replacing it +// whenever it drops, as one tunnel.Stream. +type Stream struct { + cfg Config + timings timings + logger *slog.Logger + instance string + target string + creds credentials.TransportCredentials + + // Set by Start, before any call. + rpc devtunnel.TunnelServiceClient + ctx context.Context + + msgs chan *tunnel.Message + done chan struct{} + // Why it stopped; read after done. + err error + + // Each open conn's route, by the tunnel's id for it. + routes *xsync.MapOf[string, *route] + + mu sync.Mutex + token string + tokenSet chan struct{} + // The newest call holding the tunnel, which new outbound conns take. + live *call + liveCh chan struct{} + liveClosed bool + // Calls holding the tunnel, including one finishing its conns after the + // relay said to reconnect. + held int + state bridgev1.TunnelState + lastErr string + liveSince time.Time + reconnects int64 + + ingressConns, egressConns, ingressTotal, egressTotal atomic.Int64 + bytesSent, bytesReceived atomic.Int64 + nextEgress, nextCall atomic.Uint64 +} + +// route is where one of the tunnel's conns lives on the relay's protocol. +type route struct { + c *call + // The conn's id on c. + wire uint64 + dir direction + // Both sides have half-closed once both are set. + closeSent, peerClosed atomic.Bool +} + +type direction int + +const ( + // From the deployment to the app. + ingress direction = iota + // From the app out through the deployment. + egress +) + +var _ tunnel.Stream = (*Stream)(nil) + +// New returns a Stream for cfg. Nothing connects until Start. +func New(cfg Config) (*Stream, error) { + target, creds, err := relayTarget(cfg.RelayURL) + if err != nil { + return nil, err + } + instance := cfg.Instance + if instance == "" { + instance = "bridge-" + ksuid.New().String() + } + if !instancePattern.MatchString(instance) { + return nil, fmt.Errorf("tunnel instance must be 1 to 64 letters, digits or dashes: %q", instance) + } + if cfg.RelayDialer == nil { + cfg.RelayDialer = &net.Dialer{Timeout: 10 * time.Second} + } + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &Stream{ + cfg: cfg, + timings: defaultTimings, + logger: logger.With("component", "dev-tunnel", "relay", cfg.RelayURL, "instance", instance), + instance: instance, + target: target, + creds: creds, + msgs: make(chan *tunnel.Message, queuedMessages), + done: make(chan struct{}), + routes: xsync.NewMapOf[string, *route](), + token: cfg.Token, + tokenSet: make(chan struct{}), + liveCh: make(chan struct{}), + state: bridgev1.TunnelState_TUNNEL_STATE_CONNECTING, + }, nil +} + +// relayTarget turns the relay's URL into a gRPC target. Start dials it with +// RelayDialer rather than resolving it through gRPC, so an intercept whose DNS +// forwards every domain still reaches the relay directly. +func relayTarget(relayURL string) (string, credentials.TransportCredentials, error) { + u, err := url.Parse(relayURL) + if err != nil { + return "", nil, fmt.Errorf("invalid relay URL %q: %w", relayURL, err) + } + if u.Path != "" && u.Path != "/" { + return "", nil, fmt.Errorf("relay URL %q must have no path", relayURL) + } + var creds credentials.TransportCredentials + port := u.Port() + switch u.Scheme { + case "https": + creds = credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}) + if port == "" { + port = "443" + } + case "http": + creds = insecure.NewCredentials() + if port == "" { + port = "80" + } + default: + return "", nil, fmt.Errorf("relay URL %q must be http or https", relayURL) + } + if u.Hostname() == "" { + return "", nil, fmt.Errorf("relay URL %q has no host", relayURL) + } + return "passthrough:///" + net.JoinHostPort(u.Hostname(), port), creds, nil +} + +// Start holds the tunnel until ctx ends, replacing its Connect stream +// whenever it drops. Recv fails once it stops: when ctx ends, or the relay +// refuses the tunnel for good. +func (d *Stream) Start(ctx context.Context) { + d.ctx = ctx + go func() { + err := d.hold() + if err != nil { + d.mu.Lock() + d.state = bridgev1.TunnelState_TUNNEL_STATE_FAILED + d.lastErr = err.Error() + d.mu.Unlock() + d.logger.Error("Tunnel stopped", "error", err) + } + d.err = err + close(d.done) + }() +} + +func (d *Stream) hold() error { + relayDialer := d.cfg.RelayDialer + cc, err := grpc.NewClient(d.target, + grpc.WithTransportCredentials(d.creds), + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + return relayDialer.DialContext(ctx, "tcp", addr) + }), + // Notices a dead connection, e.g. after sleep, sooner than the + // relay's heartbeats would. + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 10 * time.Second, + Timeout: 20 * time.Second, + PermitWithoutStream: true, + }), + ) + if err != nil { + return fmt.Errorf("creating relay client: %w", err) + } + defer cc.Close() + d.rpc = devtunnel.NewTunnelServiceClient(cc) + return d.run() +} + +func (d *Stream) Features() tunnel.Features { + return tunnel.Features{Window: connWindow, HalfClose: true} +} + +func (d *Stream) Recv() (*tunnel.Message, error) { + select { + case m := <-d.msgs: + return m, nil + case <-d.done: + if d.err != nil { + return nil, d.err + } + return nil, io.EOF + } +} + +func (d *Stream) Send(m *tunnel.Message) error { + if m.Kind == tunnel.MessageOpen { + return d.open(m) + } + r, ok := d.routes.Load(m.ConnID) + if !ok { + // The conn is over. + return nil + } + switch m.Kind { + case tunnel.MessageData: + d.bytesSent.Add(int64(len(m.Payload))) + r.c.send(&devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Data{Data: &devtunnel.Data{ConnId: r.wire, Payload: m.Payload}}}) + case tunnel.MessageCloseWrite: + r.closeSent.Store(true) + r.c.send(&devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Close{Close: &devtunnel.Close{ConnId: r.wire}}}) + if r.peerClosed.Load() { + d.forget(m.ConnID, r) + } + case tunnel.MessageReset: + r.c.send(resetFrame(r.wire, m.Reason)) + d.forget(m.ConnID, r) + case tunnel.MessageWindow: + r.c.send(&devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Window{Window: &devtunnel.Window{ConnId: r.wire, Bytes: uint32(m.Bytes)}}}) + } + return nil +} + +// open sends an outbound conn out through the deployment, to its hostname if +// the intercept's DNS gave it one, or else to the address it was headed for. +// The deployment resolves the hostname as its own code would. It waits a +// while for a call to hold the tunnel. +func (d *Stream) open(m *tunnel.Message) error { + c := d.awaitLive() + if c == nil { + return errors.New("no stream holds the tunnel") + } + r := &route{c: c, wire: clientConnIDFloor | d.nextEgress.Add(1), dir: egress} + if !c.track(m.ConnID, r) { + return errors.New("the tunnel's stream ended") + } + host := m.Conn.Hostname + if host == "" { + host = m.Conn.Dest.Host + } + c.send(&devtunnel.ConnectRequest{Frame: &devtunnel.ConnectRequest_Open{Open: &devtunnel.Open{ + ConnId: r.wire, + Protocol: devtunnel.Protocol_PROTOCOL_TCP, + Host: host, + Port: uint32(m.Conn.Dest.Port), + }}}) + return nil +} + +// deliver hands the tunnel one of the relay's messages. +func (d *Stream) deliver(m *tunnel.Message) { + select { + case d.msgs <- m: + case <-d.done: + } +} + +// forget drops id's route if it's still r. Reports whether it did. +func (d *Stream) forget(id string, r *route) bool { + removed := false + d.routes.Compute(id, func(cur *route, loaded bool) (*route, bool) { + if !loaded { + return nil, true + } + if cur != r { + return cur, false + } + removed = true + return cur, true + }) + if removed { + r.c.ids.Delete(r.wire) + d.counters(r.dir, -1) + } + return removed +} + +// counters tracks conns opening (delta 1) and ending (delta -1). +func (d *Stream) counters(dir direction, delta int64) { + open, total := &d.ingressConns, &d.ingressTotal + if dir == egress { + open, total = &d.egressConns, &d.egressTotal + } + open.Add(delta) + if delta > 0 { + total.Add(delta) + } +} + +// SetToken replaces the client token. A stream waiting for one connects with +// it; a live one uses it on its next call. +func (d *Stream) SetToken(token string) { + d.mu.Lock() + defer d.mu.Unlock() + d.token = token + close(d.tokenSet) + d.tokenSet = make(chan struct{}) +} + +// Status reports the tunnel's state and traffic. +func (d *Stream) Status() *bridgev1.TunnelStatus { + d.mu.Lock() + defer d.mu.Unlock() + st := &bridgev1.TunnelStatus{ + State: d.state, + LastError: d.lastErr, + Reconnects: d.reconnects, + IngressConns: d.ingressConns.Load(), + EgressConns: d.egressConns.Load(), + IngressConnsTotal: d.ingressTotal.Load(), + EgressConnsTotal: d.egressTotal.Load(), + BytesSent: d.bytesSent.Load(), + BytesReceived: d.bytesReceived.Load(), + } + if !d.liveSince.IsZero() { + st.LiveSinceUnixSeconds = d.liveSince.Unix() + } + return st +} + +// awaitLive returns the call new outbound conns take, waiting a while for +// one, or nil. +func (d *Stream) awaitLive() *call { + timer := time.NewTimer(d.timings.liveWait) + defer timer.Stop() + for { + d.mu.Lock() + c, ch := d.live, d.liveCh + d.mu.Unlock() + if c != nil { + return c + } + select { + case <-ch: + case <-timer.C: + return nil + case <-d.done: + return nil + } + } +} + +// run replaces the tunnel's call whenever it ends, for as long as it takes: +// the relay's pods roll often. A call the relay says to move off keeps its +// open conns while the next one opens. +func (d *Stream) run() error { + delay := retryMin + first := true + warnedTaken := false + for { + token, ok := d.awaitToken() + if !ok { + return nil + } + if !first { + d.mu.Lock() + d.reconnects++ + d.mu.Unlock() + } + first = false + + c := d.newCall() + ended := make(chan error, 1) + moved := make(chan struct{}) + go func() { ended <- c.run(token, sync.OnceFunc(func() { close(moved) })) }() + + var err error + select { + case <-moved: + d.logger.Info("Relay pod is shutting down, opening a new stream") + continue + case err = <-ended: + } + if d.ctx.Err() != nil { + return nil + } + if c.held.Load() { + delay = retryMin + warnedTaken = false + } + code := status.Code(err) + d.mu.Lock() + if err != nil { + d.lastErr = err.Error() + } else { + d.lastErr = "the relay ended the stream" + } + d.mu.Unlock() + if fatal[code] { + return fmt.Errorf("the relay refused the tunnel: %w", err) + } + + logger := d.logger.With("code", code.String(), "error", err) + switch code { + case codes.Unauthenticated: + // Each create-session replaces the client token. + d.refuseToken(token) + logger.Warn("The relay refused the client token, waiting for a new one") + continue + case codes.AlreadyExists: + d.setState(bridgev1.TunnelState_TUNNEL_STATE_TAKEN) + if !warnedTaken { + warnedTaken = true + logger.Warn("Another client holds the tunnel, waiting for it to let go") + } + default: + d.mu.Lock() + if d.held == 0 { + d.state = bridgev1.TunnelState_TUNNEL_STATE_CONNECTING + } + d.mu.Unlock() + logger.Info("Tunnel stream ended, reconnecting", "delay", delay) + } + + select { + case <-time.After(delay): + case <-d.ctx.Done(): + return nil + } + delay = min(delay*2, retryMax) + } +} + +// awaitToken returns the client token, waiting for SetToken while there is +// none. +func (d *Stream) awaitToken() (string, bool) { + for { + d.mu.Lock() + token, set := d.token, d.tokenSet + if token == "" { + d.state = bridgev1.TunnelState_TUNNEL_STATE_NEEDS_TOKEN + } + d.mu.Unlock() + if token != "" { + return token, true + } + select { + case <-set: + case <-d.ctx.Done(): + return "", false + } + } +} + +// refuseToken forgets token, unless SetToken already replaced it. +func (d *Stream) refuseToken(token string) { + d.mu.Lock() + defer d.mu.Unlock() + if d.token == token { + d.token = "" + } +} + +func (d *Stream) setState(state bridgev1.TunnelState) { + d.mu.Lock() + defer d.mu.Unlock() + d.state = state +} + +// setLive makes c the call new outbound conns take; nil for none. +func (d *Stream) setLive(c *call) { + d.live = c + if c != nil && !d.liveClosed { + close(d.liveCh) + d.liveClosed = true + } else if c == nil && d.liveClosed { + d.liveCh = make(chan struct{}) + d.liveClosed = false + } +} + +// holding: the relay routes the tunnel's conns to c. +func (d *Stream) holding(c *call) { + d.mu.Lock() + defer d.mu.Unlock() + d.held++ + d.setLive(c) + if d.state != bridgev1.TunnelState_TUNNEL_STATE_LIVE { + d.state = bridgev1.TunnelState_TUNNEL_STATE_LIVE + d.liveSince = time.Now() + d.logger.Info("Tunnel live", "reconnects", d.reconnects) + } +} + +// moving: c gets no new conns, but finishes the ones open on it. +func (d *Stream) moving(c *call) { + d.mu.Lock() + defer d.mu.Unlock() + if d.live == c { + d.setLive(nil) + } +} + +// lost: c ended. +func (d *Stream) lost(c *call) { + d.mu.Lock() + defer d.mu.Unlock() + if c.held.Load() { + d.held-- + } + if d.live == c { + d.setLive(nil) + } + if d.held == 0 && d.state == bridgev1.TunnelState_TUNNEL_STATE_LIVE { + d.state = bridgev1.TunnelState_TUNNEL_STATE_CONNECTING + d.liveSince = time.Time{} + } +} + +var errSilent = errors.New("no heartbeat from the relay") diff --git a/pkg/devtunnel/stream_test.go b/pkg/devtunnel/stream_test.go new file mode 100644 index 0000000..1bbcf34 --- /dev/null +++ b/pkg/devtunnel/stream_test.go @@ -0,0 +1,436 @@ +package devtunnel + +import ( + "bytes" + "context" + "io" + "net" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/api/go/vercel/api/dev_tunnel/v1" + "github.com/vercel/bridge/pkg/tunnel" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const testTimeout = 5 * time.Second + +// More than one Data frame's worth. +const frameBytes = 64 << 10 + +// fakeRelay stands in for api-dev-tunnel, handing each Connect call to the +// test. +type fakeRelay struct { + devtunnel.UnimplementedTunnelServiceServer + url string + streams chan *relayCall + // refuse ends a call before it reaches the test, unless it returns nil. + refuse func(md metadata.MD) error +} + +type relayCall struct { + md metadata.MD + call grpc.BidiStreamingServer[devtunnel.ConnectRequest, devtunnel.ConnectResponse] + recv chan *devtunnel.ConnectRequest + end chan error +} + +func startRelay(t *testing.T) *fakeRelay { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + r := &fakeRelay{url: "http://" + lis.Addr().String(), streams: make(chan *relayCall, 8)} + srv := grpc.NewServer() + devtunnel.RegisterTunnelServiceServer(srv, r) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + return r +} + +func (r *fakeRelay) Connect(call grpc.BidiStreamingServer[devtunnel.ConnectRequest, devtunnel.ConnectResponse]) error { + md, _ := metadata.FromIncomingContext(call.Context()) + if r.refuse != nil { + if err := r.refuse(md); err != nil { + return err + } + } + s := &relayCall{md: md, call: call, recv: make(chan *devtunnel.ConnectRequest, 256), end: make(chan error, 1)} + go func() { + defer close(s.recv) + for { + msg, err := call.Recv() + if err != nil { + return + } + s.recv <- msg + } + }() + r.streams <- s + select { + case err := <-s.end: + return err + case <-call.Context().Done(): + return nil + } +} + +func (r *fakeRelay) next(t *testing.T) *relayCall { + t.Helper() + select { + case s := <-r.streams: + return s + case <-time.After(testTimeout): + t.Fatal("no Connect call reached the relay") + return nil + } +} + +func (s *relayCall) send(t *testing.T, msg *devtunnel.ConnectResponse) { + t.Helper() + require.NoError(t, s.call.Send(msg)) +} + +// hold makes the stream the tunnel's, as the relay's first heartbeat does. +func (s *relayCall) hold(t *testing.T) { + s.send(t, &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Heartbeat{Heartbeat: &devtunnel.Heartbeat{}}}) +} + +// expect returns the next frame from the client other than a heartbeat. +func (s *relayCall) expect(t *testing.T) *devtunnel.ConnectRequest { + t.Helper() + timeout := time.After(testTimeout) + for { + select { + case msg, ok := <-s.recv: + require.True(t, ok, "the client ended the stream") + if msg.GetHeartbeat() == nil { + return msg + } + case <-timeout: + t.Fatal("no frame from the client") + return nil + } + } +} + +// expectData collects the client's Data on a conn until it has n bytes. +func (s *relayCall) expectData(t *testing.T, id uint64, n int) []byte { + t.Helper() + var got []byte + for len(got) < n { + msg := s.expect(t) + if d := msg.GetData(); d != nil && d.GetConnId() == id { + got = append(got, d.GetPayload()...) + } + } + return got +} + +func open(id uint64, port int) *devtunnel.ConnectResponse { + return &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Open{Open: &devtunnel.Open{ + ConnId: id, Protocol: devtunnel.Protocol_PROTOCOL_TCP, Port: uint32(port), + }}} +} + +func data(id uint64, payload string) *devtunnel.ConnectResponse { + return &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Data{Data: &devtunnel.Data{ConnId: id, Payload: []byte(payload)}}} +} + +func closeConn(id uint64) *devtunnel.ConnectResponse { + return &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Close{Close: &devtunnel.Close{ConnId: id}}} +} + +func window(id uint64, n int) *devtunnel.ConnectResponse { + return &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Window{Window: &devtunnel.Window{ConnId: id, Bytes: uint32(n)}}} +} + +// newTunnel runs a tunnel over a Stream for r. +func newTunnel(t *testing.T, r *fakeRelay, token string, port int, tune ...func(*timings)) (*Stream, tunnel.Tunnel) { + t.Helper() + d, err := New(Config{RelayURL: r.url, Token: token, Instance: "test-instance", Port: port}) + require.NoError(t, err) + d.timings = timings{heartbeat: 50 * time.Millisecond, silent: time.Second, liveWait: 2 * time.Second} + for _, f := range tune { + f(&d.timings) + } + ctx, cancel := context.WithCancel(context.Background()) + d.Start(ctx) + tun := tunnel.New(&net.Dialer{Timeout: time.Second}, d) + tun.Start(ctx) + t.Cleanup(func() { + cancel() + _ = tun.Close() + }) + return d, tun +} + +// echoApp echoes each connection, then half-closes it once the peer has. +func echoApp(t *testing.T) int { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = lis.Close() }) + go func() { + for { + nc, err := lis.Accept() + if err != nil { + return + } + go func() { + _, _ = io.Copy(nc, nc) + _ = nc.(*net.TCPConn).CloseWrite() + }() + } + }() + return lis.Addr().(*net.TCPAddr).Port +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + require.Eventually(t, cond, testTimeout, 10*time.Millisecond) +} + +func TestIngressReachesTheApp(t *testing.T) { + r := startRelay(t) + port := echoApp(t) + c, _ := newTunnel(t, r, "tok", port) + + s := r.next(t) + assert.Equal(t, []string{"Bearer tok"}, s.md.Get("authorization")) + assert.Equal(t, []string{"test-instance"}, s.md.Get(instanceHeader)) + s.hold(t) + waitFor(t, func() bool { return c.Status().GetState() == bridgev1.TunnelState_TUNNEL_STATE_LIVE }) + + s.send(t, open(1, port)) + s.send(t, data(1, "hello")) + var echoed []byte + granted := 0 + for len(echoed) < 5 || granted < 5 { + msg := s.expect(t) + if d := msg.GetData(); d != nil { + echoed = append(echoed, d.GetPayload()...) + } + if w := msg.GetWindow(); w != nil { + granted += int(w.GetBytes()) + } + } + assert.Equal(t, "hello", string(echoed)) + + // The relay's half-close reaches the app, whose own comes back. + s.send(t, closeConn(1)) + assert.Equal(t, uint64(1), s.expect(t).GetClose().GetConnId()) + waitFor(t, func() bool { return c.Status().GetIngressConns() == 0 }) + st := c.Status() + assert.Equal(t, int64(1), st.GetIngressConnsTotal()) + assert.Equal(t, int64(5), st.GetBytesSent()) + assert.Equal(t, int64(5), st.GetBytesReceived()) +} + +func TestIngressToAnotherPortIsReset(t *testing.T) { + r := startRelay(t) + newTunnel(t, r, "tok", 3000) + s := r.next(t) + s.hold(t) + + s.send(t, open(1, 22)) + reset := s.expect(t).GetReset_() + require.NotNil(t, reset) + assert.Equal(t, uint64(1), reset.GetConnId()) + assert.Equal(t, "bridge serves only port 3000", reset.GetReason()) +} + +func TestIngressSendsNoMoreThanTheWindow(t *testing.T) { + r := startRelay(t) + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = lis.Close() }) + body := bytes.Repeat([]byte("x"), connWindow+frameBytes) + go func() { + nc, err := lis.Accept() + if err == nil { + _, _ = nc.Write(body) + } + }() + port := lis.Addr().(*net.TCPAddr).Port + newTunnel(t, r, "tok", port) + s := r.next(t) + s.hold(t) + s.send(t, open(1, port)) + + assert.Len(t, s.expectData(t, 1, connWindow), connWindow) + select { + case msg := <-s.recv: + if msg.GetData() != nil { + t.Fatal("sent past the window") + } + case <-time.After(200 * time.Millisecond): + } + + s.send(t, window(1, frameBytes)) + assert.Len(t, s.expectData(t, 1, frameBytes), frameBytes) +} + +func TestEgressLeavesThroughTheDeployment(t *testing.T) { + r := startRelay(t) + c, tun := newTunnel(t, r, "tok", 3000) + s := r.next(t) + s.hold(t) + waitFor(t, func() bool { return c.Status().GetState() == bridgev1.TunnelState_TUNNEL_STATE_LIVE }) + + app, intercepted := tcpPair(t) + tun.AddConn(intercepted, "0.0.0.0:443", "example.com") + o := s.expect(t).GetOpen() + require.NotNil(t, o) + assert.GreaterOrEqual(t, o.GetConnId(), clientConnIDFloor) + assert.Equal(t, "example.com", o.GetHost()) + assert.Equal(t, uint32(443), o.GetPort()) + assert.Equal(t, devtunnel.Protocol_PROTOCOL_TCP, o.GetProtocol()) + id := o.GetConnId() + + _, err := app.Write([]byte("GET")) + require.NoError(t, err) + assert.Equal(t, "GET", string(s.expectData(t, id, 3))) + + s.send(t, data(id, "200")) + buf := make([]byte, 3) + _, err = io.ReadFull(app, buf) + require.NoError(t, err) + assert.Equal(t, "200", string(buf)) + + // The deployment couldn't reach it, or it went away. + s.send(t, &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Reset_{Reset_: &devtunnel.Reset{ConnId: id, Reason: "refused"}}}) + _ = app.SetReadDeadline(time.Now().Add(testTimeout)) + _, err = app.Read(buf) + assert.Error(t, err) + waitFor(t, func() bool { return c.Status().GetEgressConns() == 0 }) + assert.Equal(t, int64(1), c.Status().GetEgressConnsTotal()) +} + +func TestEgressWithNoLiveStreamIsDropped(t *testing.T) { + r := startRelay(t) + _, tun := newTunnel(t, r, "tok", 3000, func(tm *timings) { tm.liveWait = 50 * time.Millisecond }) + r.next(t) // never holds the tunnel + + app, intercepted := tcpPair(t) + tun.AddConn(intercepted, "0.0.0.0:443", "example.com") + _ = app.SetReadDeadline(time.Now().Add(testTimeout)) + _, err := app.Read(make([]byte, 1)) + assert.ErrorIs(t, err, io.EOF) +} + +func TestReconnectKeepsTheOldStreamsConns(t *testing.T) { + r := startRelay(t) + port := echoApp(t) + c, _ := newTunnel(t, r, "tok", port) + first := r.next(t) + first.hold(t) + first.send(t, open(1, port)) + first.send(t, data(1, "a")) + assert.Equal(t, "a", string(first.expectData(t, 1, 1))) + + first.send(t, &devtunnel.ConnectResponse{Frame: &devtunnel.ConnectResponse_Reconnect{Reconnect: &devtunnel.Reconnect{}}}) + second := r.next(t) + assert.Equal(t, []string{"test-instance"}, second.md.Get(instanceHeader)) + second.hold(t) + + first.send(t, data(1, "b")) + assert.Equal(t, "b", string(first.expectData(t, 1, 1))) + st := c.Status() + assert.Equal(t, bridgev1.TunnelState_TUNNEL_STATE_LIVE, st.GetState()) + assert.Equal(t, int64(1), st.GetReconnects()) +} + +func TestRefusedTokenWaitsForANewOne(t *testing.T) { + r := startRelay(t) + r.refuse = func(md metadata.MD) error { + if md.Get("authorization")[0] == "Bearer old" { + return status.Error(codes.Unauthenticated, "missing or invalid dev tunnel token") + } + return nil + } + c, _ := newTunnel(t, r, "old", 3000) + waitFor(t, func() bool { return c.Status().GetState() == bridgev1.TunnelState_TUNNEL_STATE_NEEDS_TOKEN }) + + c.SetToken("new") + s := r.next(t) + assert.Equal(t, []string{"Bearer new"}, s.md.Get("authorization")) +} + +func TestNoTokenWaitsForOne(t *testing.T) { + r := startRelay(t) + c, _ := newTunnel(t, r, "", 3000) + waitFor(t, func() bool { return c.Status().GetState() == bridgev1.TunnelState_TUNNEL_STATE_NEEDS_TOKEN }) + select { + case <-r.streams: + t.Fatal("connected without a token") + case <-time.After(100 * time.Millisecond): + } + + c.SetToken("tok") + assert.Equal(t, []string{"Bearer tok"}, r.next(t).md.Get("authorization")) +} + +func TestFatalRefusalStops(t *testing.T) { + r := startRelay(t) + r.refuse = func(metadata.MD) error { + return status.Error(codes.PermissionDenied, "not allowed") + } + c, tun := newTunnel(t, r, "tok", 3000) + + select { + case <-tun.Done(): + case <-time.After(testTimeout): + t.Fatal("kept retrying") + } + assert.Contains(t, c.Status().GetLastError(), "not allowed") + assert.Equal(t, bridgev1.TunnelState_TUNNEL_STATE_FAILED, c.Status().GetState()) +} + +func TestSilentRelayIsReplaced(t *testing.T) { + r := startRelay(t) + c, _ := newTunnel(t, r, "tok", 3000, func(tm *timings) { tm.silent = 200 * time.Millisecond }) + r.next(t) // never heard from again + + r.next(t) + assert.Contains(t, c.Status().GetLastError(), errSilent.Error()) +} + +func TestRelayTarget(t *testing.T) { + target, _, err := relayTarget("https://api-dev-tunnel.example.com") + require.NoError(t, err) + assert.Equal(t, "passthrough:///api-dev-tunnel.example.com:443", target) + + target, _, err = relayTarget("http://127.0.0.1:" + strconv.Itoa(8080)) + require.NoError(t, err) + assert.Equal(t, "passthrough:///127.0.0.1:8080", target) + + for _, bad := range []string{"ftp://relay", "https://relay/v1", "https://"} { + _, _, err := relayTarget(bad) + assert.Error(t, err, bad) + } +} + +// tcpPair returns both ends of a TCP connection: the app's, and the one the +// transparent proxy would have accepted. +func tcpPair(t *testing.T) (net.Conn, net.Conn) { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer lis.Close() + accepted := make(chan net.Conn, 1) + go func() { + nc, _ := lis.Accept() + accepted <- nc + }() + app, err := net.Dial("tcp", lis.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = app.Close() }) + return app, <-accepted +} diff --git a/pkg/mitm/hijacker.go b/pkg/mitm/hijacker.go index 1daacee..de9fe82 100644 --- a/pkg/mitm/hijacker.go +++ b/pkg/mitm/hijacker.go @@ -14,13 +14,14 @@ import ( // ShouldHijack for each new connection; if it returns true the tunnel calls // Hijack instead of dialing the real destination. type Hijacker interface { - // ShouldHijack returns true if the hijacker wants to handle this connection. - ShouldHijack(msg *bridgev1.TunnelNetworkMessage) bool + // ShouldHijack returns true if the hijacker wants to handle a connection + // to hostname (may be empty). + ShouldHijack(hostname string) bool // Hijack returns a net.Conn that the tunnel reads from and writes to, - // just like a dialed connection. The hijacker is responsible for - // servicing the other end (e.g. via a net.Pipe goroutine). - Hijack(ctx context.Context, msg *bridgev1.TunnelNetworkMessage) (net.Conn, error) + // just like a connection dialed to dest ("host:port"). The hijacker is + // responsible for servicing the other end (e.g. via a net.Pipe goroutine). + Hijack(ctx context.Context, hostname, dest string) (net.Conn, error) } // FacadeHijacker implements Hijacker by matching incoming connections against @@ -54,10 +55,9 @@ func NewFacadeHijacker(facades []*bridgev1.ServerFacade, certPEM, keyPEM []byte) return &FacadeHijacker{ca: ca, facades: compiled}, nil } -// ShouldHijack returns true if the message's hostname matches any facade's -// host glob pattern. -func (h *FacadeHijacker) ShouldHijack(msg *bridgev1.TunnelNetworkMessage) bool { - hostname := msg.GetHostname() +// ShouldHijack returns true if hostname matches any facade's host glob +// pattern. +func (h *FacadeHijacker) ShouldHijack(hostname string) bool { if hostname == "" { return false } @@ -71,9 +71,7 @@ func (h *FacadeHijacker) ShouldHijack(msg *bridgev1.TunnelNetworkMessage) bool { // Hijack returns a net.Conn for the tunnel to read/write. It finds all facades // matching the hostname and creates a Handler to serve the connection. -func (h *FacadeHijacker) Hijack(ctx context.Context, msg *bridgev1.TunnelNetworkMessage) (net.Conn, error) { - hostname := msg.GetHostname() - +func (h *FacadeHijacker) Hijack(ctx context.Context, hostname, dest string) (net.Conn, error) { matched := h.matchingFacades(hostname) if len(matched) == 0 { return nil, fmt.Errorf("no matching facades for hostname %q", hostname) @@ -81,13 +79,10 @@ func (h *FacadeHijacker) Hijack(ctx context.Context, msg *bridgev1.TunnelNetwork tunnelConn, handlerConn := net.Pipe() - dest := msg.GetDest() - destAddr := fmt.Sprintf("%s:%d", dest.GetIp(), dest.GetPort()) - handler := &Handler{ - CA: h.ca, - Facades: matched, - DestAddr: destAddr, + CA: h.ca, + Facades: matched, + DestAddr: dest, } go func() { diff --git a/pkg/plumbing/conn.go b/pkg/plumbing/conn.go index bfff5f0..09b0a18 100644 --- a/pkg/plumbing/conn.go +++ b/pkg/plumbing/conn.go @@ -1,112 +1,79 @@ package plumbing import ( - "io" "net" "sync" "time" ) -// TunnelConn wraps one side of a multiplexed tunnel connection as a net.Conn. -// -// Reads come from a channel fed by a central read loop (demuxed by connection -// ID). Writes go through a caller-provided send function that handles stream -// serialization and any mutex. -type TunnelConn struct { - readCh chan []byte - closeCh chan struct{} - closeOnce sync.Once - sendFn func([]byte) error - onClose func() - - localAddr net.Addr - remoteAddr net.Addr - - buf []byte // leftover from a partial Read -} - -// TunnelConnConfig configures a TunnelConn. -type TunnelConnConfig struct { - // SendFn writes data onto the tunnel stream. The caller is responsible for - // any mutex and message framing. - SendFn func([]byte) error - - // OnClose is called once when Close is invoked (e.g. to remove the conn - // from a tracking map). May be nil. - OnClose func() - - LocalAddr net.Addr - RemoteAddr net.Addr -} - -// NewTunnelConn creates a TunnelConn ready for use with bidi.New. -func NewTunnelConn(cfg TunnelConnConfig) *TunnelConn { - return &TunnelConn{ - readCh: make(chan []byte, 64), - closeCh: make(chan struct{}), - sendFn: cfg.SendFn, - onClose: cfg.OnClose, - localAddr: cfg.LocalAddr, - remoteAddr: cfg.RemoteAddr, - } +// Stream carries one conn's bytes each way: a per-conn gRPC stream, or one +// conn's frames on a multiplexed one. +type Stream interface { + // Send sends p to the peer. It doesn't keep p after returning. + Send(p []byte) error + // Recv returns the peer's next bytes, or io.EOF once the peer has + // nothing more to send. + Recv() ([]byte, error) + // CloseSend tells the peer this side has nothing more to send. + CloseSend() error + // Close abandons the stream in both directions. + Close() error } -// Deliver pushes data into the read buffer. Called by the central read loop -// when a message arrives for this connection's ID. -func (tc *TunnelConn) Deliver(data []byte) { - select { - case tc.readCh <- data: - case <-tc.closeCh: +// NewStreamConn turns s into a net.Conn, whose CloseWrite is s.CloseSend. +// Deadlines aren't supported; Close unblocks a pending Read or Write. +func NewStreamConn(s Stream, local, remote net.Addr) net.Conn { + c := &streamConn{stream: s, local: local, remote: remote, closed: make(chan struct{})} + c.done = c.closed + if d, ok := s.(interface{ Done() <-chan struct{} }); ok { + c.done = d.Done() } + return c } -// SignalClose tears down the connection from the remote side. -func (tc *TunnelConn) SignalClose() { - tc.closeOnce.Do(func() { close(tc.closeCh) }) +type streamConn struct { + stream Stream + local, remote net.Addr + // The rest of the last Recv; only Read touches it. + pending []byte + closed chan struct{} + closeOnce sync.Once + // The stream's, if it has one, else closed. + done <-chan struct{} } -// --- net.Conn implementation --- - -func (tc *TunnelConn) Read(b []byte) (int, error) { - if len(tc.buf) > 0 { - n := copy(b, tc.buf) - tc.buf = tc.buf[n:] - return n, nil - } - select { - case data, ok := <-tc.readCh: - if !ok { - return 0, io.EOF - } - n := copy(b, data) - if n < len(data) { - tc.buf = data[n:] +func (c *streamConn) Read(p []byte) (int, error) { + for len(c.pending) == 0 { + chunk, err := c.stream.Recv() + if err != nil { + return 0, err } - return n, nil - case <-tc.closeCh: - return 0, io.EOF + c.pending = chunk } + n := copy(p, c.pending) + c.pending = c.pending[n:] + return n, nil } -func (tc *TunnelConn) Write(b []byte) (int, error) { - if err := tc.sendFn(b); err != nil { +func (c *streamConn) Write(p []byte) (int, error) { + if err := c.stream.Send(p); err != nil { return 0, err } - return len(b), nil + return len(p), nil } -func (tc *TunnelConn) Close() error { - tc.closeOnce.Do(func() { close(tc.closeCh) }) - if tc.onClose != nil { - tc.onClose() - } - return nil +func (c *streamConn) CloseWrite() error { return c.stream.CloseSend() } + +func (c *streamConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return c.stream.Close() } -func (tc *TunnelConn) LocalAddr() net.Addr { return tc.localAddr } -func (tc *TunnelConn) RemoteAddr() net.Addr { return tc.remoteAddr } -func (tc *TunnelConn) SetDeadline(_ time.Time) error { return nil } -func (tc *TunnelConn) SetReadDeadline(_ time.Time) error { return nil } -func (tc *TunnelConn) SetWriteDeadline(_ time.Time) error { return nil } +// Done is closed once the conn is over. +func (c *streamConn) Done() <-chan struct{} { return c.done } -var _ net.Conn = (*TunnelConn)(nil) +func (c *streamConn) LocalAddr() net.Addr { return c.local } +func (c *streamConn) RemoteAddr() net.Addr { return c.remote } +func (c *streamConn) SetDeadline(time.Time) error { return nil } +func (c *streamConn) SetReadDeadline(time.Time) error { return nil } +func (c *streamConn) SetWriteDeadline(time.Time) error { return nil } diff --git a/pkg/plumbing/conn_test.go b/pkg/plumbing/conn_test.go new file mode 100644 index 0000000..ee04939 --- /dev/null +++ b/pkg/plumbing/conn_test.go @@ -0,0 +1,71 @@ +package plumbing + +import ( + "io" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// chunkStream replays chunks, then EOF, and records what's done to it. +type chunkStream struct { + chunks [][]byte + sent []string + sendClosed bool + closed bool + done chan struct{} +} + +func (s *chunkStream) Send(p []byte) error { + s.sent = append(s.sent, string(p)) + return nil +} + +func (s *chunkStream) Recv() ([]byte, error) { + if len(s.chunks) == 0 { + return nil, io.EOF + } + chunk := s.chunks[0] + s.chunks = s.chunks[1:] + return chunk, nil +} + +func (s *chunkStream) CloseSend() error { + s.sendClosed = true + return nil +} + +func (s *chunkStream) Close() error { + s.closed = true + return nil +} + +func (s *chunkStream) Done() <-chan struct{} { return s.done } + +func TestStreamConn(t *testing.T) { + s := &chunkStream{chunks: [][]byte{[]byte("hello "), []byte("world")}, done: make(chan struct{})} + conn := NewStreamConn(s, nil, nil) + + // A Read smaller than a chunk takes the rest of it next. + buf := make([]byte, 4) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hell", string(buf[:n])) + rest, err := io.ReadAll(conn) + require.NoError(t, err) + assert.Equal(t, "o world", string(rest)) + + _, err = conn.Write([]byte("hi")) + require.NoError(t, err) + assert.Equal(t, []string{"hi"}, s.sent) + + require.NoError(t, conn.(interface{ CloseWrite() error }).CloseWrite()) + assert.True(t, s.sendClosed) + assert.Equal(t, (<-chan struct{})(s.done), conn.(interface{ Done() <-chan struct{} }).Done()) + require.NoError(t, conn.Close()) + assert.True(t, s.closed) + + var _ net.Conn = conn +} diff --git a/pkg/plumbing/metered.go b/pkg/plumbing/metered.go new file mode 100644 index 0000000..6d87a92 --- /dev/null +++ b/pkg/plumbing/metered.go @@ -0,0 +1,154 @@ +package plumbing + +import ( + "errors" + "net" + "sync" + "sync/atomic" + "time" +) + +// MeteredConn is a net.Conn that counts what passes through it. Given a +// window, Write sends no more than the peer has granted with Grant: the flow +// control a conn multiplexed with others needs, since the transport's own can +// only slow all of them. +type MeteredConn interface { + net.Conn + // Grant lets Write send n more bytes. + Grant(n int) + // CloseWrite half-closes the wrapped conn, if it can. + CloseWrite() error + // Stats reports what has passed through so far. + Stats() *MeteredStats +} + +type meteredConn struct { + net.Conn + window int + + credit atomic.Int64 + wake chan struct{} + closed chan struct{} + closeOnce sync.Once + // The wrapped conn's, if it has a Done method, so a write waiting for + // credit returns once that conn is over. + innerDone <-chan struct{} + + bytesRead, bytesWritten atomic.Int64 + writeWait, writeWaits atomic.Int64 +} + +// MeteredStats is what has passed through a MeteredConn. +type MeteredStats struct { + BytesRead int64 + BytesWritten int64 + // What Write may still send. Zero without a window. + Credit int64 + // How long writes waited for credit, over how many waits. + WriteWait time.Duration + WriteWaits int64 +} + +// NewMeteredConn wraps conn. Write may send window bytes before Grant gives +// more; 0 for no limit. +func NewMeteredConn(conn net.Conn, window int) MeteredConn { + m := &meteredConn{ + Conn: conn, + window: window, + wake: make(chan struct{}, 1), + closed: make(chan struct{}), + } + m.credit.Store(int64(window)) + if d, ok := conn.(interface{ Done() <-chan struct{} }); ok { + m.innerDone = d.Done() + } + return m +} + +func (m *meteredConn) Read(p []byte) (int, error) { + n, err := m.Conn.Read(p) + m.bytesRead.Add(int64(n)) + return n, err +} + +func (m *meteredConn) Write(p []byte) (int, error) { + if m.window <= 0 { + n, err := m.Conn.Write(p) + m.bytesWritten.Add(int64(n)) + return n, err + } + written := 0 + for written < len(p) { + credit, err := m.awaitCredit() + if err != nil { + return written, err + } + size := min(len(p)-written, int(credit)) + n, err := m.Conn.Write(p[written : written+size]) + m.credit.Add(-int64(n)) + m.bytesWritten.Add(int64(n)) + written += n + if err != nil { + return written, err + } + } + return written, nil +} + +func (m *meteredConn) Grant(n int) { + m.credit.Add(int64(n)) + select { + case m.wake <- struct{}{}: + default: + } +} + +func (m *meteredConn) CloseWrite() error { + if cw, ok := m.Conn.(interface{ CloseWrite() error }); ok { + return cw.CloseWrite() + } + return errors.ErrUnsupported +} + +func (m *meteredConn) Close() error { + m.closeOnce.Do(func() { close(m.closed) }) + return m.Conn.Close() +} + +func (m *meteredConn) Stats() *MeteredStats { + st := &MeteredStats{ + BytesRead: m.bytesRead.Load(), + BytesWritten: m.bytesWritten.Load(), + WriteWait: time.Duration(m.writeWait.Load()), + WriteWaits: m.writeWaits.Load(), + } + if m.window > 0 { + st.Credit = m.credit.Load() + } + return st +} + +func (m *meteredConn) awaitCredit() (int64, error) { + var start time.Time + defer func() { + if !start.IsZero() { + m.writeWait.Add(int64(time.Since(start))) + } + }() + for { + if credit := m.credit.Load(); credit > 0 { + return credit, nil + } + if start.IsZero() { + start = time.Now() + m.writeWaits.Add(1) + } + select { + case <-m.wake: + case <-m.closed: + return 0, net.ErrClosed + case <-m.innerDone: + return 0, net.ErrClosed + } + } +} diff --git a/pkg/plumbing/metered_test.go b/pkg/plumbing/metered_test.go new file mode 100644 index 0000000..2b0831b --- /dev/null +++ b/pkg/plumbing/metered_test.go @@ -0,0 +1,127 @@ +package plumbing + +import ( + "bytes" + "errors" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingConn keeps what's written to it and reads from r. +type recordingConn struct { + net.Conn + r io.Reader + mu sync.Mutex + w bytes.Buffer + done chan struct{} +} + +func (c *recordingConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +func (c *recordingConn) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.w.Write(p) +} + +func (c *recordingConn) written() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.w.String() +} + +func (c *recordingConn) Close() error { return nil } + +func (c *recordingConn) Done() <-chan struct{} { return c.done } + +func newRecordingConn(r string) *recordingConn { + return &recordingConn{r: bytes.NewReader([]byte(r)), done: make(chan struct{})} +} + +// writeAsync starts a Write and returns its result. +func writeAsync(m MeteredConn, p string) <-chan error { + errc := make(chan error, 1) + go func() { + _, err := m.Write([]byte(p)) + errc <- err + }() + return errc +} + +func TestMeteredConnCountsWithoutAWindow(t *testing.T) { + inner := newRecordingConn("hello") + m := NewMeteredConn(inner, 0) + + _, err := m.Write([]byte("abc")) + require.NoError(t, err) + _, err = m.Read(make([]byte, 16)) + require.NoError(t, err) + + assert.Equal(t, "abc", inner.written()) + assert.Equal(t, &MeteredStats{BytesRead: 5, BytesWritten: 3}, m.Stats()) +} + +func TestMeteredConnHoldsWritesToTheWindow(t *testing.T) { + inner := newRecordingConn("") + m := NewMeteredConn(inner, 4) + + errc := writeAsync(m, "abcdefgh") + require.Eventually(t, func() bool { return inner.written() == "abcd" }, time.Second, time.Millisecond) + select { + case <-errc: + t.Fatal("wrote past the window") + case <-time.After(50 * time.Millisecond): + } + + m.Grant(4) + require.NoError(t, <-errc) + assert.Equal(t, "abcdefgh", inner.written()) + st := m.Stats() + assert.Equal(t, int64(0), st.Credit) + assert.Equal(t, int64(1), st.WriteWaits) + assert.Greater(t, st.WriteWait, time.Duration(0)) +} + +func TestMeteredConnWriteWaitingEndsWithTheConn(t *testing.T) { + m := NewMeteredConn(newRecordingConn(""), 1) + errc := writeAsync(m, "ab") + time.Sleep(10 * time.Millisecond) + require.NoError(t, m.Close()) + assert.ErrorIs(t, <-errc, net.ErrClosed) + + inner := newRecordingConn("") + m = NewMeteredConn(inner, 1) + errc = writeAsync(m, "ab") + time.Sleep(10 * time.Millisecond) + close(inner.done) + assert.ErrorIs(t, <-errc, net.ErrClosed) +} + +func TestMeteredConnCloseWrite(t *testing.T) { + assert.True(t, errors.Is(NewMeteredConn(newRecordingConn(""), 0).CloseWrite(), errors.ErrUnsupported)) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer lis.Close() + go func() { + nc, err := lis.Accept() + if err == nil { + _, _ = io.Copy(io.Discard, nc) + _ = nc.Close() + } + }() + nc, err := net.Dial("tcp", lis.Addr().String()) + require.NoError(t, err) + defer nc.Close() + m := NewMeteredConn(nc, 0) + require.NoError(t, m.CloseWrite()) + // The peer saw EOF and closed its side. + _, err = nc.Read(make([]byte, 1)) + assert.ErrorIs(t, err, io.EOF) +} diff --git a/pkg/plumbing/mux.go b/pkg/plumbing/mux.go new file mode 100644 index 0000000..daee405 --- /dev/null +++ b/pkg/plumbing/mux.go @@ -0,0 +1,218 @@ +package plumbing + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "sync" + "sync/atomic" +) + +// Frames writes one conn's frames onto a multiplexed transport. +type Frames interface { + // Data sends payload, which Frames may keep. + Data(payload []byte) error + // CloseWrite tells the peer this side has nothing more to send. A + // transport that can't returns errors.ErrUnsupported, and the conn ends + // instead. + CloseWrite() error + // Reset abandons the conn on the peer, for reason. + Reset(reason string) error + // Window grants the peer n more bytes, what Recv returned. A no-op + // without flow control. + Window(n int) error + // Release forgets the conn once it's over. + Release() +} + +// MuxConfig configures a MuxStream. +type MuxConfig struct { + // MaxFrame caps each Data's payload; 0 for no cap. + MaxFrame int + // Queue is how many payloads from the peer may wait unread before Deliver + // waits; 0 for no limit, for a transport whose window bounds them. + Queue int + // Window is what the conn may send before the peer grants more; 0 for no + // limit. + Window int + Local, Remote net.Addr +} + +// MuxStream is one conn's Stream on a multiplexed transport: Recv returns what +// the transport Delivers for it, and Send, CloseSend and Close write its +// frames. Conn is a net.Conn over it, metered to its window. +type MuxStream struct { + frames Frames + cfg MuxConfig + conn MeteredConn + + mu sync.Mutex + in [][]byte + eof bool + wake chan struct{} + room chan struct{} + + // Both sides have half-closed once both are set. + closeSent, eofRead atomic.Bool + done chan struct{} + once sync.Once +} + +var _ Stream = (*MuxStream)(nil) + +func NewMuxStream(frames Frames, cfg MuxConfig) *MuxStream { + s := &MuxStream{ + frames: frames, + cfg: cfg, + wake: make(chan struct{}, 1), + room: make(chan struct{}, 1), + done: make(chan struct{}), + } + s.conn = NewMeteredConn(NewStreamConn(s, cfg.Local, cfg.Remote), cfg.Window) + return s +} + +// Conn is the net.Conn over the stream. Grant it the peer's window. +func (s *MuxStream) Conn() MeteredConn { return s.conn } + +// Deliver queues payload from the peer, waiting while a bounded queue is +// full, until ctx ends. +func (s *MuxStream) Deliver(ctx context.Context, payload []byte) { + if len(payload) == 0 { + return + } + for { + s.mu.Lock() + if s.eof || s.isDone() { + s.mu.Unlock() + return + } + if s.cfg.Queue == 0 || len(s.in) < s.cfg.Queue { + s.in = append(s.in, payload) + s.mu.Unlock() + signal(s.wake) + return + } + s.mu.Unlock() + select { + case <-s.room: + case <-s.done: + return + case <-ctx.Done(): + return + } + } +} + +// PeerClosed: the peer has nothing more to send. Recv returns io.EOF once it +// has returned the rest. +func (s *MuxStream) PeerClosed() { + s.mu.Lock() + s.eof = true + s.mu.Unlock() + signal(s.wake) +} + +// PeerReset ends the conn: the peer abandoned it, so what it sent unread is +// dropped. +func (s *MuxStream) PeerReset() { s.end() } + +func (s *MuxStream) Recv() ([]byte, error) { + for { + if s.isDone() { + return nil, net.ErrClosed + } + s.mu.Lock() + if len(s.in) > 0 { + payload := s.in[0] + s.in[0] = nil + s.in = s.in[1:] + s.mu.Unlock() + signal(s.room) + if err := s.frames.Window(len(payload)); err != nil { + return nil, err + } + return payload, nil + } + eof := s.eof + s.mu.Unlock() + if eof { + s.eofRead.Store(true) + if s.closeSent.Load() { + s.end() + } + return nil, io.EOF + } + select { + case <-s.wake: + case <-s.done: + } + } +} + +func (s *MuxStream) Send(p []byte) error { + for len(p) > 0 { + if s.isDone() { + return net.ErrClosed + } + size := len(p) + if s.cfg.MaxFrame > 0 { + size = min(size, s.cfg.MaxFrame) + } + if err := s.frames.Data(bytes.Clone(p[:size])); err != nil { + return err + } + p = p[size:] + } + return nil +} + +func (s *MuxStream) CloseSend() error { + if s.isDone() || !s.closeSent.CompareAndSwap(false, true) { + return nil + } + err := s.frames.CloseWrite() + if errors.Is(err, errors.ErrUnsupported) || s.eofRead.Load() { + s.end() + return nil + } + return err +} + +// Close resets the conn. +func (s *MuxStream) Close() error { + if s.isDone() { + return nil + } + err := s.frames.Reset("connection closed") + s.end() + return err +} + +// Done is closed once the conn is over. +func (s *MuxStream) Done() <-chan struct{} { return s.done } + +func (s *MuxStream) end() { + s.once.Do(func() { + close(s.done) + s.frames.Release() + }) +} + +func (s *MuxStream) isDone() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +func signal(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} diff --git a/pkg/plumbing/mux_test.go b/pkg/plumbing/mux_test.go new file mode 100644 index 0000000..4d9a72d --- /dev/null +++ b/pkg/plumbing/mux_test.go @@ -0,0 +1,163 @@ +package plumbing + +import ( + "context" + "errors" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingFrames keeps the frames a MuxStream writes. +type recordingFrames struct { + mu sync.Mutex + data []string + windows []int + closes int + resets []string + releases int + halfClose bool +} + +func (f *recordingFrames) Data(p []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + f.data = append(f.data, string(p)) + return nil +} + +func (f *recordingFrames) CloseWrite() error { + if !f.halfClose { + return errors.ErrUnsupported + } + f.mu.Lock() + defer f.mu.Unlock() + f.closes++ + return nil +} + +func (f *recordingFrames) Reset(reason string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.resets = append(f.resets, reason) + return nil +} + +func (f *recordingFrames) Window(n int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.windows = append(f.windows, n) + return nil +} + +func (f *recordingFrames) Release() { + f.mu.Lock() + defer f.mu.Unlock() + f.releases++ +} + +func TestMuxStreamReceivesInOrderAndGrantsWhatItReturns(t *testing.T) { + f := &recordingFrames{halfClose: true} + s := NewMuxStream(f, MuxConfig{}) + ctx := context.Background() + s.Deliver(ctx, []byte("ab")) + s.Deliver(ctx, []byte("cde")) + s.PeerClosed() + + got, err := io.ReadAll(s.Conn()) + require.NoError(t, err) + assert.Equal(t, "abcde", string(got)) + assert.Equal(t, []int{2, 3}, f.windows) +} + +func TestMuxStreamBoundedQueueWaitsForRecv(t *testing.T) { + s := NewMuxStream(&recordingFrames{}, MuxConfig{Queue: 1}) + ctx := context.Background() + s.Deliver(ctx, []byte("a")) + + delivered := make(chan struct{}) + go func() { + s.Deliver(ctx, []byte("b")) + close(delivered) + }() + select { + case <-delivered: + t.Fatal("queued past the bound") + case <-time.After(50 * time.Millisecond): + } + _, err := s.Recv() + require.NoError(t, err) + select { + case <-delivered: + case <-time.After(time.Second): + t.Fatal("Recv made no room") + } + + // A cancelled ctx gives up a wait. + waiting, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + s.Deliver(waiting, []byte("c")) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Deliver kept waiting after ctx ended") + } +} + +func TestMuxStreamEndsOnceBothSidesHalfClose(t *testing.T) { + f := &recordingFrames{halfClose: true} + s := NewMuxStream(f, MuxConfig{}) + require.NoError(t, s.CloseSend()) + assert.Equal(t, 1, f.closes) + assert.Equal(t, 0, f.releases) + + s.PeerClosed() + _, err := s.Recv() + assert.ErrorIs(t, err, io.EOF) + assert.Equal(t, 1, f.releases) + require.NoError(t, s.Close()) + assert.Empty(t, f.resets, "a conn both sides closed isn't reset") +} + +func TestMuxStreamWithoutHalfCloseEndsAtCloseSend(t *testing.T) { + f := &recordingFrames{} + s := NewMuxStream(f, MuxConfig{}) + require.NoError(t, s.CloseSend()) + assert.Equal(t, 1, f.releases) + _, err := s.Recv() + assert.ErrorIs(t, err, net.ErrClosed) +} + +func TestMuxStreamSendsInFramesAndResets(t *testing.T) { + f := &recordingFrames{halfClose: true} + s := NewMuxStream(f, MuxConfig{MaxFrame: 2}) + require.NoError(t, s.Send([]byte("abcde"))) + assert.Equal(t, []string{"ab", "cd", "e"}, f.data) + + require.NoError(t, s.Close()) + assert.Equal(t, []string{"connection closed"}, f.resets) + assert.ErrorIs(t, s.Send([]byte("x")), net.ErrClosed) + require.NoError(t, s.Close()) + assert.Equal(t, 1, f.releases) +} + +func TestMuxStreamPeerResetDropsWhatWasUnread(t *testing.T) { + f := &recordingFrames{halfClose: true} + s := NewMuxStream(f, MuxConfig{}) + s.Deliver(context.Background(), []byte("unread")) + s.PeerReset() + + _, err := s.Recv() + assert.ErrorIs(t, err, net.ErrClosed) + assert.Empty(t, f.resets, "the peer already knows") + assert.Equal(t, 1, f.releases) +} diff --git a/pkg/proxy/grpcserver.go b/pkg/proxy/grpcserver.go index 75e37fa..96411af 100644 --- a/pkg/proxy/grpcserver.go +++ b/pkg/proxy/grpcserver.go @@ -15,6 +15,7 @@ import ( "github.com/vercel/bridge/pkg/grpcutil" "github.com/vercel/bridge/pkg/mitm" "github.com/vercel/bridge/pkg/tunnel" + "github.com/vercel/bridge/pkg/tunnel/bridgestream" bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" "google.golang.org/grpc" @@ -264,7 +265,7 @@ func (s *GRPCServer) TunnelNetwork(stream grpc.BidiStreamingServer[bridgev1.Tunn if s.hijacker != nil { opts = append(opts, tunnel.WithHijacker(s.hijacker)) } - tun := tunnel.New(&net.Dialer{Timeout: egressDialTimeout}, stream, opts...) + tun := tunnel.New(&net.Dialer{Timeout: egressDialTimeout}, bridgestream.New(stream), opts...) s.tunnelMu.Lock() s.tunnel = tun diff --git a/pkg/tunnel/bridgestream/bridgestream.go b/pkg/tunnel/bridgestream/bridgestream.go new file mode 100644 index 0000000..8f93721 --- /dev/null +++ b/pkg/tunnel/bridgestream/bridgestream.go @@ -0,0 +1,125 @@ +// Package bridgestream carries a tunnel over a bridge TunnelNetwork stream, +// translating its TunnelNetworkMessages to and from tunnel Messages. +// +// Every TunnelNetworkMessage addresses its conn, the first one to an unknown +// conn opens it, and one with an error ends it. The stream has no half-close +// and no flow control. +package bridgestream + +import ( + "log/slog" + "sync" + + "github.com/puzpuzpuz/xsync/v3" + bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/pkg/tunnel" +) + +// GRPCStream is a TunnelNetwork stream. Both the server-side +// BidiStreamingServer and the client-side TunnelNetworkClient satisfy it. +type GRPCStream interface { + Send(*bridgev1.TunnelNetworkMessage) error + Recv() (*bridgev1.TunnelNetworkMessage, error) +} + +// New adapts stream to a tunnel.Stream. +func New(stream GRPCStream) tunnel.Stream { + return &adapter{stream: stream, conns: xsync.NewMapOf[string, *tunnel.ConnInfo]()} +} + +type adapter struct { + stream GRPCStream + sendMu sync.Mutex + // Each open conn's addresses, as this side sends them. + conns *xsync.MapOf[string, *tunnel.ConnInfo] + // The Data that came with an Open, for the next Recv. Only Recv touches it. + pending *tunnel.Message +} + +func (a *adapter) Features() tunnel.Features { return tunnel.Features{} } + +func (a *adapter) Recv() (*tunnel.Message, error) { + if m := a.pending; m != nil { + a.pending = nil + return m, nil + } + for { + msg, err := a.stream.Recv() + if err != nil { + return nil, err + } + id := msg.GetConnectionId() + _, open := a.conns.Load(id) + if msg.GetError() != "" { + if open { + a.conns.Delete(id) + return &tunnel.Message{Kind: tunnel.MessageReset, ConnID: id, Reason: msg.GetError()}, nil + } + continue + } + data := &tunnel.Message{Kind: tunnel.MessageData, ConnID: id, Payload: msg.GetData()} + if open { + return data, nil + } + dest := msg.GetDest() + if dest == nil { + slog.Info("Tunnel: ignoring message with no dest", "conn_id", id) + continue + } + info := &tunnel.ConnInfo{ + Source: address(msg.GetSource()), + Dest: address(dest), + Hostname: msg.GetHostname(), + } + a.conns.Store(id, info.Reply()) + if len(data.Payload) > 0 { + a.pending = data + } + return &tunnel.Message{Kind: tunnel.MessageOpen, ConnID: id, Conn: info}, nil + } +} + +func (a *adapter) Send(m *tunnel.Message) error { + switch m.Kind { + case tunnel.MessageOpen: + // The conn's first Data opens it. + a.conns.Store(m.ConnID, m.Conn) + return nil + case tunnel.MessageData: + info, ok := a.conns.Load(m.ConnID) + if !ok { + return nil + } + return a.send(&bridgev1.TunnelNetworkMessage{ + ConnectionId: m.ConnID, + Source: tunnelAddress(info.Source), + Dest: tunnelAddress(info.Dest), + Hostname: info.Hostname, + Data: m.Payload, + }) + case tunnel.MessageCloseWrite: + // No half-close: the conn ends here, and the peer isn't told. + a.conns.Delete(m.ConnID) + return nil + case tunnel.MessageReset: + a.conns.Delete(m.ConnID) + return a.send(&bridgev1.TunnelNetworkMessage{ConnectionId: m.ConnID, Error: m.Reason}) + default: + return nil + } +} + +// send serializes Sends, which the stream doesn't take concurrently. +func (a *adapter) send(msg *bridgev1.TunnelNetworkMessage) error { + a.sendMu.Lock() + defer a.sendMu.Unlock() + return a.stream.Send(msg) +} + +func address(a *bridgev1.TunnelAddress) tunnel.Addr { + return tunnel.Addr{Host: a.GetIp(), Port: int(a.GetPort())} +} + +func tunnelAddress(a tunnel.Addr) *bridgev1.TunnelAddress { + return &bridgev1.TunnelAddress{Ip: a.Host, Port: int32(a.Port)} +} diff --git a/pkg/tunnel/tunnel_test.go b/pkg/tunnel/bridgestream/bridgestream_test.go similarity index 96% rename from pkg/tunnel/tunnel_test.go rename to pkg/tunnel/bridgestream/bridgestream_test.go index 6b51fab..541986f 100644 --- a/pkg/tunnel/tunnel_test.go +++ b/pkg/tunnel/bridgestream/bridgestream_test.go @@ -1,4 +1,4 @@ -package tunnel +package bridgestream import ( "context" @@ -11,6 +11,7 @@ import ( "time" bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" + "github.com/vercel/bridge/pkg/tunnel" ) const testTimeout = 5 * time.Second @@ -148,17 +149,18 @@ func waitFor(t *testing.T, what string, cond func() bool) { t.Fatalf("timed out waiting for %s", what) } -func startTunnel(t *testing.T, dialer *blockingDialer) (*fakeStream, Tunnel) { +func startTunnel(t *testing.T, dialer *blockingDialer) (*fakeStream, *adapter) { t.Helper() stream := newFakeStream() - tun := New(dialer, stream) + a := New(stream).(*adapter) + tun := tunnel.New(dialer, a) ctx, cancel := context.WithCancel(context.Background()) tun.Start(ctx) t.Cleanup(func() { cancel() tun.Close() }) - return stream, tun + return stream, a } func TestChunksArrivingDuringDialShareOneConnection(t *testing.T) { @@ -214,14 +216,14 @@ func TestDialFailureReportsErrorAndForgetsConnection(t *testing.T) { func TestPeerErrorDuringDialClosesTheDialedConnection(t *testing.T) { dialer := newBlockingDialer() - stream, tun := startTunnel(t, dialer) + stream, a := startTunnel(t, dialer) const connID = "172.17.0.3:55988->172.20.143.45:80" stream.in <- dataMsg(connID, "hello") waitFor(t, "first dial", func() bool { return dialer.dials.Load() == 1 }) stream.in <- &bridgev1.TunnelNetworkMessage{ConnectionId: connID, Error: "peer closed"} waitFor(t, "the peer error to drop the buffered connection", func() bool { - _, loaded := tun.(*tunnelImpl).conns.Load(connID) + _, loaded := a.conns.Load(connID) return !loaded }) diff --git a/pkg/tunnel/message.go b/pkg/tunnel/message.go new file mode 100644 index 0000000..90d864c --- /dev/null +++ b/pkg/tunnel/message.go @@ -0,0 +1,82 @@ +package tunnel + +import ( + "net" + "strconv" +) + +// Stream carries a tunnel's Messages over one transport: a bridge +// TunnelNetwork stream (bridgestream), or a dev tunnel's Connect stream +// (devtunnel). It translates them to and from the transport's own. +type Stream interface { + // Send sends m to the peer. It's called from many goroutines at once. + Send(m *Message) error + // Recv returns the peer's next Message. An error ends the tunnel. + Recv() (*Message, error) + // Features says what the transport carries beyond Open, Data and Reset. + Features() Features +} + +// Features are what a transport carries beyond Open, Data and Reset. +type Features struct { + // Window is what each side of a conn may send before the other grants + // more with a Window message; 0 for no flow control. + Window int + // HalfClose: a CloseWrite reaches the peer, which can still send. Without + // it, a conn ends at either side's EOF. + HalfClose bool +} + +type MessageKind uint8 + +const ( + // A new conn to Conn.Dest. + MessageOpen MessageKind = iota + 1 + // Payload for a conn. + MessageData + // The sender has nothing more to send on the conn. + MessageCloseWrite + // The conn is abandoned in both directions, for Reason. + MessageReset + // The receiver may send Bytes more of the conn's Data. + MessageWindow +) + +// Message is one message of the tunnel's own protocol, about one conn. +type Message struct { + Kind MessageKind + ConnID string + // Open only. + Conn *ConnInfo + Payload []byte + Reason string + Bytes int +} + +// Addr is a host (an IP address or a hostname) and a port. +type Addr struct { + Host string + Port int +} + +func (a Addr) String() string { return net.JoinHostPort(a.Host, strconv.Itoa(a.Port)) } + +// ConnInfo addresses a conn, as the side opening it sees it. +type ConnInfo struct { + Source, Dest Addr + // The DNS name Dest was looked up as, if any. + Hostname string +} + +// Reply addresses the other way: from where the conn was headed, back to +// where it came from. +func (c *ConnInfo) Reply() *ConnInfo { + return &ConnInfo{Source: c.Dest, Dest: c.Source, Hostname: c.Hostname} +} + +// SplitAddr parses "host:port". +func SplitAddr(s string) Addr { + host, portStr, _ := net.SplitHostPort(s) + port, _ := strconv.Atoi(portStr) + return Addr{Host: host, Port: port} +} diff --git a/pkg/tunnel/pump_test.go b/pkg/tunnel/pump_test.go new file mode 100644 index 0000000..675abdf --- /dev/null +++ b/pkg/tunnel/pump_test.go @@ -0,0 +1,129 @@ +package tunnel + +import ( + "context" + "io" + "net" + "testing" + "time" +) + +const testTimeout = 5 * time.Second + +// fakeStream hands the tunnel the messages a test gives it, and keeps what the +// tunnel sends. +type fakeStream struct { + features Features + in chan *Message + out chan *Message +} + +func newFakeStream(f Features) *fakeStream { + return &fakeStream{features: f, in: make(chan *Message, 64), out: make(chan *Message, 1024)} +} + +func (s *fakeStream) Send(m *Message) error { s.out <- m; return nil } +func (s *fakeStream) Features() Features { return s.features } + +func (s *fakeStream) Recv() (*Message, error) { + m, ok := <-s.in + if !ok { + return nil, io.EOF + } + return m, nil +} + +// next returns the next message the tunnel sent other than a window grant. +func (s *fakeStream) next(t *testing.T) *Message { + t.Helper() + for { + select { + case m := <-s.out: + if m.Kind != MessageWindow { + return m + } + case <-time.After(testTimeout): + t.Fatal("the tunnel sent nothing") + } + } +} + +func startTunnel(t *testing.T, s Stream) Tunnel { + t.Helper() + tun := New(&net.Dialer{Timeout: time.Second}, s) + tun.Start(context.Background()) + t.Cleanup(func() { tun.Close() }) + return tun +} + +func TestHalfCloseCrossesThePump(t *testing.T) { + app, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer app.Close() + got := make(chan string, 1) + go func() { + c, err := app.Accept() + if err != nil { + return + } + // Reads to the peer's half-close, then answers and closes. + req, _ := io.ReadAll(c) + got <- string(req) + _, _ = c.Write([]byte("bye")) + _ = c.Close() + }() + s := newFakeStream(Features{Window: 1 << 20, HalfClose: true}) + startTunnel(t, s) + + s.in <- &Message{Kind: MessageOpen, ConnID: "1", Conn: &ConnInfo{Dest: SplitAddr(app.Addr().String())}} + s.in <- &Message{Kind: MessageData, ConnID: "1", Payload: []byte("hi")} + s.in <- &Message{Kind: MessageCloseWrite, ConnID: "1"} + + select { + case req := <-got: + if req != "hi" { + t.Fatalf("the app read %q", req) + } + case <-time.After(testTimeout): + t.Fatal("the app never saw the peer's half-close") + } + if m := s.next(t); m.Kind != MessageData || string(m.Payload) != "bye" { + t.Fatalf("expected the app's answer, got %+v", m) + } + if m := s.next(t); m.Kind != MessageCloseWrite { + t.Fatalf("expected the app's half-close, got %+v", m) + } +} + +func TestWritesWaitForWindow(t *testing.T) { + s := newFakeStream(Features{Window: 10, HalfClose: true}) + tun := startTunnel(t, s) + app, intercepted := net.Pipe() + defer app.Close() + tun.AddConn(intercepted, "10.0.0.1:443", "example.com") + open := s.next(t) + if open.Kind != MessageOpen || open.Conn.Hostname != "example.com" || open.Conn.Dest.Port != 443 { + t.Fatalf("expected the open, got %+v", open) + } + + go func() { _, _ = app.Write([]byte("0123456789abcdefghijklmno")) }() + sent := "" + for len(sent) < 10 { + sent += string(s.next(t).Payload) + } + select { + case m := <-s.out: + t.Fatalf("sent past the window: %+v", m) + case <-time.After(100 * time.Millisecond): + } + + s.in <- &Message{Kind: MessageWindow, ConnID: open.ConnID, Bytes: 15} + for len(sent) < 25 { + sent += string(s.next(t).Payload) + } + if sent != "0123456789abcdefghijklmno" { + t.Fatalf("sent %q", sent) + } +} diff --git a/pkg/tunnel/tunnel.go b/pkg/tunnel/tunnel.go index 4755f22..3d0ea9c 100644 --- a/pkg/tunnel/tunnel.go +++ b/pkg/tunnel/tunnel.go @@ -2,40 +2,42 @@ package tunnel import ( "context" - "fmt" + "errors" "io" "log/slog" "net" - "strconv" "sync" "github.com/puzpuzpuz/xsync/v3" - bridgev1 "github.com/vercel/bridge/api/go/bridge/v1" - "github.com/vercel/bridge/pkg/ioutil" "github.com/vercel/bridge/pkg/mitm" "github.com/vercel/bridge/pkg/plumbing" ) -// Stream abstracts a bidirectional gRPC stream. Both the server-side -// BidiStreamingServer and client-side TunnelNetworkClient satisfy this -// interface directly since both directions use TunnelNetworkMessage. -type Stream interface { - Send(*bridgev1.TunnelNetworkMessage) error - Recv() (*bridgev1.TunnelNetworkMessage, error) -} +const ( + // Each direction of a conn copies through one buffer of this size. + copyBufferSize = 32 << 10 + // Per Data message. + maxPayload = 64 << 10 + // What a conn may have queued from a peer without flow control before the + // tunnel waits for it to be read. + queuedPayloads = 64 +) -// Tunnel is a wrapper around a Tunnel gRPC stream that is responsible for managing the connections it is currently multiplexing. It is intended to be used by both client and server-side +// Tunnel multiplexes conns over one Stream. It is used on both ends: the +// intercept and the bridge server, or the intercept and a dev tunnel's relay. type Tunnel interface { - // AddConn Adds a connection to the map of connections that are tracked under this Tunnel. When a conn is added, it is placed into a sync map and a goroutine is spawned that reads from the conn. Whenever data is received from the conn, its data is forwarded to the underlying gRPC stream. A destination override may be provided to override the call for original destination from the Conn. The hostname is the DNS name associated with this connection (may be empty). + // AddConn sends conn through the tunnel, to destOverride if given or else + // where conn was headed. hostname is the DNS name the destination was + // looked up as (may be empty). AddConn(conn net.Conn, destOverride string, hostname string) - // Start starts the pump for the Tunnel. It has a loop for receiving messages which are routed based on their connection ID. + // Start reads the stream, dialing a local conn for each the peer opens. Start(ctx context.Context) // Close cancels the tunnel context, closing all connections and stopping all goroutines. Close() error - // Done returns a channel that is closed when the recv pump exits. + // Done returns a channel that is closed when the tunnel stops. Done() <-chan struct{} } @@ -48,15 +50,23 @@ func WithHijacker(h mitm.Hijacker) Option { return func(t *tunnelImpl) { t.hijacker = h } } -// New creates a Tunnel. When a message is received down the stream and a corresponding connection ID cannot be found, the dialer is called to instantiate that connection. The client-side uses the StaticPortDialer while the server-side uses a standard ContextDialer. +// New creates a Tunnel over stream. A conn the peer opens is dialed with +// dialer: the client side uses the StaticPortDialer while the server side uses +// a standard ContextDialer. func New(dialer plumbing.ContextDialer, stream Stream, opts ...Option) Tunnel { t := &tunnelImpl{ - dialer: dialer, - stream: stream, - sendCh: make(chan *bridgev1.TunnelNetworkMessage, 64), - conns: xsync.NewMapOf[string, io.ReadWriteCloser](), - done: make(chan struct{}), + dialer: dialer, + stream: stream, + features: stream.Features(), + conns: xsync.NewMapOf[string, *conn](), + done: make(chan struct{}), + logger: slog.Default(), } + t.bufs.New = func() any { + buf := make([]byte, copyBufferSize) + return &buf + } + t.ctx, t.cancel = context.WithCancel(context.Background()) for _, o := range opts { o(t) } @@ -67,8 +77,10 @@ type tunnelImpl struct { dialer plumbing.ContextDialer hijacker mitm.Hijacker stream Stream - sendCh chan *bridgev1.TunnelNetworkMessage - conns *xsync.MapOf[string, io.ReadWriteCloser] + features Features + conns *xsync.MapOf[string, *conn] + bufs sync.Pool + logger *slog.Logger done chan struct{} ctx context.Context cancel context.CancelFunc @@ -77,229 +89,249 @@ type tunnelImpl struct { func (t *tunnelImpl) Done() <-chan struct{} { return t.done } -func (t *tunnelImpl) AddConn(conn net.Conn, destOverride string, hostname string) { - srcAddr := conn.RemoteAddr().String() - dstAddr := conn.LocalAddr().String() - if destOverride != "" { - dstAddr = destOverride - } - - srcHost, srcPortStr, _ := net.SplitHostPort(srcAddr) - srcPort, _ := strconv.Atoi(srcPortStr) - - dstHost, dstPortStr, _ := net.SplitHostPort(dstAddr) - dstPort, _ := strconv.Atoi(dstPortStr) - - connID := plumbing.ConnectionID(srcHost, srcPort, dstHost, dstPort) - src := &bridgev1.TunnelAddress{Ip: srcHost, Port: int32(srcPort)} - dst := &bridgev1.TunnelAddress{Ip: dstHost, Port: int32(dstPort)} - - slog.Debug("Tunnel: adding connection", "conn_id", connID, "src", srcAddr, "dst", dstAddr, "hostname", hostname) - - t.conns.Store(connID, conn) - go t.readFromConn(conn, connID, src, dst, hostname) -} - -// readFromConn reads from a connection and forwards data to the stream via sendCh. -func (t *tunnelImpl) readFromConn(conn io.ReadCloser, connID string, src, dst *bridgev1.TunnelAddress, hostname string) { - defer func() { - conn.Close() - t.conns.Delete(connID) - }() - - buf := make([]byte, 32*1024) - for { - n, err := conn.Read(buf) - if n > 0 { - data := make([]byte, n) - copy(data, buf[:n]) +func (t *tunnelImpl) Start(ctx context.Context) { + stop := context.AfterFunc(ctx, t.cancel) + recv := make(chan *Message) + recvErr := make(chan error, 1) + go func() { + for { + m, err := t.stream.Recv() + if err != nil { + recvErr <- err + return + } select { - case t.sendCh <- &bridgev1.TunnelNetworkMessage{ - ConnectionId: connID, - Source: src, - Dest: dst, - Data: data, - Hostname: hostname, - }: + case recv <- m: case <-t.ctx.Done(): return } } - if err != nil { - return + }() + go func() { + defer stop() + defer t.Close() + for { + select { + case m := <-recv: + t.handle(m) + case err := <-recvErr: + if errors.Is(err, io.EOF) { + t.logger.Info("Tunnel: stream closed (EOF)") + } else { + t.logger.Info("Tunnel: stream recv error", "error", err) + } + return + case <-t.ctx.Done(): + return + } } - } + }() } func (t *tunnelImpl) Close() error { t.closeOnce.Do(func() { t.cancel() - t.closeAll() + t.conns.Range(func(_ string, c *conn) bool { + c.stream.PeerReset() + return true + }) close(t.done) }) return nil } -func (t *tunnelImpl) Start(ctx context.Context) { - t.ctx, t.cancel = context.WithCancel(ctx) +func (t *tunnelImpl) AddConn(local net.Conn, destOverride string, hostname string) { + srcAddr := local.RemoteAddr().String() + dstAddr := local.LocalAddr().String() + if destOverride != "" { + dstAddr = destOverride + } + info := &ConnInfo{Source: SplitAddr(srcAddr), Dest: SplitAddr(dstAddr), Hostname: hostname} + id := plumbing.ConnectionID(info.Source.Host, info.Source.Port, info.Dest.Host, info.Dest.Port) + logger := t.logger.With("conn_id", id, "src", srcAddr, "dst", dstAddr, "hostname", hostname) + logger.Debug("Tunnel: adding connection") - // Single goroutine drains the send channel onto the stream. + c := t.newConn(id, info) go func() { - for { - select { - case msg, ok := <-t.sendCh: - if !ok { - return - } - if err := t.stream.Send(msg); err != nil { - slog.Info("Tunnel: stream send error", "conn_id", msg.GetConnectionId(), "error", err) - return - } - slog.Debug("Tunnel: sent", "conn_id", msg.GetConnectionId(), "bytes", len(msg.GetData())) - case <-t.ctx.Done(): - return - } + if err := t.stream.Send(&Message{Kind: MessageOpen, ConnID: id, Conn: info}); err != nil { + logger.Info("Tunnel: open failed", "error", err) + c.stream.PeerReset() + _ = local.Close() + return } + t.pump(local, c) }() +} - // Recv pump runs until the stream closes or context is cancelled. - go func() { - defer t.Close() +// handle routes one of the peer's messages to its conn. +func (t *tunnelImpl) handle(m *Message) { + if m.Kind == MessageOpen { + t.open(m) + return + } + c, ok := t.conns.Load(m.ConnID) + if !ok { + return + } + switch m.Kind { + case MessageData: + c.stream.Deliver(t.ctx, m.Payload) + case MessageCloseWrite: + c.stream.PeerClosed() + case MessageReset: + c.stream.PeerReset() + case MessageWindow: + c.stream.Conn().Grant(m.Bytes) + } +} - recvCh := make(chan *bridgev1.TunnelNetworkMessage) - recvErr := make(chan error, 1) - go func() { - for { - msg, err := t.stream.Recv() - if err != nil { - recvErr <- err - return - } - recvCh <- msg - } - }() +// open registers a conn the peer opened before dialing it, so its Data queues +// on it during the dial. +func (t *tunnelImpl) open(m *Message) { + if m.Conn == nil { + t.logger.Info("Tunnel: ignoring open with no dest", "conn_id", m.ConnID) + return + } + c := t.newConn(m.ConnID, m.Conn.Reply()) + go func() { + local, err := t.dial(m.Conn) + if err != nil { + t.logger.Info("Tunnel: connect failed", "conn_id", m.ConnID, "hostname", m.Conn.Hostname, "error", err) + _ = c.stream.Close() + return + } + t.pump(local, c) + }() +} - for { - select { - case msg := <-recvCh: - connID := msg.GetConnectionId() - - // Route to existing connection. - if conn, ok := t.conns.Load(connID); ok { - if msg.GetError() != "" { - conn.Close() - t.conns.Delete(connID) - continue - } - if data := msg.GetData(); len(data) > 0 { - if _, err := conn.Write(data); err != nil { - slog.Debug("Write to conn failed", "connection_id", connID, "error", err) - conn.Close() - t.conns.Delete(connID) - } - } - continue - } +// dial gives the hijacker first shot at a conn the peer opened, then falls +// through to the dialer. +func (t *tunnelImpl) dial(info *ConnInfo) (net.Conn, error) { + dest := info.Dest.String() + if t.hijacker != nil && t.hijacker.ShouldHijack(info.Hostname) { + local, err := t.hijacker.Hijack(t.ctx, info.Hostname, dest) + if err == nil { + return local, nil + } + t.logger.Info("Tunnel: hijack failed, falling back to dialer", "hostname", info.Hostname, "error", err) + } + return t.dialer.DialContext(t.ctx, "tcp", dest) +} - // Unknown connection ID → this message opens it. - t.openConn(msg) +// pump copies between local and c in both directions, each through a buffer +// from the pool, then closes both. Either side's EOF half-closes the other; a +// failure either way closes both, which resets c. +func (t *tunnelImpl) pump(local net.Conn, c *conn) { + remote := c.stream.Conn() + toRemote := t.bufs.Get().(*[]byte) + fromRemote := t.bufs.Get().(*[]byte) + defer func() { + t.bufs.Put(toRemote) + t.bufs.Put(fromRemote) + }() - case err := <-recvErr: - if err != io.EOF { - slog.Info("Tunnel: stream recv error", "error", err) - } else { - slog.Info("Tunnel: stream closed (EOF)") - } - return + // Once the conn is over, reading remote fails and aborts too. + abort := sync.OnceFunc(func() { + _ = remote.Close() + _ = local.Close() + }) - case <-t.ctx.Done(): - return - } - } + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + copyHalf(remote, local, *toRemote, abort) }() + go func() { + defer wg.Done() + copyHalf(local, remote, *fromRemote, abort) + }() + wg.Wait() + abort() + + st := remote.Stats() + t.logger.Debug("Tunnel: connection closed", + "conn_id", c.id, + "bytes_sent", st.BytesWritten, + "bytes_received", st.BytesRead, + "window_wait", st.WriteWait, + "window_waits", st.WriteWaits, + ) } -// openConn registers a buffered connection for msg's ID before dialing. The -// peer has no explicit open message: the first chunk of a connection is what -// opens it, and the next chunks can arrive before the dial completes. Storing -// the buffer synchronously on the recv pump makes those chunks queue behind -// the first one instead of each dialing a second connection under the same ID -// and splitting the stream between them. -func (t *tunnelImpl) openConn(msg *bridgev1.TunnelNetworkMessage) { - connID := msg.GetConnectionId() - if msg.GetDest() == nil { - slog.Info("Tunnel: ignoring message with no dest", "conn_id", connID) +// copyHalf copies src to dst until src ends, then half-closes dst. +func copyHalf(dst, src net.Conn, buf []byte, abort func()) { + // Hides ReadFrom and WriteTo, which would copy through buffers of their own. + _, err := io.CopyBuffer(struct{ io.Writer }{dst}, struct{ io.Reader }{src}, buf) + if err != nil { + abort() return } - - buffered := ioutil.NewBufferedReadWriteCloser() - t.conns.Store(connID, buffered) - if data := msg.GetData(); len(data) > 0 { - _, _ = buffered.Write(data) + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() } - go t.dial(msg, buffered) } -func (t *tunnelImpl) dial(msg *bridgev1.TunnelNetworkMessage, buffered ioutil.BufferedReadWriteCloser) { - connID := msg.GetConnectionId() - hostname := msg.GetHostname() - dest := msg.GetDest() - - // Resolve the connection: hijacker gets first shot, then fall through to the dialer. - var conn net.Conn - var err error +// conn is one of the tunnel's conns, as the plumbing.Frames its MuxStream +// writes: each frame is a Message on the tunnel's Stream. +type conn struct { + t *tunnelImpl + id string + stream *plumbing.MuxStream +} - if t.hijacker != nil && t.hijacker.ShouldHijack(msg) { - conn, err = t.hijacker.Hijack(t.ctx, msg) - if err != nil { - slog.Info("Tunnel: hijack failed, falling back to dialer", "conn_id", connID, "hostname", hostname, "error", err) - } +// newConn registers a conn that sends as info addresses it. +func (t *tunnelImpl) newConn(id string, info *ConnInfo) *conn { + c := &conn{t: t, id: id} + queue := 0 + if t.features.Window == 0 { + queue = queuedPayloads } + c.stream = plumbing.NewMuxStream(c, plumbing.MuxConfig{ + MaxFrame: maxPayload, + Queue: queue, + Window: t.features.Window, + Local: addr(info.Source.String()), + Remote: addr(info.Dest.String()), + }) + t.conns.Store(id, c) + return c +} - if conn == nil { - addr := fmt.Sprintf("%s:%d", dest.GetIp(), dest.GetPort()) - conn, err = t.dialer.DialContext(t.ctx, "tcp", addr) - } +func (c *conn) Data(payload []byte) error { + return c.t.stream.Send(&Message{Kind: MessageData, ConnID: c.id, Payload: payload}) +} - if err != nil { - slog.Info("Tunnel: connect failed", "conn_id", connID, "hostname", hostname, "error", err) - t.deleteIfSame(connID, buffered) - buffered.Close() - select { - case t.sendCh <- &bridgev1.TunnelNetworkMessage{ - ConnectionId: connID, - Error: err.Error(), - }: - case <-t.ctx.Done(): - } - return +func (c *conn) CloseWrite() error { + err := c.t.stream.Send(&Message{Kind: MessageCloseWrite, ConnID: c.id}) + if !c.t.features.HalfClose { + return errors.ErrUnsupported } + return err +} - if err := buffered.Set(conn); err != nil { - slog.Debug("Tunnel: dropping dialed connection", "connection_id", connID, "error", err) - t.deleteIfSame(connID, buffered) - return - } +func (c *conn) Reset(reason string) error { + return c.t.stream.Send(&Message{Kind: MessageReset, ConnID: c.id, Reason: reason}) +} - go t.readFromConn(buffered, connID, dest, msg.GetSource(), hostname) +func (c *conn) Window(n int) error { + if c.t.features.Window == 0 { + return nil + } + return c.t.stream.Send(&Message{Kind: MessageWindow, ConnID: c.id, Bytes: n}) } -// deleteIfSame removes connID only while it still maps to conn, so a connection -// the peer has since reopened under the same ID is left alone. -func (t *tunnelImpl) deleteIfSame(connID string, conn ioutil.BufferedReadWriteCloser) { - t.conns.Compute(connID, func(cur io.ReadWriteCloser, loaded bool) (io.ReadWriteCloser, bool) { +// Release forgets the conn, unless another has since taken its id. +func (c *conn) Release() { + c.t.conns.Compute(c.id, func(cur *conn, loaded bool) (*conn, bool) { if !loaded { return nil, true } - b, ok := cur.(ioutil.BufferedReadWriteCloser) - return cur, ok && b == conn + return cur, cur == c }) } -func (t *tunnelImpl) closeAll() { - t.conns.Range(func(key string, conn io.ReadWriteCloser) bool { - conn.Close() - t.conns.Delete(key) - return true - }) -} +type addr string + +func (a addr) Network() string { return "tunnel" } +func (a addr) String() string { return string(a) } diff --git a/proto/bridge/v1/interceptor.proto b/proto/bridge/v1/interceptor.proto index bccc13c..92c0a99 100644 --- a/proto/bridge/v1/interceptor.proto +++ b/proto/bridge/v1/interceptor.proto @@ -19,6 +19,12 @@ service InterceptorService { // immediate check passed. Useful as a "is the app healthy right now?" // primitive that ignores any stale state from a previous dev session. rpc Probe(ProbeRequest) returns (ProbeResponse); + + // SetTunnelToken gives an intercept on a dev tunnel + // (--tunnel-protocol=dev-tunnel) its client token, from create-session. One + // started without a token, or whose token the relay refused + // (TUNNEL_STATE_NEEDS_TOKEN), connects with it. + rpc SetTunnelToken(SetTunnelTokenRequest) returns (SetTunnelTokenResponse); } message GetStatusRequest {} @@ -39,8 +45,63 @@ message GetStatusResponse { // Startup probe result against the local app. Unset if no startup // probe was configured on the source. ProbeStatus startup = 4; + + // The dev tunnel's stream to its relay. Unset unless + // --tunnel-protocol=dev-tunnel. + TunnelStatus tunnel = 5; } +// TunnelStatus is the state of a dev tunnel's stream to api-dev-tunnel, and +// the conns it carries. +message TunnelStatus { + TunnelState state = 1; + + // Why the last stream ended, or the relay refused one. Empty before any + // has. + string last_error = 2 [json_name = "last_error"]; + + // Unix seconds since a stream has held the tunnel. Zero unless LIVE. + int64 live_since_unix_seconds = 3 [json_name = "live_since_unix_seconds"]; + + // Streams opened after the first. + int64 reconnects = 4; + + // Conns open now from the deployment to the app (ingress), and from the + // app out through the deployment (egress). + int64 ingress_conns = 5 [json_name = "ingress_conns"]; + int64 egress_conns = 6 [json_name = "egress_conns"]; + + // Conns opened since the intercept started. + int64 ingress_conns_total = 7 [json_name = "ingress_conns_total"]; + int64 egress_conns_total = 8 [json_name = "egress_conns_total"]; + + // Payload bytes sent to and received from the relay. + int64 bytes_sent = 9 [json_name = "bytes_sent"]; + int64 bytes_received = 10 [json_name = "bytes_received"]; +} + +enum TunnelState { + TUNNEL_STATE_UNSPECIFIED = 0; + // No stream holds the tunnel yet, or the last one ended and the next is + // opening. + TUNNEL_STATE_CONNECTING = 1; + // A stream holds the tunnel: the deployment's conns reach the app. + TUNNEL_STATE_LIVE = 2; + // The relay refused the client token, or there is none yet. Waiting for + // SetTunnelToken. + TUNNEL_STATE_NEEDS_TOKEN = 3; + // Another client holds the tunnel. Retrying until it lets go. + TUNNEL_STATE_TAKEN = 4; + // The relay refused the tunnel for good; last_error says why. + TUNNEL_STATE_FAILED = 5; +} + +message SetTunnelTokenRequest { + string token = 1; +} + +message SetTunnelTokenResponse {} + // ProbeStatus reflects the current health view from a single configured // probe — what the source deployment's probe would have reported if it had // been run against the developer's local app. diff --git a/proto/vercel/api/dev_tunnel/v1/tunnel.proto b/proto/vercel/api/dev_tunnel/v1/tunnel.proto new file mode 100644 index 0000000..67252cd --- /dev/null +++ b/proto/vercel/api/dev_tunnel/v1/tunnel.proto @@ -0,0 +1,144 @@ +// Vendored from vercel/api, services/api-dev-tunnel/proto/tunnel/v1/tunnel.proto. +// Edit it there and copy it here. + +syntax = "proto3"; + +package vercel.api.dev_tunnel.v1; + +// Relays TCP connections between the tunnel's deployment and the developer's +// machine. Payloads are opaque bytes; nothing here interprets them. +service TunnelService { + // The stream `vc dev --tunnel` holds open, carrying every conn the + // deployment dials and every conn the client opens through the deployment. + // Authenticated with the client token + // (`authorization: Bearer`), which the next create-session replaces: a CLI + // refused with UNAUTHENTICATED on a reconnect asks for a new one. + // + // One client per tunnel: another stream fails with ALREADY_EXISTS, unless it + // carries the same `x-vercel-dev-tunnel-instance` (1 to 64 letters, digits + // or dashes, one per CLI process). Then it replaces the old stream, which + // ends with ABORTED, so a CLI can reconnect while its old stream is still + // open on the service. The CLI sends a Heartbeat at least every 30s; a + // stream silent for three ends with DEADLINE_EXCEEDED, and one ending with + // UNAVAILABLE means reconnect. A CLI that stops reading the stream while + // the service has frames for it has it end with RESOURCE_EXHAUSTED. + // + // A pod shutting down sends Reconnect and refuses new streams with + // UNAVAILABLE. Once the CLI's new stream holds the tunnel elsewhere, the old + // one gets no new conns and ends with ABORTED when its open ones are done. + rpc Connect(stream ConnectRequest) returns (stream ConnectResponse); + + // One conn from the deployment: a TCP connection to a port on the + // developer's machine. The first message is the target; the rest carry + // payload. Ending the request stream half-closes the conn, and `end` says the + // developer's side has; the call ends once both have, and fails if either + // side resets. With no client connected, it waits up to 5s for one, then + // fails with UNAVAILABLE. A tunnel carries at most 256 conns at once; more + // wait up to 30s for one to end, then fail with RESOURCE_EXHAUSTED, as do + // any past 256 waiting. Authenticated with the server token + // (`authorization: Bearer`). + rpc Dial(stream DialRequest) returns (stream DialResponse); +} + +enum Protocol { + PROTOCOL_UNSPECIFIED = 0; + PROTOCOL_TCP = 1; +} + +// CLI to service. +message ConnectRequest { + oneof frame { + Data data = 1; + Close close = 2; + Reset reset = 3; + Heartbeat heartbeat = 4; + Window window = 5; + Open open = 6; + } +} + +// Service to CLI. +message ConnectResponse { + oneof frame { + Open open = 1; + Data data = 2; + Close close = 3; + Reset reset = 4; + Heartbeat heartbeat = 5; + Window window = 6; + Reconnect reconnect = 7; + } +} + +// A new conn. From the service, the CLI connects to `port` on the developer's +// machine, or resets the conn if it doesn't serve that port. From the client, +// the deployment connects to `host` and `port`, resolving `host` as its own +// code would, and the service resets the conn if it can't. The service numbers +// its conns from 1; a client numbers its own from 2^63, so the two never meet. +message Open { + uint64 conn_id = 1; + Protocol protocol = 2; + uint32 port = 3; + // A hostname or IP address. Only a client's Open has one. + string host = 4; +} + +// No more than the conn's window allows (see Window). +message Data { + uint64 conn_id = 1; + bytes payload = 2; +} + +// The sender has nothing more to send (TCP half-close). A conn is over once +// both sides have closed, or either has reset. +message Close { + uint64 conn_id = 1; +} + +// Abandons the conn in both directions, e.g. connection refused. +message Reset { + uint64 conn_id = 1; + string reason = 2; +} + +// Lets the peer send `bytes` more of a conn's Data, as the sender of this +// consumes what it received. Each side starts each conn able to send 256 KiB. +// A client that sends past its window, or grants back more than it was sent, +// has the conn reset. +message Window { + uint64 conn_id = 1; + uint32 bytes = 2; +} + +// Sent both ways every 30s, inside the ALB's idle timeout. The service drops a +// client it hasn't heard from in three. The service also sends one as soon as +// a Connect stream holds the tunnel, so the CLI knows it's live. +message Heartbeat {} + +// This pod is shutting down. The CLI opens a new stream now, and keeps this one +// until the service ends it: it still carries the conns already open on it. +message Reconnect {} + +message DialRequest { + oneof frame { + // First message only. + DialTarget target = 1; + bytes payload = 2; + } +} + +message DialTarget { + Protocol protocol = 1; + uint32 port = 2; +} + +message DialResponse { + oneof frame { + bytes payload = 1; + // The developer's side has nothing more to send. Sent once; more payload + // may still go the other way. + DialEnd end = 2; + } +} + +message DialEnd {}