From 01a034be5355d2273cddf272defb0dee4f784dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Tue, 8 Sep 2026 05:09:58 +0800 Subject: [PATCH] Blackhole outbound: Add customizable response data support (#6713) https://github.com/XTLS/Xray-core/pull/6711#issuecomment-5508683394 --------- Co-authored-by: Raaad1on --- infra/conf/blackhole.go | 50 +++++++--------- infra/conf/blackhole_test.go | 27 ++++++++- proxy/blackhole/blackhole.go | 37 ++++++++++-- proxy/blackhole/blackhole_test.go | 56 ++++++++++++++---- proxy/blackhole/config.go | 47 --------------- proxy/blackhole/config.pb.go | 96 ++++++++++++------------------- proxy/blackhole/config.proto | 11 ++-- proxy/blackhole/config_test.go | 21 +++---- 8 files changed, 170 insertions(+), 175 deletions(-) delete mode 100644 proxy/blackhole/config.go diff --git a/infra/conf/blackhole.go b/infra/conf/blackhole.go index 19ee1a6e6..7046d1efe 100644 --- a/infra/conf/blackhole.go +++ b/infra/conf/blackhole.go @@ -1,52 +1,42 @@ package conf import ( - "encoding/json" + "encoding/base64" + "strings" "github.com/xtls/xray-core/common/errors" - "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/proxy/blackhole" "google.golang.org/protobuf/proto" ) -type NoneResponse struct{} - -func (*NoneResponse) Build() (proto.Message, error) { - return new(blackhole.NoneResponse), nil -} - -type HTTPResponse struct{} - -func (*HTTPResponse) Build() (proto.Message, error) { - return new(blackhole.HTTPResponse), nil +type ResponseConfig struct { + Type string `json:"type"` + CustomResponseData string `json:"customResponseData"` } type BlackholeConfig struct { - Response json.RawMessage `json:"response"` + Response *ResponseConfig `json:"response"` } func (v *BlackholeConfig) Build() (proto.Message, error) { config := new(blackhole.Config) if v.Response != nil { - response, _, err := configLoader.Load(v.Response) - if err != nil { - return nil, errors.New("Config: Failed to parse Blackhole response config.").Base(err) + responseName := strings.ToLower(v.Response.Type) + switch responseName { + case "none", "": + config.Response = &blackhole.Response{Type: "none"} + case "http": + config.Response = &blackhole.Response{Type: "http"} + case "custom": + data, err := base64.StdEncoding.DecodeString(v.Response.CustomResponseData) + if err != nil { + return nil, errors.New("failed to decode custom response data: " + err.Error()) + } + config.Response = &blackhole.Response{Type: "custom", CustomResponseData: data} + default: + return nil, errors.New("unknown blackhole response: " + responseName) } - responseSettings, err := response.(Buildable).Build() - if err != nil { - return nil, err - } - config.Response = serial.ToTypedMessage(responseSettings) } return config, nil } - -var configLoader = NewJSONConfigLoader( - ConfigCreatorCache{ - "none": func() interface{} { return new(NoneResponse) }, - "http": func() interface{} { return new(HTTPResponse) }, - }, - "type", - "", -) diff --git a/infra/conf/blackhole_test.go b/infra/conf/blackhole_test.go index 2b0b92950..83e9b1f22 100644 --- a/infra/conf/blackhole_test.go +++ b/infra/conf/blackhole_test.go @@ -3,7 +3,6 @@ package conf_test import ( "testing" - "github.com/xtls/xray-core/common/serial" . "github.com/xtls/xray-core/infra/conf" "github.com/xtls/xray-core/proxy/blackhole" ) @@ -22,7 +21,7 @@ func TestHTTPResponseJSON(t *testing.T) { }`, Parser: loadJSON(creator), Output: &blackhole.Config{ - Response: serial.ToTypedMessage(&blackhole.HTTPResponse{}), + Response: &blackhole.Response{Type: "http"}, }, }, { @@ -32,3 +31,27 @@ func TestHTTPResponseJSON(t *testing.T) { }, }) } + +func TestCustomResponseJSON(t *testing.T) { + creator := func() Buildable { + return new(BlackholeConfig) + } + + runMultiTestCase(t, []TestCase{ + { + Input: `{ + "response": { + "type": "custom", + "customResponseData": "Y3VzdG9tIHJlc3BvbnNl" + } + }`, + Parser: loadJSON(creator), + Output: &blackhole.Config{ + Response: &blackhole.Response{ + Type: "custom", + CustomResponseData: []byte("custom response"), + }, + }, + }, + }) +} diff --git a/proxy/blackhole/blackhole.go b/proxy/blackhole/blackhole.go index 17350d156..ee0ee7b08 100644 --- a/proxy/blackhole/blackhole.go +++ b/proxy/blackhole/blackhole.go @@ -2,12 +2,15 @@ package blackhole import ( + "bytes" "context" + "net/http" "time" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/dice" + "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/common/session" "github.com/xtls/xray-core/common/signal" @@ -17,14 +20,34 @@ import ( // Handler is an outbound connection that silently swallow the entire payload. type Handler struct { - response ResponseConfig + response []byte +} + +var http403response = http.Response{ + StatusCode: 403, + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Connection": {"close"}, + "Cache-Control": {"max-age=3600, public"}, + }, } // New creates a new blackhole handler. func New(ctx context.Context, config *Config) (*Handler, error) { - response, err := config.GetInternalResponse() - if err != nil { - return nil, err + response := []byte{} + if config.Response != nil { + switch config.Response.Type { + case "", "none": + case "http": + var data bytes.Buffer + common.Must(http403response.Write(&data)) + response = data.Bytes() + case "custom": + response = config.Response.CustomResponseData + default: + return nil, errors.New("unknown blackhole response: " + config.Response.Type) + } } return &Handler{ response: response, @@ -37,8 +60,10 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte ob := outbounds[len(outbounds)-1] ob.Name = "blackhole" - nBytes := h.response.WriteTo(link.Writer) - if nBytes > 0 { + if len(h.response) > 0 { + mbc := buf.MultiBufferContainer{} + common.Must2(mbc.Write(h.response)) + link.Writer.WriteMultiBuffer(mbc.MultiBuffer) // Sleep a little here to make sure the response is sent to client. time.Sleep(time.Second) } diff --git a/proxy/blackhole/blackhole_test.go b/proxy/blackhole/blackhole_test.go index 6a9cb8e84..ac1822757 100644 --- a/proxy/blackhole/blackhole_test.go +++ b/proxy/blackhole/blackhole_test.go @@ -1,12 +1,15 @@ package blackhole_test import ( + "bufio" + "bytes" "context" + "crypto/rand" + "net/http" "testing" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/buf" - "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/common/session" "github.com/xtls/xray-core/proxy/blackhole" "github.com/xtls/xray-core/transport" @@ -16,27 +19,58 @@ import ( func TestBlackholeHTTPResponse(t *testing.T) { ctx := session.ContextWithOutbounds(context.Background(), []*session.Outbound{{}}) handler, err := blackhole.New(ctx, &blackhole.Config{ - Response: serial.ToTypedMessage(&blackhole.HTTPResponse{}), + Response: &blackhole.Response{Type: "http"}, }) common.Must(err) reader, writer := pipe.New(pipe.WithoutSizeLimit()) - var mb buf.MultiBuffer - var rerr error + dataCh := make(chan buf.MultiBuffer, 1) go func() { - b, e := reader.ReadMultiBuffer() - mb = b - rerr = e + mb := common.Must2(reader.ReadMultiBuffer()) + dataCh <- mb }() - link := transport.Link{ Reader: reader, Writer: writer, } common.Must(handler.Process(ctx, &link, nil)) - common.Must(rerr) - if mb.IsEmpty() { - t.Error("expect http response, but nothing") + mb := <-dataCh + data := make([]byte, mb.Len()) + mb.Copy(data) + resp := common.Must2(http.ReadResponse(bufio.NewReader(bytes.NewBuffer(data)), nil)) + if resp.StatusCode != 403 { + t.Errorf("expected 403 response, got %d", resp.StatusCode) + } +} + +func TestBlackholeCustomResponse(t *testing.T) { + ctx := session.ContextWithOutbounds(context.Background(), []*session.Outbound{{}}) + // slightly bigger than a buffer + expected := make([]byte, buf.Size+1000) + if _, err := rand.Read(expected); err != nil { + t.Fatal(err) + } + handler, err := blackhole.New(ctx, &blackhole.Config{ + Response: &blackhole.Response{ + Type: "custom", + CustomResponseData: expected, + }, + }) + common.Must(err) + + reader, writer := pipe.New(pipe.WithoutSizeLimit()) + var actual buf.MultiBuffer + var rerr error + go func() { + actual, rerr = reader.ReadMultiBuffer() + }() + + link := transport.Link{Reader: reader, Writer: writer} + common.Must(handler.Process(ctx, &link, nil)) + common.Must(rerr) + + if actual.String() != string(expected) { + t.Errorf("custom response mismatch") } } diff --git a/proxy/blackhole/config.go b/proxy/blackhole/config.go deleted file mode 100644 index e4a16684c..000000000 --- a/proxy/blackhole/config.go +++ /dev/null @@ -1,47 +0,0 @@ -package blackhole - -import ( - "github.com/xtls/xray-core/common" - "github.com/xtls/xray-core/common/buf" -) - -const ( - http403response = `HTTP/1.1 403 Forbidden -Connection: close -Cache-Control: max-age=3600, public -Content-Length: 0 - - -` -) - -// ResponseConfig is the configuration for blackhole responses. -type ResponseConfig interface { - // WriteTo writes a predefined response to the specified buffer. - WriteTo(buf.Writer) int32 -} - -// WriteTo implements ResponseConfig.WriteTo(). -func (*NoneResponse) WriteTo(buf.Writer) int32 { return 0 } - -// WriteTo implements ResponseConfig.WriteTo(). -func (*HTTPResponse) WriteTo(writer buf.Writer) int32 { - b := buf.New() - common.Must2(b.WriteString(http403response)) - n := b.Len() - writer.WriteMultiBuffer(buf.MultiBuffer{b}) - return n -} - -// GetInternalResponse converts response settings from proto to internal data structure. -func (c *Config) GetInternalResponse() (ResponseConfig, error) { - if c.GetResponse() == nil { - return new(NoneResponse), nil - } - - config, err := c.GetResponse().GetInstance() - if err != nil { - return nil, err - } - return config.(ResponseConfig), nil -} diff --git a/proxy/blackhole/config.pb.go b/proxy/blackhole/config.pb.go index e7d52f777..ca1f1c923 100644 --- a/proxy/blackhole/config.pb.go +++ b/proxy/blackhole/config.pb.go @@ -7,7 +7,6 @@ package blackhole import ( - serial "github.com/xtls/xray-core/common/serial" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -22,26 +21,28 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -type NoneResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type Response struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + CustomResponseData []byte `protobuf:"bytes,2,opt,name=custom_response_data,json=customResponseData,proto3" json:"custom_response_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *NoneResponse) Reset() { - *x = NoneResponse{} +func (x *Response) Reset() { + *x = Response{} mi := &file_proxy_blackhole_config_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *NoneResponse) String() string { +func (x *Response) String() string { return protoimpl.X.MessageStringOf(x) } -func (*NoneResponse) ProtoMessage() {} +func (*Response) ProtoMessage() {} -func (x *NoneResponse) ProtoReflect() protoreflect.Message { +func (x *Response) ProtoReflect() protoreflect.Message { mi := &file_proxy_blackhole_config_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -53,57 +54,35 @@ func (x *NoneResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use NoneResponse.ProtoReflect.Descriptor instead. -func (*NoneResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use Response.ProtoReflect.Descriptor instead. +func (*Response) Descriptor() ([]byte, []int) { return file_proxy_blackhole_config_proto_rawDescGZIP(), []int{0} } -type HTTPResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HTTPResponse) Reset() { - *x = HTTPResponse{} - mi := &file_proxy_blackhole_config_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HTTPResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HTTPResponse) ProtoMessage() {} - -func (x *HTTPResponse) ProtoReflect() protoreflect.Message { - mi := &file_proxy_blackhole_config_proto_msgTypes[1] +func (x *Response) GetType() string { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Type } - return mi.MessageOf(x) + return "" } -// Deprecated: Use HTTPResponse.ProtoReflect.Descriptor instead. -func (*HTTPResponse) Descriptor() ([]byte, []int) { - return file_proxy_blackhole_config_proto_rawDescGZIP(), []int{1} +func (x *Response) GetCustomResponseData() []byte { + if x != nil { + return x.CustomResponseData + } + return nil } type Config struct { state protoimpl.MessageState `protogen:"open.v1"` - Response *serial.TypedMessage `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"` + Response *Response `protobuf:"bytes,1,opt,name=response,proto3" json:"response,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Config) Reset() { *x = Config{} - mi := &file_proxy_blackhole_config_proto_msgTypes[2] + mi := &file_proxy_blackhole_config_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -115,7 +94,7 @@ func (x *Config) String() string { func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { - mi := &file_proxy_blackhole_config_proto_msgTypes[2] + mi := &file_proxy_blackhole_config_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -128,10 +107,10 @@ func (x *Config) ProtoReflect() protoreflect.Message { // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { - return file_proxy_blackhole_config_proto_rawDescGZIP(), []int{2} + return file_proxy_blackhole_config_proto_rawDescGZIP(), []int{1} } -func (x *Config) GetResponse() *serial.TypedMessage { +func (x *Config) GetResponse() *Response { if x != nil { return x.Response } @@ -142,11 +121,12 @@ var File_proxy_blackhole_config_proto protoreflect.FileDescriptor const file_proxy_blackhole_config_proto_rawDesc = "" + "\n" + - "\x1cproxy/blackhole/config.proto\x12\x14xray.proxy.blackhole\x1a!common/serial/typed_message.proto\"\x0e\n" + - "\fNoneResponse\"\x0e\n" + - "\fHTTPResponse\"F\n" + - "\x06Config\x12<\n" + - "\bresponse\x18\x01 \x01(\v2 .xray.common.serial.TypedMessageR\bresponseB^\n" + + "\x1cproxy/blackhole/config.proto\x12\x14xray.proxy.blackhole\"P\n" + + "\bResponse\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x120\n" + + "\x14custom_response_data\x18\x02 \x01(\fR\x12customResponseData\"D\n" + + "\x06Config\x12:\n" + + "\bresponse\x18\x01 \x01(\v2\x1e.xray.proxy.blackhole.ResponseR\bresponseB^\n" + "\x18com.xray.proxy.blackholeP\x01Z)github.com/xtls/xray-core/proxy/blackhole\xaa\x02\x14Xray.Proxy.Blackholeb\x06proto3" var ( @@ -161,15 +141,13 @@ func file_proxy_blackhole_config_proto_rawDescGZIP() []byte { return file_proxy_blackhole_config_proto_rawDescData } -var file_proxy_blackhole_config_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_proxy_blackhole_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_proxy_blackhole_config_proto_goTypes = []any{ - (*NoneResponse)(nil), // 0: xray.proxy.blackhole.NoneResponse - (*HTTPResponse)(nil), // 1: xray.proxy.blackhole.HTTPResponse - (*Config)(nil), // 2: xray.proxy.blackhole.Config - (*serial.TypedMessage)(nil), // 3: xray.common.serial.TypedMessage + (*Response)(nil), // 0: xray.proxy.blackhole.Response + (*Config)(nil), // 1: xray.proxy.blackhole.Config } var file_proxy_blackhole_config_proto_depIdxs = []int32{ - 3, // 0: xray.proxy.blackhole.Config.response:type_name -> xray.common.serial.TypedMessage + 0, // 0: xray.proxy.blackhole.Config.response:type_name -> xray.proxy.blackhole.Response 1, // [1:1] is the sub-list for method output_type 1, // [1:1] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name @@ -188,7 +166,7 @@ func file_proxy_blackhole_config_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proxy_blackhole_config_proto_rawDesc), len(file_proxy_blackhole_config_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/proxy/blackhole/config.proto b/proxy/blackhole/config.proto index 3214c1b56..93b4d8e7b 100644 --- a/proxy/blackhole/config.proto +++ b/proxy/blackhole/config.proto @@ -6,12 +6,11 @@ option go_package = "github.com/xtls/xray-core/proxy/blackhole"; option java_package = "com.xray.proxy.blackhole"; option java_multiple_files = true; -import "common/serial/typed_message.proto"; - -message NoneResponse {} - -message HTTPResponse {} +message Response { + string type = 1; + bytes custom_response_data = 2; +} message Config { - xray.common.serial.TypedMessage response = 1; + Response response = 1; } diff --git a/proxy/blackhole/config_test.go b/proxy/blackhole/config_test.go index f2755b50d..f54cb0154 100644 --- a/proxy/blackhole/config_test.go +++ b/proxy/blackhole/config_test.go @@ -1,26 +1,19 @@ package blackhole_test import ( - "bufio" - "net/http" + "context" "testing" "github.com/xtls/xray-core/common" - "github.com/xtls/xray-core/common/buf" - . "github.com/xtls/xray-core/proxy/blackhole" + "github.com/xtls/xray-core/proxy/blackhole" ) func TestHTTPResponse(t *testing.T) { - buffer := buf.New() - - httpResponse := new(HTTPResponse) - httpResponse.WriteTo(buf.NewWriter(buffer)) - - reader := bufio.NewReader(buffer) - response, err := http.ReadResponse(reader, nil) + handler, err := blackhole.New(context.Background(), &blackhole.Config{ + Response: &blackhole.Response{Type: "http"}, + }) common.Must(err) - - if response.StatusCode != 403 { - t.Error("expected status code 403, but got ", response.StatusCode) + if handler == nil { + t.Error("expected HTTP response handler") } }