From 646e2aff947ce561872314382dc1175a0100c5e5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 12 Nov 2026 13:06:35 +0800 Subject: [PATCH] Freedom: Add custom firewall replies --- infra/conf/freedom.go | 50 ++++++++++--- infra/conf/freedom_test.go | 10 ++- proxy/freedom/config.pb.go | 134 +++++++++++++++++++++++++--------- proxy/freedom/config.proto | 6 ++ proxy/freedom/freedom.go | 34 +++++++++ proxy/freedom/freedom_test.go | 54 ++++++++++++++ 6 files changed, 244 insertions(+), 44 deletions(-) create mode 100644 proxy/freedom/freedom_test.go diff --git a/infra/conf/freedom.go b/infra/conf/freedom.go index 8c93152bc..5384c7d9a 100644 --- a/infra/conf/freedom.go +++ b/infra/conf/freedom.go @@ -46,13 +46,37 @@ type Noise struct { } type FreedomFirewallConfig struct { - Action string `json:"action"` - Network *NetworkList `json:"network"` - Port *PortList `json:"port"` - IP *StringList `json:"ip"` - Delay *Int32Range `json:"delay"` - LogLevel string `json:"logLevel"` - BlockDelay *Int32Range `json:"blockDelay"` + Action string `json:"action"` + Network *NetworkList `json:"network"` + Port *PortList `json:"port"` + IP *StringList `json:"ip"` + Delay *Int32Range `json:"delay"` + LogLevel string `json:"logLevel"` + Reply *FreedomReplyConfig `json:"reply"` + BlockDelay *Int32Range `json:"blockDelay"` +} + +type FreedomReplyConfig struct { + Type string `json:"type"` + CustomResponseData string `json:"customResponseData"` +} + +func (c *FreedomReplyConfig) Build() (*freedom.Reply, error) { + replyType := strings.ToLower(c.Type) + switch replyType { + case "none", "": + return &freedom.Reply{Type: "none"}, nil + case "http": + return &freedom.Reply{Type: "http"}, nil + case "custom": + data, err := base64.StdEncoding.DecodeString(c.CustomResponseData) + if err != nil { + return nil, errors.New("failed to decode custom reply data: " + err.Error()) + } + return &freedom.Reply{Type: "custom", CustomResponseData: data}, nil + default: + return nil, errors.New("unknown firewall reply: " + replyType) + } } // Build implements Buildable @@ -296,8 +320,8 @@ func (c *FreedomFirewallConfig) Build() (*freedom.FirewallConfig, error) { } if rule.Action != freedom.RuleAction_Block { - if c.Delay != nil || c.LogLevel != "" { - return nil, errors.New("delay and logLevel are only supported for firewall action block") + if c.Delay != nil || c.LogLevel != "" || c.Reply != nil { + return nil, errors.New("delay, logLevel and reply are only supported for firewall action block") } return rule, nil } @@ -309,6 +333,14 @@ func (c *FreedomFirewallConfig) Build() (*freedom.FirewallConfig, error) { } } + if c.Reply != nil { + reply, err := c.Reply.Build() + if err != nil { + return nil, err + } + rule.Reply = reply + } + switch strings.ToLower(c.LogLevel) { case "", "info": rule.LogLevel = log.Severity_Info.Enum() diff --git a/infra/conf/freedom_test.go b/infra/conf/freedom_test.go index ee7cd86d8..af29e9eeb 100644 --- a/infra/conf/freedom_test.go +++ b/infra/conf/freedom_test.go @@ -48,7 +48,11 @@ func TestFreedomConfig(t *testing.T) { "port": "53,443", "ip": ["10.0.0.0/8", "2001:db8::/32"], "delay": "30-60", - "logLevel": "debug" + "logLevel": "debug", + "reply": { + "type": "custom", + "customResponseData": "Y3VzdG9tIHJlc3BvbnNl" + } }, { "action": "allow", "network": ["udp"] @@ -90,6 +94,10 @@ func TestFreedomConfig(t *testing.T) { }, Delay: &freedom.Range{Min: 30, Max: 60}, LogLevel: log.Severity_Debug.Enum(), + Reply: &freedom.Reply{ + Type: "custom", + CustomResponseData: []byte("custom response"), + }, }, { Action: freedom.RuleAction_Allow, diff --git a/proxy/freedom/config.pb.go b/proxy/freedom/config.pb.go index 8e555770d..ced63563e 100644 --- a/proxy/freedom/config.pb.go +++ b/proxy/freedom/config.pb.go @@ -352,6 +352,58 @@ func (x *Range) GetMax() uint64 { return 0 } +type Reply 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 *Reply) Reset() { + *x = Reply{} + mi := &file_proxy_freedom_config_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Reply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Reply) ProtoMessage() {} + +func (x *Reply) ProtoReflect() protoreflect.Message { + mi := &file_proxy_freedom_config_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 Reply.ProtoReflect.Descriptor instead. +func (*Reply) Descriptor() ([]byte, []int) { + return file_proxy_freedom_config_proto_rawDescGZIP(), []int{4} +} + +func (x *Reply) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Reply) GetCustomResponseData() []byte { + if x != nil { + return x.CustomResponseData + } + return nil +} + type FirewallConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Action RuleAction `protobuf:"varint,1,opt,name=action,proto3,enum=xray.proxy.freedom.RuleAction" json:"action,omitempty"` @@ -360,13 +412,14 @@ type FirewallConfig struct { Ip []*geodata.IPRule `protobuf:"bytes,4,rep,name=ip,proto3" json:"ip,omitempty"` Delay *Range `protobuf:"bytes,5,opt,name=delay,proto3" json:"delay,omitempty"` LogLevel *log.Severity `protobuf:"varint,6,opt,name=log_level,json=logLevel,proto3,enum=xray.common.log.Severity,oneof" json:"log_level,omitempty"` + Reply *Reply `protobuf:"bytes,7,opt,name=reply,proto3" json:"reply,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *FirewallConfig) Reset() { *x = FirewallConfig{} - mi := &file_proxy_freedom_config_proto_msgTypes[4] + mi := &file_proxy_freedom_config_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -378,7 +431,7 @@ func (x *FirewallConfig) String() string { func (*FirewallConfig) ProtoMessage() {} func (x *FirewallConfig) ProtoReflect() protoreflect.Message { - mi := &file_proxy_freedom_config_proto_msgTypes[4] + mi := &file_proxy_freedom_config_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -391,7 +444,7 @@ func (x *FirewallConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use FirewallConfig.ProtoReflect.Descriptor instead. func (*FirewallConfig) Descriptor() ([]byte, []int) { - return file_proxy_freedom_config_proto_rawDescGZIP(), []int{4} + return file_proxy_freedom_config_proto_rawDescGZIP(), []int{5} } func (x *FirewallConfig) GetAction() RuleAction { @@ -436,6 +489,13 @@ func (x *FirewallConfig) GetLogLevel() log.Severity { return log.Severity(0) } +func (x *FirewallConfig) GetReply() *Reply { + if x != nil { + return x.Reply + } + return nil +} + type Config struct { state protoimpl.MessageState `protogen:"open.v1"` DomainStrategy internet.DomainStrategy `protobuf:"varint,1,opt,name=domain_strategy,json=domainStrategy,proto3,enum=xray.transport.internet.DomainStrategy" json:"domain_strategy,omitempty"` @@ -450,7 +510,7 @@ type Config struct { func (x *Config) Reset() { *x = Config{} - mi := &file_proxy_freedom_config_proto_msgTypes[5] + mi := &file_proxy_freedom_config_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -462,7 +522,7 @@ func (x *Config) String() string { func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { - mi := &file_proxy_freedom_config_proto_msgTypes[5] + mi := &file_proxy_freedom_config_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -475,7 +535,7 @@ func (x *Config) ProtoReflect() protoreflect.Message { // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { - return file_proxy_freedom_config_proto_rawDescGZIP(), []int{5} + return file_proxy_freedom_config_proto_rawDescGZIP(), []int{6} } func (x *Config) GetDomainStrategy() internet.DomainStrategy { @@ -550,14 +610,18 @@ const file_proxy_freedom_config_proto_rawDesc = "" + "\bapply_to\x18\x06 \x01(\tR\aapplyTo\"+\n" + "\x05Range\x12\x10\n" + "\x03min\x18\x01 \x01(\x04R\x03min\x12\x10\n" + - "\x03max\x18\x02 \x01(\x04R\x03max\"\xdf\x02\n" + + "\x03max\x18\x02 \x01(\x04R\x03max\"M\n" + + "\x05Reply\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x120\n" + + "\x14custom_response_data\x18\x02 \x01(\fR\x12customResponseData\"\x90\x03\n" + "\x0eFirewallConfig\x126\n" + "\x06action\x18\x01 \x01(\x0e2\x1e.xray.proxy.freedom.RuleActionR\x06action\x124\n" + "\bnetworks\x18\x02 \x03(\x0e2\x18.xray.common.net.NetworkR\bnetworks\x126\n" + "\tport_list\x18\x03 \x01(\v2\x19.xray.common.net.PortListR\bportList\x12+\n" + "\x02ip\x18\x04 \x03(\v2\x1b.xray.common.geodata.IPRuleR\x02ip\x12/\n" + "\x05delay\x18\x05 \x01(\v2\x19.xray.proxy.freedom.RangeR\x05delay\x12;\n" + - "\tlog_level\x18\x06 \x01(\x0e2\x19.xray.common.log.SeverityH\x00R\blogLevel\x88\x01\x01B\f\n" + + "\tlog_level\x18\x06 \x01(\x0e2\x19.xray.common.log.SeverityH\x00R\blogLevel\x88\x01\x01\x12/\n" + + "\x05reply\x18\a \x01(\v2\x19.xray.proxy.freedom.ReplyR\x05replyB\f\n" + "\n" + "_log_level\"\x88\x03\n" + "\x06Config\x12P\n" + @@ -587,40 +651,42 @@ func file_proxy_freedom_config_proto_rawDescGZIP() []byte { } var file_proxy_freedom_config_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proxy_freedom_config_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_proxy_freedom_config_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_proxy_freedom_config_proto_goTypes = []any{ (RuleAction)(0), // 0: xray.proxy.freedom.RuleAction (*DestinationOverride)(nil), // 1: xray.proxy.freedom.DestinationOverride (*Fragment)(nil), // 2: xray.proxy.freedom.Fragment (*Noise)(nil), // 3: xray.proxy.freedom.Noise (*Range)(nil), // 4: xray.proxy.freedom.Range - (*FirewallConfig)(nil), // 5: xray.proxy.freedom.FirewallConfig - (*Config)(nil), // 6: xray.proxy.freedom.Config - (*protocol.ServerEndpoint)(nil), // 7: xray.common.protocol.ServerEndpoint - (net.Network)(0), // 8: xray.common.net.Network - (*net.PortList)(nil), // 9: xray.common.net.PortList - (*geodata.IPRule)(nil), // 10: xray.common.geodata.IPRule - (log.Severity)(0), // 11: xray.common.log.Severity - (internet.DomainStrategy)(0), // 12: xray.transport.internet.DomainStrategy + (*Reply)(nil), // 5: xray.proxy.freedom.Reply + (*FirewallConfig)(nil), // 6: xray.proxy.freedom.FirewallConfig + (*Config)(nil), // 7: xray.proxy.freedom.Config + (*protocol.ServerEndpoint)(nil), // 8: xray.common.protocol.ServerEndpoint + (net.Network)(0), // 9: xray.common.net.Network + (*net.PortList)(nil), // 10: xray.common.net.PortList + (*geodata.IPRule)(nil), // 11: xray.common.geodata.IPRule + (log.Severity)(0), // 12: xray.common.log.Severity + (internet.DomainStrategy)(0), // 13: xray.transport.internet.DomainStrategy } var file_proxy_freedom_config_proto_depIdxs = []int32{ - 7, // 0: xray.proxy.freedom.DestinationOverride.server:type_name -> xray.common.protocol.ServerEndpoint + 8, // 0: xray.proxy.freedom.DestinationOverride.server:type_name -> xray.common.protocol.ServerEndpoint 0, // 1: xray.proxy.freedom.FirewallConfig.action:type_name -> xray.proxy.freedom.RuleAction - 8, // 2: xray.proxy.freedom.FirewallConfig.networks:type_name -> xray.common.net.Network - 9, // 3: xray.proxy.freedom.FirewallConfig.port_list:type_name -> xray.common.net.PortList - 10, // 4: xray.proxy.freedom.FirewallConfig.ip:type_name -> xray.common.geodata.IPRule + 9, // 2: xray.proxy.freedom.FirewallConfig.networks:type_name -> xray.common.net.Network + 10, // 3: xray.proxy.freedom.FirewallConfig.port_list:type_name -> xray.common.net.PortList + 11, // 4: xray.proxy.freedom.FirewallConfig.ip:type_name -> xray.common.geodata.IPRule 4, // 5: xray.proxy.freedom.FirewallConfig.delay:type_name -> xray.proxy.freedom.Range - 11, // 6: xray.proxy.freedom.FirewallConfig.log_level:type_name -> xray.common.log.Severity - 12, // 7: xray.proxy.freedom.Config.domain_strategy:type_name -> xray.transport.internet.DomainStrategy - 1, // 8: xray.proxy.freedom.Config.destination_override:type_name -> xray.proxy.freedom.DestinationOverride - 2, // 9: xray.proxy.freedom.Config.fragment:type_name -> xray.proxy.freedom.Fragment - 3, // 10: xray.proxy.freedom.Config.noises:type_name -> xray.proxy.freedom.Noise - 5, // 11: xray.proxy.freedom.Config.firewall:type_name -> xray.proxy.freedom.FirewallConfig - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 12, // 6: xray.proxy.freedom.FirewallConfig.log_level:type_name -> xray.common.log.Severity + 5, // 7: xray.proxy.freedom.FirewallConfig.reply:type_name -> xray.proxy.freedom.Reply + 13, // 8: xray.proxy.freedom.Config.domain_strategy:type_name -> xray.transport.internet.DomainStrategy + 1, // 9: xray.proxy.freedom.Config.destination_override:type_name -> xray.proxy.freedom.DestinationOverride + 2, // 10: xray.proxy.freedom.Config.fragment:type_name -> xray.proxy.freedom.Fragment + 3, // 11: xray.proxy.freedom.Config.noises:type_name -> xray.proxy.freedom.Noise + 6, // 12: xray.proxy.freedom.Config.firewall:type_name -> xray.proxy.freedom.FirewallConfig + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_proxy_freedom_config_proto_init() } @@ -628,14 +694,14 @@ func file_proxy_freedom_config_proto_init() { if File_proxy_freedom_config_proto != nil { return } - file_proxy_freedom_config_proto_msgTypes[4].OneofWrappers = []any{} + file_proxy_freedom_config_proto_msgTypes[5].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proxy_freedom_config_proto_rawDesc), len(file_proxy_freedom_config_proto_rawDesc)), NumEnums: 1, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/proxy/freedom/config.proto b/proxy/freedom/config.proto index 3b71e06df..2a6dd33d5 100644 --- a/proxy/freedom/config.proto +++ b/proxy/freedom/config.proto @@ -47,6 +47,11 @@ enum RuleAction { Block = 1; } +message Reply { + string type = 1; + bytes custom_response_data = 2; +} + message FirewallConfig { RuleAction action = 1; repeated xray.common.net.Network networks = 2; @@ -54,6 +59,7 @@ message FirewallConfig { repeated xray.common.geodata.IPRule ip = 4; Range delay = 5; optional xray.common.log.Severity log_level = 6; + Reply reply = 7; } message Config { diff --git a/proxy/freedom/freedom.go b/proxy/freedom/freedom.go index 88151b9c9..4ab3ecab0 100644 --- a/proxy/freedom/freedom.go +++ b/proxy/freedom/freedom.go @@ -1,9 +1,11 @@ package freedom import ( + "bytes" "context" "crypto/rand" "io" + "net/http" "strings" "sync/atomic" "time" @@ -38,6 +40,16 @@ var ( defaultBlockAllRule *Firewall ) +var http403response = http.Response{ + StatusCode: 403, + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "Connection": {"close"}, + "Cache-Control": {"max-age=3600, public"}, + }, +} + func reloadEnvSettings() error { const defaultFlagValue = "NOT_DEFINED_AT_ALL" value := platform.NewEnvFlag(platform.UseFreedomSplice).GetValue(func() string { return defaultFlagValue }) @@ -92,6 +104,7 @@ type Firewall struct { ip geodata.IPMatcher delay *Range logLevel *log.Severity + reply []byte } // Handler handles Freedom connections. @@ -110,6 +123,20 @@ func buildRule(config *FirewallConfig) (*Firewall, error) { logLevel: config.LogLevel, } + if config.Reply != nil { + switch config.Reply.Type { + case "", "none": + case "http": + var data bytes.Buffer + common.Must(http403response.Write(&data)) + rule.reply = data.Bytes() + case "custom": + rule.reply = config.Reply.CustomResponseData + default: + return nil, errors.New("unknown firewall reply: " + config.Reply.Type) + } + } + if len(config.Networks) == 0 { rule.network = allNetworks } else { @@ -251,6 +278,13 @@ func (h *Handler) blackhole(ctx context.Context, input buf.Reader, output buf.Wr delay := rule.blockDuration() rule.log(ctx, "target blocked by a firewall rule: ", *dest, ", blackholing connection for ", delay) defer common.Close(output) + if len(rule.reply) > 0 { + mbc := buf.MultiBufferContainer{} + common.Must2(mbc.Write(rule.reply)) + output.WriteMultiBuffer(mbc.MultiBuffer) + // Sleep a little here to make sure the reply is sent to client. + time.Sleep(time.Second) + } done := make(chan struct{}) go func() { // a DispatchLink inbound's reader ignores Interrupt, so don't wait for this copy _ = buf.Copy(input, buf.Discard) diff --git a/proxy/freedom/freedom_test.go b/proxy/freedom/freedom_test.go new file mode 100644 index 000000000..07eeafa1b --- /dev/null +++ b/proxy/freedom/freedom_test.go @@ -0,0 +1,54 @@ +package freedom + +import ( + "bufio" + "bytes" + "context" + "net/http" + "testing" + + "github.com/xtls/xray-core/common/buf" + "github.com/xtls/xray-core/common/net" +) + +func TestFirewallReply(t *testing.T) { + custom := bytes.Repeat([]byte("custom response"), buf.Size) + for _, test := range []struct { + name string + reply *Reply + want []byte + }{ + {name: "default"}, + {name: "none", reply: &Reply{Type: "none"}}, + {name: "http", reply: &Reply{Type: "http"}}, + {name: "custom", reply: &Reply{Type: "custom", CustomResponseData: custom}, want: custom}, + } { + t.Run(test.name, func(t *testing.T) { + rule, err := buildRule(&FirewallConfig{ + Action: RuleAction_Block, + Delay: &Range{}, + Reply: test.reply, + }) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + dest := net.TCPDestination(net.LocalHostIP, 80) + if err := new(Handler).blackhole(context.Background(), buf.NewReader(bytes.NewReader(nil)), &buf.BufferToBytesWriter{Writer: &output}, rule, &dest); err != nil { + t.Fatal(err) + } + if test.name == "http" { + response, err := http.ReadResponse(bufio.NewReader(&output), nil) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("HTTP status = %d, want 403", response.StatusCode) + } + } else if !bytes.Equal(output.Bytes(), test.want) { + t.Fatalf("reply has %d bytes, want %d", output.Len(), len(test.want)) + } + }) + } +}