From 175502d8079aa5a151242ed911d01a1b90b98b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B2=D0=B0=D0=BD?= <82300276+fatyzzz@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:17:51 +0700 Subject: [PATCH] header-custom finalmask: Add programmable handshake templates and runtime core (#5920) https://github.com/XTLS/Xray-core/pull/5920#issuecomment-4252579201 https://github.com/XTLS/Xray-core/pull/5920#issuecomment-4231698135 https://t.me/projectXtls/1829 https://t.me/projectXtls/1640 --- infra/conf/transport_internet.go | 231 +++++++++- infra/conf/transport_test.go | 134 ++++++ transport/internet/finalmask/finalmask.go | 9 +- .../finalmask/header/custom/config.pb.go | 333 ++++++++++++-- .../finalmask/header/custom/config.proto | 23 +- .../finalmask/header/custom/evaluator.go | 406 ++++++++++++++++++ .../finalmask/header/custom/evaluator_test.go | 130 ++++++ .../finalmask/header/custom/metadata_test.go | 210 +++++++++ .../internet/finalmask/header/custom/state.go | 57 +++ .../finalmask/header/custom/state_test.go | 105 +++++ .../internet/finalmask/header/custom/tcp.go | 90 +++- .../header/custom/tcp_runtime_test.go | 150 +++++++ .../internet/finalmask/header/custom/udp.go | 236 ++++++---- .../header/custom/udp_runtime_test.go | 83 ++++ transport/internet/finalmask/tcp_test.go | 126 ++++++ transport/internet/finalmask/udp_test.go | 102 +++++ 16 files changed, 2275 insertions(+), 150 deletions(-) create mode 100644 transport/internet/finalmask/header/custom/evaluator.go create mode 100644 transport/internet/finalmask/header/custom/evaluator_test.go create mode 100644 transport/internet/finalmask/header/custom/metadata_test.go create mode 100644 transport/internet/finalmask/header/custom/state.go create mode 100644 transport/internet/finalmask/header/custom/state_test.go create mode 100644 transport/internet/finalmask/header/custom/tcp_runtime_test.go create mode 100644 transport/internet/finalmask/header/custom/udp_runtime_test.go diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index f0dd08ff..23df27a8 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -8,6 +8,7 @@ import ( "math" "net/url" "os" + "regexp" "runtime" "strconv" "strings" @@ -1231,6 +1232,8 @@ func PraseByteSlice(data json.RawMessage, typ string) ([]byte, error) { } var ( + customVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + tcpmaskLoader = NewJSONConfigLoader(ConfigCreatorCache{ "header-custom": func() interface{} { return new(HeaderCustomTCP) }, "fragment": func() interface{} { return new(FragmentMask) }, @@ -1256,11 +1259,14 @@ var ( ) type TCPItem struct { - Delay Int32Range `json:"delay"` - Rand int32 `json:"rand"` - RandRange *Int32Range `json:"randRange"` - Type string `json:"type"` - Packet json.RawMessage `json:"packet"` + Delay Int32Range `json:"delay"` + Rand int32 `json:"rand"` + RandRange *Int32Range `json:"randRange"` + Capture string `json:"capture"` + Type string `json:"type"` + Reuse string `json:"reuse"` + Transform *CustomTransform `json:"transform"` + Packet json.RawMessage `json:"packet"` } type HeaderCustomTCP struct { @@ -1272,22 +1278,22 @@ type HeaderCustomTCP struct { func (c *HeaderCustomTCP) Build() (proto.Message, error) { for _, value := range c.Clients { for _, item := range value { - if len(item.Packet) > 0 && item.Rand > 0 { - return nil, errors.New("len(item.Packet) > 0 && item.Rand > 0") + if err := validateCustomItemSpec(item.Capture, item.Packet, item.Rand, item.Reuse, item.Transform); err != nil { + return nil, err } } } for _, value := range c.Servers { for _, item := range value { - if len(item.Packet) > 0 && item.Rand > 0 { - return nil, errors.New("len(item.Packet) > 0 && item.Rand > 0") + if err := validateCustomItemSpec(item.Capture, item.Packet, item.Rand, item.Reuse, item.Transform); err != nil { + return nil, err } } } for _, value := range c.Errors { for _, item := range value { - if len(item.Packet) > 0 && item.Rand > 0 { - return nil, errors.New("len(item.Packet) > 0 && item.Rand > 0") + if err := validateCustomItemSpec(item.Capture, item.Packet, item.Rand, item.Reuse, item.Transform); err != nil { + return nil, err } } } @@ -1308,6 +1314,10 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { if item.Packet, err = PraseByteSlice(item.Packet, item.Type); err != nil { return nil, err } + transform, err := buildCustomTransform(item.Transform) + if err != nil { + return nil, err + } clients[i].Sequence = append(clients[i].Sequence, &custom.TCPItem{ DelayMin: int64(item.Delay.From), DelayMax: int64(item.Delay.To), @@ -1315,6 +1325,9 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { RandMin: item.RandRange.From, RandMax: item.RandRange.To, Packet: item.Packet, + Save: item.Capture, + Var: item.Reuse, + Expr: transform, }) } } @@ -1333,6 +1346,10 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { if item.Packet, err = PraseByteSlice(item.Packet, item.Type); err != nil { return nil, err } + transform, err := buildCustomTransform(item.Transform) + if err != nil { + return nil, err + } servers[i].Sequence = append(servers[i].Sequence, &custom.TCPItem{ DelayMin: int64(item.Delay.From), DelayMax: int64(item.Delay.To), @@ -1340,6 +1357,9 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { RandMin: item.RandRange.From, RandMax: item.RandRange.To, Packet: item.Packet, + Save: item.Capture, + Var: item.Reuse, + Expr: transform, }) } } @@ -1358,6 +1378,10 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { if item.Packet, err = PraseByteSlice(item.Packet, item.Type); err != nil { return nil, err } + transform, err := buildCustomTransform(item.Transform) + if err != nil { + return nil, err + } errors[i].Sequence = append(errors[i].Sequence, &custom.TCPItem{ DelayMin: int64(item.Delay.From), DelayMax: int64(item.Delay.To), @@ -1365,6 +1389,9 @@ func (c *HeaderCustomTCP) Build() (proto.Message, error) { RandMin: item.RandRange.From, RandMax: item.RandRange.To, Packet: item.Packet, + Save: item.Capture, + Var: item.Reuse, + Expr: transform, }) } } @@ -1471,10 +1498,162 @@ func (c *NoiseMask) Build() (proto.Message, error) { } type UDPItem struct { - Rand int32 `json:"rand"` - RandRange *Int32Range `json:"randRange"` - Type string `json:"type"` - Packet json.RawMessage `json:"packet"` + Rand int32 `json:"rand"` + RandRange *Int32Range `json:"randRange"` + Capture string `json:"capture"` + Type string `json:"type"` + Reuse string `json:"reuse"` + Transform *CustomTransform `json:"transform"` + Packet json.RawMessage `json:"packet"` +} + +type CustomTransform struct { + Op string `json:"op"` + Args []CustomTransformArg `json:"args"` +} + +type CustomTransformArg struct { + Type string `json:"type"` + Bytes json.RawMessage `json:"bytes"` + U64 *uint64 `json:"u64"` + Reuse string `json:"reuse"` + Metadata string `json:"metadata"` + Transform *CustomTransform `json:"transform"` +} + +func validateCustomVarName(name string) error { + if name == "" { + return nil + } + if !customVarNamePattern.MatchString(name) { + return errors.New("invalid variable name") + } + return nil +} + +func validateCustomItemSpec(capture string, packet json.RawMessage, rand int32, reuse string, transform *CustomTransform) error { + if err := validateCustomVarName(capture); err != nil { + return err + } + if err := validateCustomVarName(reuse); err != nil { + return err + } + + kindCount := 0 + if len(packet) > 0 { + kindCount++ + } + if rand > 0 { + kindCount++ + } + if reuse != "" { + kindCount++ + } + if transform != nil { + kindCount++ + } + if kindCount > 1 { + return errors.New("exactly one item kind must be set") + } + if kindCount == 0 && capture != "" { + return errors.New("exactly one item kind must be set") + } + + return nil +} + +func buildCustomTransform(transform *CustomTransform) (*custom.Expr, error) { + if transform == nil { + return nil, nil + } + if transform.Op == "" { + return nil, errors.New("transform op is required") + } + if len(transform.Args) == 0 { + return nil, errors.New("transform args are required") + } + + args := make([]*custom.ExprArg, 0, len(transform.Args)) + for _, arg := range transform.Args { + parsedArg, err := buildCustomTransformArg(arg) + if err != nil { + return nil, err + } + args = append(args, parsedArg) + } + + return &custom.Expr{ + Op: transform.Op, + Args: args, + }, nil +} + +func buildCustomTransformArg(arg CustomTransformArg) (*custom.ExprArg, error) { + kindCount := 0 + if len(arg.Bytes) > 0 { + kindCount++ + } + if arg.U64 != nil { + kindCount++ + } + if arg.Reuse != "" { + kindCount++ + } + if arg.Metadata != "" { + kindCount++ + } + if arg.Transform != nil { + kindCount++ + } + if kindCount != 1 { + return nil, errors.New("transform arg must set exactly one value") + } + + if len(arg.Bytes) > 0 { + value, err := PraseByteSlice(arg.Bytes, arg.Type) + if err != nil { + return nil, err + } + return &custom.ExprArg{ + Value: &custom.ExprArg_Bytes{ + Bytes: value, + }, + }, nil + } + if arg.U64 != nil { + return &custom.ExprArg{ + Value: &custom.ExprArg_U64{ + U64: *arg.U64, + }, + }, nil + } + if arg.Reuse != "" { + if err := validateCustomVarName(arg.Reuse); err != nil { + return nil, err + } + return &custom.ExprArg{ + Value: &custom.ExprArg_Var{ + Var: arg.Reuse, + }, + }, nil + } + if arg.Metadata != "" { + return &custom.ExprArg{ + Value: &custom.ExprArg_Metadata{ + Metadata: arg.Metadata, + }, + }, nil + } + + parsedExpr, err := buildCustomTransform(arg.Transform) + if err != nil { + return nil, err + } + return &custom.ExprArg{ + Value: &custom.ExprArg_Expr{ + Expr: parsedExpr, + }, + }, nil } type HeaderCustomUDP struct { @@ -1484,13 +1663,13 @@ type HeaderCustomUDP struct { func (c *HeaderCustomUDP) Build() (proto.Message, error) { for _, item := range c.Client { - if len(item.Packet) > 0 && item.Rand > 0 { - return nil, errors.New("len(item.Packet) > 0 && item.Rand > 0") + if err := validateCustomItemSpec(item.Capture, item.Packet, item.Rand, item.Reuse, item.Transform); err != nil { + return nil, err } } for _, item := range c.Server { - if len(item.Packet) > 0 && item.Rand > 0 { - return nil, errors.New("len(item.Packet) > 0 && item.Rand > 0") + if err := validateCustomItemSpec(item.Capture, item.Packet, item.Rand, item.Reuse, item.Transform); err != nil { + return nil, err } } @@ -1506,11 +1685,18 @@ func (c *HeaderCustomUDP) Build() (proto.Message, error) { if item.Packet, err = PraseByteSlice(item.Packet, item.Type); err != nil { return nil, err } + transform, err := buildCustomTransform(item.Transform) + if err != nil { + return nil, err + } client = append(client, &custom.UDPItem{ Rand: item.Rand, RandMin: item.RandRange.From, RandMax: item.RandRange.To, Packet: item.Packet, + Save: item.Capture, + Var: item.Reuse, + Expr: transform, }) } @@ -1526,11 +1712,18 @@ func (c *HeaderCustomUDP) Build() (proto.Message, error) { if item.Packet, err = PraseByteSlice(item.Packet, item.Type); err != nil { return nil, err } + transform, err := buildCustomTransform(item.Transform) + if err != nil { + return nil, err + } server = append(server, &custom.UDPItem{ Rand: item.Rand, RandMin: item.RandRange.From, RandMax: item.RandRange.To, Packet: item.Packet, + Save: item.Capture, + Var: item.Reuse, + Expr: transform, }) } diff --git a/infra/conf/transport_test.go b/infra/conf/transport_test.go index 87e5f920..1912126a 100644 --- a/infra/conf/transport_test.go +++ b/infra/conf/transport_test.go @@ -2,10 +2,12 @@ package conf_test import ( "encoding/json" + "strings" "testing" . "github.com/xtls/xray-core/infra/conf" "github.com/xtls/xray-core/transport/internet" + finalmaskcustom "github.com/xtls/xray-core/transport/internet/finalmask/header/custom" "google.golang.org/protobuf/proto" ) @@ -156,3 +158,135 @@ func TestSocketConfig(t *testing.T) { t.Fatalf("unexpected parsed TFO value, which should be -1") } } + +func TestHeaderCustomUDPBuild(t *testing.T) { + parser := loadJSON(func() Buildable { return new(HeaderCustomUDP) }) + + runMultiTestCase(t, []TestCase{ + { + Input: `{ + "client": [ + { + "type": "hex", + "packet": "aabb" + }, + { + "rand": 2, + "capture": "seed", + "randRange": "16-32" + } + ], + "server": [ + { + "capture": "txid", + "transform": { + "op": "concat", + "args": [ + {"reuse": "seed"}, + {"u64": 258}, + {"type": "hex", "bytes": "c0de"} + ] + } + }, + { + "reuse": "txid" + } + ] + }`, + Parser: parser, + Output: &finalmaskcustom.UDPConfig{ + Client: []*finalmaskcustom.UDPItem{ + { + RandMax: 255, + Packet: []byte{0xAA, 0xBB}, + }, + { + Rand: 2, + RandMin: 16, + RandMax: 32, + Save: "seed", + }, + }, + Server: []*finalmaskcustom.UDPItem{ + { + RandMax: 255, + Save: "txid", + Expr: &finalmaskcustom.Expr{ + Op: "concat", + Args: []*finalmaskcustom.ExprArg{ + { + Value: &finalmaskcustom.ExprArg_Var{ + Var: "seed", + }, + }, + { + Value: &finalmaskcustom.ExprArg_U64{ + U64: 258, + }, + }, + { + Value: &finalmaskcustom.ExprArg_Bytes{ + Bytes: []byte{0xC0, 0xDE}, + }, + }, + }, + }, + }, + { + RandMax: 255, + Var: "txid", + }, + }, + }, + }, + }) +} + +func TestHeaderCustomTCPBuildRejectsMixedItemKinds(t *testing.T) { + parser := loadJSON(func() Buildable { return new(HeaderCustomTCP) }) + + _, err := parser(`{ + "clients": [[ + { + "packet": [1, 2], + "reuse": "txid" + } + ]] + }`) + if err == nil || !strings.Contains(err.Error(), "exactly one item kind") { + t.Fatalf("expected mixed item kind rejection, got %v", err) + } +} + +func TestHeaderCustomUDPBuildRejectsInvalidVariableNames(t *testing.T) { + parser := loadJSON(func() Buildable { return new(HeaderCustomUDP) }) + + _, err := parser(`{ + "client": [ + { + "capture": "bad-name", + "rand": 4 + } + ] + }`) + if err == nil || !strings.Contains(err.Error(), "invalid variable name") { + t.Fatalf("expected invalid variable name rejection, got %v", err) + } +} + +func TestHeaderCustomUDPBuildRejectsExprWithoutArgs(t *testing.T) { + parser := loadJSON(func() Buildable { return new(HeaderCustomUDP) }) + + _, err := parser(`{ + "client": [ + { + "transform": { + "op": "concat" + } + } + ] + }`) + if err == nil || !strings.Contains(err.Error(), "transform args") { + t.Fatalf("expected transform arg rejection, got %v", err) + } +} diff --git a/transport/internet/finalmask/finalmask.go b/transport/internet/finalmask/finalmask.go index 5ef25064..f9c92f66 100644 --- a/transport/internet/finalmask/finalmask.go +++ b/transport/internet/finalmask/finalmask.go @@ -113,6 +113,10 @@ type headerManagerConn struct { writeBuf [UDPSize]byte } +type headerReadAddrAware interface { + SetReadAddr(net.Addr) +} + func (c *headerManagerConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) { buf := p if len(buf) < UDPSize { @@ -140,6 +144,9 @@ func (c *headerManagerConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) } for i := range c.conns { + if aware, ok := c.conns[i].(headerReadAddrAware); ok { + aware.SetReadAddr(addr) + } n, _, err = c.conns[i].ReadFrom(newBuf) if n == 0 || err != nil { errors.LogDebug(context.Background(), addr, " mask read err ", err) @@ -175,7 +182,7 @@ func (c *headerManagerConn) WriteTo(p []byte, addr net.Addr) (n int, err error) n = copy(c.writeBuf[sum:], p) for i := len(c.conns) - 1; i >= 0; i-- { - n, err = c.conns[i].WriteTo(c.writeBuf[sum-c.sizes[i]:n+sum], nil) + n, err = c.conns[i].WriteTo(c.writeBuf[sum-c.sizes[i]:n+sum], addr) if n == 0 || err != nil { errors.LogDebug(context.Background(), addr, " mask write err ", err) return 0, nil diff --git a/transport/internet/finalmask/header/custom/config.pb.go b/transport/internet/finalmask/header/custom/config.pb.go index c8db3e1a..2340a544 100644 --- a/transport/internet/finalmask/header/custom/config.pb.go +++ b/transport/internet/finalmask/header/custom/config.pb.go @@ -21,6 +21,188 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type Expr struct { + state protoimpl.MessageState `protogen:"open.v1"` + Op string `protobuf:"bytes,1,opt,name=op,proto3" json:"op,omitempty"` + Args []*ExprArg `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Expr) Reset() { + *x = Expr{} + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Expr) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Expr) ProtoMessage() {} + +func (x *Expr) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_finalmask_header_custom_config_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 Expr.ProtoReflect.Descriptor instead. +func (*Expr) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Expr) GetOp() string { + if x != nil { + return x.Op + } + return "" +} + +func (x *Expr) GetArgs() []*ExprArg { + if x != nil { + return x.Args + } + return nil +} + +type ExprArg struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *ExprArg_Bytes + // *ExprArg_U64 + // *ExprArg_Var + // *ExprArg_Metadata + // *ExprArg_Expr + Value isExprArg_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExprArg) Reset() { + *x = ExprArg{} + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExprArg) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExprArg) ProtoMessage() {} + +func (x *ExprArg) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_finalmask_header_custom_config_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 ExprArg.ProtoReflect.Descriptor instead. +func (*ExprArg) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{1} +} + +func (x *ExprArg) GetValue() isExprArg_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *ExprArg) GetBytes() []byte { + if x != nil { + if x, ok := x.Value.(*ExprArg_Bytes); ok { + return x.Bytes + } + } + return nil +} + +func (x *ExprArg) GetU64() uint64 { + if x != nil { + if x, ok := x.Value.(*ExprArg_U64); ok { + return x.U64 + } + } + return 0 +} + +func (x *ExprArg) GetVar() string { + if x != nil { + if x, ok := x.Value.(*ExprArg_Var); ok { + return x.Var + } + } + return "" +} + +func (x *ExprArg) GetMetadata() string { + if x != nil { + if x, ok := x.Value.(*ExprArg_Metadata); ok { + return x.Metadata + } + } + return "" +} + +func (x *ExprArg) GetExpr() *Expr { + if x != nil { + if x, ok := x.Value.(*ExprArg_Expr); ok { + return x.Expr + } + } + return nil +} + +type isExprArg_Value interface { + isExprArg_Value() +} + +type ExprArg_Bytes struct { + Bytes []byte `protobuf:"bytes,1,opt,name=bytes,proto3,oneof"` +} + +type ExprArg_U64 struct { + U64 uint64 `protobuf:"varint,2,opt,name=u64,proto3,oneof"` +} + +type ExprArg_Var struct { + Var string `protobuf:"bytes,3,opt,name=var,proto3,oneof"` +} + +type ExprArg_Metadata struct { + Metadata string `protobuf:"bytes,4,opt,name=metadata,proto3,oneof"` +} + +type ExprArg_Expr struct { + Expr *Expr `protobuf:"bytes,5,opt,name=expr,proto3,oneof"` +} + +func (*ExprArg_Bytes) isExprArg_Value() {} + +func (*ExprArg_U64) isExprArg_Value() {} + +func (*ExprArg_Var) isExprArg_Value() {} + +func (*ExprArg_Metadata) isExprArg_Value() {} + +func (*ExprArg_Expr) isExprArg_Value() {} + type TCPItem struct { state protoimpl.MessageState `protogen:"open.v1"` DelayMin int64 `protobuf:"varint,1,opt,name=delay_min,json=delayMin,proto3" json:"delay_min,omitempty"` @@ -29,13 +211,16 @@ type TCPItem struct { RandMin int32 `protobuf:"varint,4,opt,name=rand_min,json=randMin,proto3" json:"rand_min,omitempty"` RandMax int32 `protobuf:"varint,5,opt,name=rand_max,json=randMax,proto3" json:"rand_max,omitempty"` Packet []byte `protobuf:"bytes,6,opt,name=packet,proto3" json:"packet,omitempty"` + Save string `protobuf:"bytes,7,opt,name=save,proto3" json:"save,omitempty"` + Var string `protobuf:"bytes,8,opt,name=var,proto3" json:"var,omitempty"` + Expr *Expr `protobuf:"bytes,9,opt,name=expr,proto3" json:"expr,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TCPItem) Reset() { *x = TCPItem{} - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -47,7 +232,7 @@ func (x *TCPItem) String() string { func (*TCPItem) ProtoMessage() {} func (x *TCPItem) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -60,7 +245,7 @@ func (x *TCPItem) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPItem.ProtoReflect.Descriptor instead. func (*TCPItem) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{0} + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{2} } func (x *TCPItem) GetDelayMin() int64 { @@ -105,6 +290,27 @@ func (x *TCPItem) GetPacket() []byte { return nil } +func (x *TCPItem) GetSave() string { + if x != nil { + return x.Save + } + return "" +} + +func (x *TCPItem) GetVar() string { + if x != nil { + return x.Var + } + return "" +} + +func (x *TCPItem) GetExpr() *Expr { + if x != nil { + return x.Expr + } + return nil +} + type TCPSequence struct { state protoimpl.MessageState `protogen:"open.v1"` Sequence []*TCPItem `protobuf:"bytes,1,rep,name=sequence,proto3" json:"sequence,omitempty"` @@ -114,7 +320,7 @@ type TCPSequence struct { func (x *TCPSequence) Reset() { *x = TCPSequence{} - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[1] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -126,7 +332,7 @@ func (x *TCPSequence) String() string { func (*TCPSequence) ProtoMessage() {} func (x *TCPSequence) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[1] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -139,7 +345,7 @@ func (x *TCPSequence) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPSequence.ProtoReflect.Descriptor instead. func (*TCPSequence) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{1} + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{3} } func (x *TCPSequence) GetSequence() []*TCPItem { @@ -160,7 +366,7 @@ type TCPConfig struct { func (x *TCPConfig) Reset() { *x = TCPConfig{} - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[2] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -172,7 +378,7 @@ func (x *TCPConfig) String() string { func (*TCPConfig) ProtoMessage() {} func (x *TCPConfig) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[2] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -185,7 +391,7 @@ func (x *TCPConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPConfig.ProtoReflect.Descriptor instead. func (*TCPConfig) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{2} + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{4} } func (x *TCPConfig) GetClients() []*TCPSequence { @@ -215,13 +421,16 @@ type UDPItem struct { RandMin int32 `protobuf:"varint,2,opt,name=rand_min,json=randMin,proto3" json:"rand_min,omitempty"` RandMax int32 `protobuf:"varint,3,opt,name=rand_max,json=randMax,proto3" json:"rand_max,omitempty"` Packet []byte `protobuf:"bytes,4,opt,name=packet,proto3" json:"packet,omitempty"` + Save string `protobuf:"bytes,5,opt,name=save,proto3" json:"save,omitempty"` + Var string `protobuf:"bytes,6,opt,name=var,proto3" json:"var,omitempty"` + Expr *Expr `protobuf:"bytes,7,opt,name=expr,proto3" json:"expr,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UDPItem) Reset() { *x = UDPItem{} - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[3] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -233,7 +442,7 @@ func (x *UDPItem) String() string { func (*UDPItem) ProtoMessage() {} func (x *UDPItem) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[3] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -246,7 +455,7 @@ func (x *UDPItem) ProtoReflect() protoreflect.Message { // Deprecated: Use UDPItem.ProtoReflect.Descriptor instead. func (*UDPItem) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{3} + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{5} } func (x *UDPItem) GetRand() int32 { @@ -277,6 +486,27 @@ func (x *UDPItem) GetPacket() []byte { return nil } +func (x *UDPItem) GetSave() string { + if x != nil { + return x.Save + } + return "" +} + +func (x *UDPItem) GetVar() string { + if x != nil { + return x.Var + } + return "" +} + +func (x *UDPItem) GetExpr() *Expr { + if x != nil { + return x.Expr + } + return nil +} + type UDPConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Client []*UDPItem `protobuf:"bytes,1,rep,name=client,proto3" json:"client,omitempty"` @@ -287,7 +517,7 @@ type UDPConfig struct { func (x *UDPConfig) Reset() { *x = UDPConfig{} - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[4] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -299,7 +529,7 @@ func (x *UDPConfig) String() string { func (*UDPConfig) ProtoMessage() {} func (x *UDPConfig) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[4] + mi := &file_transport_internet_finalmask_header_custom_config_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -312,7 +542,7 @@ func (x *UDPConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use UDPConfig.ProtoReflect.Descriptor instead. func (*UDPConfig) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{4} + return file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP(), []int{6} } func (x *UDPConfig) GetClient() []*UDPItem { @@ -333,25 +563,41 @@ var File_transport_internet_finalmask_header_custom_config_proto protoreflect.Fi const file_transport_internet_finalmask_header_custom_config_proto_rawDesc = "" + "\n" + - "7transport/internet/finalmask/header/custom/config.proto\x12/xray.transport.internet.finalmask.header.custom\"\xa5\x01\n" + + "7transport/internet/finalmask/header/custom/config.proto\x12/xray.transport.internet.finalmask.header.custom\"d\n" + + "\x04Expr\x12\x0e\n" + + "\x02op\x18\x01 \x01(\tR\x02op\x12L\n" + + "\x04args\x18\x02 \x03(\v28.xray.transport.internet.finalmask.header.custom.ExprArgR\x04args\"\xbd\x01\n" + + "\aExprArg\x12\x16\n" + + "\x05bytes\x18\x01 \x01(\fH\x00R\x05bytes\x12\x12\n" + + "\x03u64\x18\x02 \x01(\x04H\x00R\x03u64\x12\x12\n" + + "\x03var\x18\x03 \x01(\tH\x00R\x03var\x12\x1c\n" + + "\bmetadata\x18\x04 \x01(\tH\x00R\bmetadata\x12K\n" + + "\x04expr\x18\x05 \x01(\v25.xray.transport.internet.finalmask.header.custom.ExprH\x00R\x04exprB\a\n" + + "\x05value\"\x96\x02\n" + "\aTCPItem\x12\x1b\n" + "\tdelay_min\x18\x01 \x01(\x03R\bdelayMin\x12\x1b\n" + "\tdelay_max\x18\x02 \x01(\x03R\bdelayMax\x12\x12\n" + "\x04rand\x18\x03 \x01(\x05R\x04rand\x12\x19\n" + "\brand_min\x18\x04 \x01(\x05R\arandMin\x12\x19\n" + "\brand_max\x18\x05 \x01(\x05R\arandMax\x12\x16\n" + - "\x06packet\x18\x06 \x01(\fR\x06packet\"c\n" + + "\x06packet\x18\x06 \x01(\fR\x06packet\x12\x12\n" + + "\x04save\x18\a \x01(\tR\x04save\x12\x10\n" + + "\x03var\x18\b \x01(\tR\x03var\x12I\n" + + "\x04expr\x18\t \x01(\v25.xray.transport.internet.finalmask.header.custom.ExprR\x04expr\"c\n" + "\vTCPSequence\x12T\n" + "\bsequence\x18\x01 \x03(\v28.xray.transport.internet.finalmask.header.custom.TCPItemR\bsequence\"\x91\x02\n" + "\tTCPConfig\x12V\n" + "\aclients\x18\x01 \x03(\v2<.xray.transport.internet.finalmask.header.custom.TCPSequenceR\aclients\x12V\n" + "\aservers\x18\x02 \x03(\v2<.xray.transport.internet.finalmask.header.custom.TCPSequenceR\aservers\x12T\n" + - "\x06errors\x18\x03 \x03(\v2<.xray.transport.internet.finalmask.header.custom.TCPSequenceR\x06errors\"k\n" + + "\x06errors\x18\x03 \x03(\v2<.xray.transport.internet.finalmask.header.custom.TCPSequenceR\x06errors\"\xdc\x01\n" + "\aUDPItem\x12\x12\n" + "\x04rand\x18\x01 \x01(\x05R\x04rand\x12\x19\n" + "\brand_min\x18\x02 \x01(\x05R\arandMin\x12\x19\n" + "\brand_max\x18\x03 \x01(\x05R\arandMax\x12\x16\n" + - "\x06packet\x18\x04 \x01(\fR\x06packet\"\xaf\x01\n" + + "\x06packet\x18\x04 \x01(\fR\x06packet\x12\x12\n" + + "\x04save\x18\x05 \x01(\tR\x04save\x12\x10\n" + + "\x03var\x18\x06 \x01(\tR\x03var\x12I\n" + + "\x04expr\x18\a \x01(\v25.xray.transport.internet.finalmask.header.custom.ExprR\x04expr\"\xaf\x01\n" + "\tUDPConfig\x12P\n" + "\x06client\x18\x01 \x03(\v28.xray.transport.internet.finalmask.header.custom.UDPItemR\x06client\x12P\n" + "\x06server\x18\x02 \x03(\v28.xray.transport.internet.finalmask.header.custom.UDPItemR\x06serverB\xaf\x01\n" + @@ -369,26 +615,32 @@ func file_transport_internet_finalmask_header_custom_config_proto_rawDescGZIP() return file_transport_internet_finalmask_header_custom_config_proto_rawDescData } -var file_transport_internet_finalmask_header_custom_config_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_transport_internet_finalmask_header_custom_config_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_transport_internet_finalmask_header_custom_config_proto_goTypes = []any{ - (*TCPItem)(nil), // 0: xray.transport.internet.finalmask.header.custom.TCPItem - (*TCPSequence)(nil), // 1: xray.transport.internet.finalmask.header.custom.TCPSequence - (*TCPConfig)(nil), // 2: xray.transport.internet.finalmask.header.custom.TCPConfig - (*UDPItem)(nil), // 3: xray.transport.internet.finalmask.header.custom.UDPItem - (*UDPConfig)(nil), // 4: xray.transport.internet.finalmask.header.custom.UDPConfig + (*Expr)(nil), // 0: xray.transport.internet.finalmask.header.custom.Expr + (*ExprArg)(nil), // 1: xray.transport.internet.finalmask.header.custom.ExprArg + (*TCPItem)(nil), // 2: xray.transport.internet.finalmask.header.custom.TCPItem + (*TCPSequence)(nil), // 3: xray.transport.internet.finalmask.header.custom.TCPSequence + (*TCPConfig)(nil), // 4: xray.transport.internet.finalmask.header.custom.TCPConfig + (*UDPItem)(nil), // 5: xray.transport.internet.finalmask.header.custom.UDPItem + (*UDPConfig)(nil), // 6: xray.transport.internet.finalmask.header.custom.UDPConfig } var file_transport_internet_finalmask_header_custom_config_proto_depIdxs = []int32{ - 0, // 0: xray.transport.internet.finalmask.header.custom.TCPSequence.sequence:type_name -> xray.transport.internet.finalmask.header.custom.TCPItem - 1, // 1: xray.transport.internet.finalmask.header.custom.TCPConfig.clients:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence - 1, // 2: xray.transport.internet.finalmask.header.custom.TCPConfig.servers:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence - 1, // 3: xray.transport.internet.finalmask.header.custom.TCPConfig.errors:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence - 3, // 4: xray.transport.internet.finalmask.header.custom.UDPConfig.client:type_name -> xray.transport.internet.finalmask.header.custom.UDPItem - 3, // 5: xray.transport.internet.finalmask.header.custom.UDPConfig.server:type_name -> xray.transport.internet.finalmask.header.custom.UDPItem - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 1, // 0: xray.transport.internet.finalmask.header.custom.Expr.args:type_name -> xray.transport.internet.finalmask.header.custom.ExprArg + 0, // 1: xray.transport.internet.finalmask.header.custom.ExprArg.expr:type_name -> xray.transport.internet.finalmask.header.custom.Expr + 0, // 2: xray.transport.internet.finalmask.header.custom.TCPItem.expr:type_name -> xray.transport.internet.finalmask.header.custom.Expr + 2, // 3: xray.transport.internet.finalmask.header.custom.TCPSequence.sequence:type_name -> xray.transport.internet.finalmask.header.custom.TCPItem + 3, // 4: xray.transport.internet.finalmask.header.custom.TCPConfig.clients:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence + 3, // 5: xray.transport.internet.finalmask.header.custom.TCPConfig.servers:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence + 3, // 6: xray.transport.internet.finalmask.header.custom.TCPConfig.errors:type_name -> xray.transport.internet.finalmask.header.custom.TCPSequence + 0, // 7: xray.transport.internet.finalmask.header.custom.UDPItem.expr:type_name -> xray.transport.internet.finalmask.header.custom.Expr + 5, // 8: xray.transport.internet.finalmask.header.custom.UDPConfig.client:type_name -> xray.transport.internet.finalmask.header.custom.UDPItem + 5, // 9: xray.transport.internet.finalmask.header.custom.UDPConfig.server:type_name -> xray.transport.internet.finalmask.header.custom.UDPItem + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_transport_internet_finalmask_header_custom_config_proto_init() } @@ -396,13 +648,20 @@ func file_transport_internet_finalmask_header_custom_config_proto_init() { if File_transport_internet_finalmask_header_custom_config_proto != nil { return } + file_transport_internet_finalmask_header_custom_config_proto_msgTypes[1].OneofWrappers = []any{ + (*ExprArg_Bytes)(nil), + (*ExprArg_U64)(nil), + (*ExprArg_Var)(nil), + (*ExprArg_Metadata)(nil), + (*ExprArg_Expr)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_header_custom_config_proto_rawDesc), len(file_transport_internet_finalmask_header_custom_config_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/transport/internet/finalmask/header/custom/config.proto b/transport/internet/finalmask/header/custom/config.proto index cbf498ef..3602e522 100644 --- a/transport/internet/finalmask/header/custom/config.proto +++ b/transport/internet/finalmask/header/custom/config.proto @@ -6,6 +6,21 @@ option go_package = "github.com/xtls/xray-core/transport/internet/finalmask/head option java_package = "com.xray.transport.internet.finalmask.header.custom"; option java_multiple_files = true; +message Expr { + string op = 1; + repeated ExprArg args = 2; +} + +message ExprArg { + oneof value { + bytes bytes = 1; + uint64 u64 = 2; + string var = 3; + string metadata = 4; + Expr expr = 5; + } +} + message TCPItem { int64 delay_min = 1; int64 delay_max = 2; @@ -13,6 +28,9 @@ message TCPItem { int32 rand_min = 4; int32 rand_max = 5; bytes packet = 6; + string save = 7; + string var = 8; + Expr expr = 9; } message TCPSequence { @@ -30,9 +48,12 @@ message UDPItem { int32 rand_min = 2; int32 rand_max = 3; bytes packet = 4; + string save = 5; + string var = 6; + Expr expr = 7; } message UDPConfig { repeated UDPItem client = 1; repeated UDPItem server = 2; -} \ No newline at end of file +} diff --git a/transport/internet/finalmask/header/custom/evaluator.go b/transport/internet/finalmask/header/custom/evaluator.go new file mode 100644 index 00000000..0fba850a --- /dev/null +++ b/transport/internet/finalmask/header/custom/evaluator.go @@ -0,0 +1,406 @@ +package custom + +import ( + "encoding/binary" + "net" + + "github.com/xtls/xray-core/common/crypto" + "github.com/xtls/xray-core/common/errors" +) + +type evalValue struct { + bytes []byte + u64 *uint64 +} + +type evalContext struct { + vars map[string][]byte + metadata map[string]evalValue +} + +func newEvalContext() *evalContext { + return &evalContext{ + vars: make(map[string][]byte), + metadata: make(map[string]evalValue), + } +} + +func newEvalContextWithAddrs(local, remote net.Addr) *evalContext { + ctx := newEvalContext() + loadMetadata(ctx.metadata, "local", local) + loadMetadata(ctx.metadata, "remote", remote) + return ctx +} + +func evaluateUDPItems(items []*UDPItem) ([]byte, error) { + return evaluateUDPItemsWithContext(items, newEvalContext()) +} + +func evaluateUDPItemsWithContext(items []*UDPItem, ctx *evalContext) ([]byte, error) { + var out []byte + for _, item := range items { + value, err := evaluateItem(item.Rand, item.RandMin, item.RandMax, item.Packet, item.Save, item.Var, item.Expr, ctx) + if err != nil { + return nil, err + } + out = append(out, value...) + } + return out, nil +} + +func measureUDPItems(items []*UDPItem) (int, error) { + return measureUDPItemsWithFallback(items, nil) +} + +func measureUDPItemsWithFallback(items []*UDPItem, fallback map[string]int) (int, error) { + sizeCtx := make(map[string]int) + for key, value := range fallback { + sizeCtx[key] = value + } + total := 0 + for _, item := range items { + itemSize, err := measureItem(item.Rand, item.Packet, item.Save, item.Var, item.Expr, sizeCtx) + if err != nil { + return 0, err + } + total += itemSize + } + return total, nil +} + +func collectSavedUDPSizes(items []*UDPItem) map[string]int { + sizeCtx := make(map[string]int) + for _, item := range items { + itemSize, err := measureItem(item.Rand, item.Packet, item.Save, item.Var, item.Expr, sizeCtx) + if err != nil { + continue + } + if item.Save != "" { + sizeCtx[item.Save] = itemSize + } + } + return sizeCtx +} + +func measureItem(randLen int32, packet []byte, save, varName string, expr *Expr, sizeCtx map[string]int) (int, error) { + var size int + switch { + case randLen > 0: + size = int(randLen) + case len(packet) > 0: + size = len(packet) + case varName != "": + length, ok := sizeCtx[varName] + if !ok { + return 0, errors.New("unknown variable: ", varName) + } + size = length + case expr != nil: + exprSize, err := measureExpr(expr, sizeCtx) + if err != nil { + return 0, err + } + size = exprSize + default: + size = 0 + } + + if save != "" { + sizeCtx[save] = size + } + + return size, nil +} + +func evaluateTCPSequence(sequence *TCPSequence) ([]byte, error) { + ctx := newEvalContext() + var out []byte + for _, item := range sequence.Sequence { + value, err := evaluateItem(item.Rand, item.RandMin, item.RandMax, item.Packet, item.Save, item.Var, item.Expr, ctx) + if err != nil { + return nil, err + } + out = append(out, value...) + } + return out, nil +} + +func evaluateItem(randLen, randMin, randMax int32, packet []byte, save, varName string, expr *Expr, ctx *evalContext) ([]byte, error) { + var value []byte + switch { + case randLen > 0: + value = make([]byte, randLen) + crypto.RandBytesBetween(value, byte(randMin), byte(randMax)) + case len(packet) > 0: + value = append([]byte(nil), packet...) + case varName != "": + saved, ok := ctx.vars[varName] + if !ok { + return nil, errors.New("unknown variable: ", varName) + } + value = append([]byte(nil), saved...) + case expr != nil: + evaluated, err := evaluateExpr(expr, ctx) + if err != nil { + return nil, err + } + bytesValue, err := evaluated.asBytes() + if err != nil { + return nil, err + } + value = bytesValue + default: + value = nil + } + + if save != "" { + ctx.vars[save] = append([]byte(nil), value...) + } + + return value, nil +} + +func evaluateExpr(expr *Expr, ctx *evalContext) (evalValue, error) { + switch expr.GetOp() { + case "concat": + var out []byte + for _, arg := range expr.GetArgs() { + value, err := evaluateExprArg(arg, ctx) + if err != nil { + return evalValue{}, err + } + bytesValue, err := value.asBytes() + if err != nil { + return evalValue{}, err + } + out = append(out, bytesValue...) + } + return evalValue{bytes: out}, nil + case "slice": + if len(expr.GetArgs()) != 3 { + return evalValue{}, errors.New("slice expects 3 args") + } + source, err := evaluateExprArg(expr.GetArgs()[0], ctx) + if err != nil { + return evalValue{}, err + } + offset, err := evaluateExprArg(expr.GetArgs()[1], ctx) + if err != nil { + return evalValue{}, err + } + length, err := evaluateExprArg(expr.GetArgs()[2], ctx) + if err != nil { + return evalValue{}, err + } + sourceBytes, err := source.asBytes() + if err != nil { + return evalValue{}, err + } + offsetU64, err := offset.asU64() + if err != nil { + return evalValue{}, err + } + lengthU64, err := length.asU64() + if err != nil { + return evalValue{}, err + } + end := offsetU64 + lengthU64 + if end > uint64(len(sourceBytes)) { + return evalValue{}, errors.New("slice out of bounds") + } + return evalValue{bytes: append([]byte(nil), sourceBytes[offsetU64:end]...)}, nil + case "xor16": + return evaluateXor(expr.GetArgs(), 0xFFFF, 2, ctx) + case "xor32": + return evaluateXor(expr.GetArgs(), 0xFFFFFFFF, 4, ctx) + case "be16": + if len(expr.GetArgs()) != 1 { + return evalValue{}, errors.New("be16 expects 1 arg") + } + value, err := evaluateExprArg(expr.GetArgs()[0], ctx) + if err != nil { + return evalValue{}, err + } + u64Value, err := value.asU64() + if err != nil { + return evalValue{}, err + } + if u64Value > 0xFFFF { + return evalValue{}, errors.New("be16 overflow") + } + out := make([]byte, 2) + binary.BigEndian.PutUint16(out, uint16(u64Value)) + return evalValue{bytes: out}, nil + case "be32": + if len(expr.GetArgs()) != 1 { + return evalValue{}, errors.New("be32 expects 1 arg") + } + value, err := evaluateExprArg(expr.GetArgs()[0], ctx) + if err != nil { + return evalValue{}, err + } + u64Value, err := value.asU64() + if err != nil { + return evalValue{}, err + } + if u64Value > 0xFFFFFFFF { + return evalValue{}, errors.New("be32 overflow") + } + out := make([]byte, 4) + binary.BigEndian.PutUint32(out, uint32(u64Value)) + return evalValue{bytes: out}, nil + default: + return evalValue{}, errors.New("unsupported expr op: ", expr.GetOp()) + } +} + +func evaluateXor(args []*ExprArg, mask uint64, width int, ctx *evalContext) (evalValue, error) { + if len(args) != 2 { + return evalValue{}, errors.New("xor expects 2 args") + } + left, err := evaluateExprArg(args[0], ctx) + if err != nil { + return evalValue{}, err + } + right, err := evaluateExprArg(args[1], ctx) + if err != nil { + return evalValue{}, err + } + leftU64, err := left.asU64() + if err != nil { + return evalValue{}, err + } + rightU64, err := right.asU64() + if err != nil { + return evalValue{}, err + } + if width == 2 && (leftU64 > 0xFFFF || rightU64 > 0xFFFF) { + return evalValue{}, errors.New("xor16 overflow") + } + if width == 4 && (leftU64 > 0xFFFFFFFF || rightU64 > 0xFFFFFFFF) { + return evalValue{}, errors.New("xor32 overflow") + } + result := (leftU64 ^ rightU64) & mask + return evalValue{u64: &result}, nil +} + +func measureExpr(expr *Expr, sizeCtx map[string]int) (int, error) { + switch expr.GetOp() { + case "concat": + total := 0 + for _, arg := range expr.GetArgs() { + size, err := measureExprArg(arg, sizeCtx) + if err != nil { + return 0, err + } + total += size + } + return total, nil + case "slice": + if len(expr.GetArgs()) != 3 { + return 0, errors.New("slice expects 3 args") + } + lengthArg := expr.GetArgs()[2] + if value, ok := lengthArg.GetValue().(*ExprArg_U64); ok { + return int(value.U64), nil + } + return 0, errors.New("slice length must be u64") + case "be16": + return 2, nil + case "be32": + return 4, nil + default: + return 0, errors.New("expr size is not bytes for op: ", expr.GetOp()) + } +} + +func evaluateExprArg(arg *ExprArg, ctx *evalContext) (evalValue, error) { + switch value := arg.GetValue().(type) { + case *ExprArg_Bytes: + return evalValue{bytes: append([]byte(nil), value.Bytes...)}, nil + case *ExprArg_U64: + return evalValue{u64: &value.U64}, nil + case *ExprArg_Var: + saved, ok := ctx.vars[value.Var] + if !ok { + return evalValue{}, errors.New("unknown variable: ", value.Var) + } + return evalValue{bytes: append([]byte(nil), saved...)}, nil + case *ExprArg_Metadata: + metadata, ok := ctx.metadata[value.Metadata] + if !ok { + return evalValue{}, errors.New("unknown metadata: ", value.Metadata) + } + return metadata, nil + case *ExprArg_Expr: + return evaluateExpr(value.Expr, ctx) + default: + return evalValue{}, errors.New("empty expr arg") + } +} + +func measureExprArg(arg *ExprArg, sizeCtx map[string]int) (int, error) { + switch value := arg.GetValue().(type) { + case *ExprArg_Bytes: + return len(value.Bytes), nil + case *ExprArg_U64: + return 0, errors.New("u64 arg has no byte width") + case *ExprArg_Var: + length, ok := sizeCtx[value.Var] + if !ok { + return 0, errors.New("unknown variable: ", value.Var) + } + return length, nil + case *ExprArg_Metadata: + return 0, errors.New("metadata not implemented: ", value.Metadata) + case *ExprArg_Expr: + return measureExpr(value.Expr, sizeCtx) + default: + return 0, errors.New("empty expr arg") + } +} + +func (v evalValue) asBytes() ([]byte, error) { + if v.bytes != nil { + return append([]byte(nil), v.bytes...), nil + } + return nil, errors.New("expr value is not bytes") +} + +func (v evalValue) asU64() (uint64, error) { + if v.u64 != nil { + return *v.u64, nil + } + return 0, errors.New("expr value is not u64") +} + +func sizeMapFromEvalContext(ctx *evalContext) map[string]int { + sizes := make(map[string]int, len(ctx.vars)) + for key, value := range ctx.vars { + sizes[key] = len(value) + } + return sizes +} + +func loadMetadata(dst map[string]evalValue, prefix string, addr net.Addr) { + if addr == nil { + return + } + + switch value := addr.(type) { + case *net.UDPAddr: + loadIPPortMetadata(dst, prefix, value.IP, value.Port) + case *net.TCPAddr: + loadIPPortMetadata(dst, prefix, value.IP, value.Port) + } +} + +func loadIPPortMetadata(dst map[string]evalValue, prefix string, ip net.IP, port int) { + portValue := uint64(port) + dst[prefix+"_port"] = evalValue{u64: &portValue} + + if ip4 := ip.To4(); ip4 != nil { + ipValue := uint64(binary.BigEndian.Uint32(ip4)) + dst[prefix+"_ip4_u32"] = evalValue{u64: &ipValue} + } +} diff --git a/transport/internet/finalmask/header/custom/evaluator_test.go b/transport/internet/finalmask/header/custom/evaluator_test.go new file mode 100644 index 00000000..6bd34264 --- /dev/null +++ b/transport/internet/finalmask/header/custom/evaluator_test.go @@ -0,0 +1,130 @@ +package custom + +import ( + "bytes" + "testing" +) + +func TestEvaluatorSaveAndReuseWithinPacket(t *testing.T) { + items := []*UDPItem{ + { + Rand: 4, + RandMin: 0x2A, + RandMax: 0x2A, + Save: "txid", + }, + { + Var: "txid", + }, + } + + got, err := evaluateUDPItems(items) + if err != nil { + t.Fatal(err) + } + + want := bytes.Repeat([]byte{0x2A}, 8) + if !bytes.Equal(got, want) { + t.Fatalf("unexpected output: %x", got) + } +} + +func TestEvaluatorSliceReturnsWindow(t *testing.T) { + sequence := &TCPSequence{ + Sequence: []*TCPItem{ + { + Expr: &Expr{ + Op: "slice", + Args: []*ExprArg{ + {Value: &ExprArg_Bytes{Bytes: []byte{1, 2, 3, 4}}}, + {Value: &ExprArg_U64{U64: 1}}, + {Value: &ExprArg_U64{U64: 2}}, + }, + }, + }, + }, + } + + got, err := evaluateTCPSequence(sequence) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(got, []byte{2, 3}) { + t.Fatalf("unexpected output: %x", got) + } +} + +func TestEvaluatorConcatPreservesOrder(t *testing.T) { + items := []*UDPItem{ + { + Expr: &Expr{ + Op: "concat", + Args: []*ExprArg{ + {Value: &ExprArg_Bytes{Bytes: []byte("ab")}}, + {Value: &ExprArg_Bytes{Bytes: []byte("cd")}}, + {Value: &ExprArg_Bytes{Bytes: []byte("ef")}}, + }, + }, + }, + } + + got, err := evaluateUDPItems(items) + if err != nil { + t.Fatal(err) + } + + if string(got) != "abcdef" { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestEvaluatorBeXorProducesExpectedBytes(t *testing.T) { + items := []*UDPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + { + Value: &ExprArg_Expr{ + Expr: &Expr{ + Op: "xor16", + Args: []*ExprArg{ + {Value: &ExprArg_U64{U64: 0x1234}}, + {Value: &ExprArg_U64{U64: 0xFFFF}}, + }, + }, + }, + }, + }, + }, + }, + } + + got, err := evaluateUDPItems(items) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(got, []byte{0xED, 0xCB}) { + t.Fatalf("unexpected output: %x", got) + } +} + +func TestEvaluatorRejectsInvalidArgType(t *testing.T) { + items := []*UDPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Bytes{Bytes: []byte{0x01}}}, + }, + }, + }, + } + + _, err := evaluateUDPItems(items) + if err == nil { + t.Fatal("expected evaluator error") + } +} diff --git a/transport/internet/finalmask/header/custom/metadata_test.go b/transport/internet/finalmask/header/custom/metadata_test.go new file mode 100644 index 00000000..ee300bab --- /dev/null +++ b/transport/internet/finalmask/header/custom/metadata_test.go @@ -0,0 +1,210 @@ +package custom + +import ( + "bytes" + "encoding/binary" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/xtls/xray-core/transport/internet/finalmask" +) + +func TestMetadataEvaluatorRejectsUnknownName(t *testing.T) { + items := []*UDPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "nope"}}, + }, + }, + }, + } + + _, err := evaluateUDPItemsWithContext(items, newEvalContext()) + if err == nil || !strings.Contains(err.Error(), "unknown metadata") { + t.Fatalf("expected unknown metadata error, got %v", err) + } +} + +func TestMetadataUDPWriteUsesRemotePort(t *testing.T) { + cfg := &UDPConfig{ + Client: []*UDPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "remote_port"}}, + }, + }, + }, + }, + } + + clientRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + serverRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer serverRaw.Close() + + client, err := finalmask.NewUdpmaskManager([]finalmask.Udpmask{cfg}).WrapPacketConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + + payload := []byte("meta") + if _, err := client.WriteTo(payload, serverRaw.LocalAddr()); err != nil { + t.Fatal(err) + } + + wire := make([]byte, 64) + _ = serverRaw.SetDeadline(time.Now().Add(time.Second)) + n, _, err := serverRaw.ReadFrom(wire) + if err != nil { + t.Fatal(err) + } + if n != len(payload)+2 { + t.Fatalf("unexpected wire size: %d", n) + } + wantPort := uint16(serverRaw.LocalAddr().(*net.UDPAddr).Port) + if got := binary.BigEndian.Uint16(wire[:2]); got != wantPort { + t.Fatalf("unexpected encoded port: got=%d want=%d", got, wantPort) + } + if !bytes.Equal(wire[2:n], payload) { + t.Fatalf("unexpected payload: %q", wire[2:n]) + } +} + +func TestMetadataTCPHandshakeUsesEndpointPorts(t *testing.T) { + clientCfg := &TCPConfig{ + Clients: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "remote_port"}}, + }, + }, + }, + }, + }, + }, + Servers: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "local_port"}}, + }, + }, + }, + }, + }, + }, + } + serverCfg := &TCPConfig{ + Clients: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "local_port"}}, + }, + }, + }, + }, + }, + }, + Servers: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Expr: &Expr{ + Op: "be16", + Args: []*ExprArg{ + {Value: &ExprArg_Metadata{Metadata: "remote_port"}}, + }, + }, + }, + }, + }, + }, + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + serverRawCh := make(chan net.Conn, 1) + errCh := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + errCh <- err + return + } + serverRawCh <- conn + }() + + clientRaw, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + var serverRaw net.Conn + select { + case serverRaw = <-serverRawCh: + case err := <-errCh: + t.Fatal(err) + case <-time.After(2 * time.Second): + t.Fatal("accept timeout") + } + defer serverRaw.Close() + + client, err := clientCfg.WrapConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := serverCfg.WrapConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(time.Second)) + _ = server.SetDeadline(time.Now().Add(time.Second)) + + writeErr := make(chan error, 1) + go func() { + _, err := client.Write([]byte("meta")) + writeErr <- err + }() + + buf := make([]byte, 4) + if _, err := io.ReadFull(server, buf); err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf, []byte("meta")) { + t.Fatalf("unexpected payload: %q", buf) + } + if err := <-writeErr; err != nil { + t.Fatal(err) + } +} diff --git a/transport/internet/finalmask/header/custom/state.go b/transport/internet/finalmask/header/custom/state.go new file mode 100644 index 00000000..d484168d --- /dev/null +++ b/transport/internet/finalmask/header/custom/state.go @@ -0,0 +1,57 @@ +package custom + +import ( + "sync" + "time" +) + +type stateEntry struct { + vars map[string][]byte + expiresAt time.Time +} + +type stateStore struct { + mu sync.Mutex + ttl time.Duration + entries map[string]stateEntry +} + +func newStateStore(ttl time.Duration) *stateStore { + return &stateStore{ + ttl: ttl, + entries: make(map[string]stateEntry), + } +} + +func (s *stateStore) get(key string) (map[string][]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.entries[key] + if !ok { + return nil, false + } + if !entry.expiresAt.IsZero() && time.Now().After(entry.expiresAt) { + delete(s.entries, key) + return nil, false + } + return cloneVars(entry.vars), true +} + +func (s *stateStore) set(key string, vars map[string][]byte) { + s.mu.Lock() + defer s.mu.Unlock() + + s.entries[key] = stateEntry{ + vars: cloneVars(vars), + expiresAt: time.Now().Add(s.ttl), + } +} + +func cloneVars(vars map[string][]byte) map[string][]byte { + cloned := make(map[string][]byte, len(vars)) + for key, value := range vars { + cloned[key] = append([]byte(nil), value...) + } + return cloned +} diff --git a/transport/internet/finalmask/header/custom/state_test.go b/transport/internet/finalmask/header/custom/state_test.go new file mode 100644 index 00000000..4ef0a047 --- /dev/null +++ b/transport/internet/finalmask/header/custom/state_test.go @@ -0,0 +1,105 @@ +package custom + +import ( + "bytes" + "net" + "testing" + "time" + + "github.com/xtls/xray-core/transport/internet/finalmask" +) + +func mustSendRecvUDP(t *testing.T, from net.PacketConn, to net.PacketConn, msg []byte) { + t.Helper() + + go func() { + _, err := from.WriteTo(msg, to.LocalAddr()) + if err != nil { + t.Error(err) + } + }() + + buf := make([]byte, 1024) + n, _, err := to.ReadFrom(buf) + if err != nil { + t.Fatal(err) + } + if n != len(msg) { + t.Fatalf("unexpected size: %d", n) + } + if !bytes.Equal(buf[:n], msg) { + t.Fatalf("unexpected payload: %q", buf[:n]) + } +} + +func TestStateUDPResponseReusesPriorCapturedValues(t *testing.T) { + cfg := &UDPConfig{ + Client: []*UDPItem{ + { + Rand: 2, + RandMin: 0x2A, + RandMax: 0x2A, + Save: "txid", + }, + }, + Server: []*UDPItem{ + { + Var: "txid", + }, + }, + } + maskManager := finalmask.NewUdpmaskManager([]finalmask.Udpmask{cfg}) + + clientRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + serverRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer serverRaw.Close() + + client, err := maskManager.WrapPacketConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := maskManager.WrapPacketConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(2 * time.Second)) + _ = server.SetDeadline(time.Now().Add(2 * time.Second)) + + mustSendRecvUDP(t, client, server, []byte("client->server")) + mustSendRecvUDP(t, server, client, []byte("server->client")) +} + +func TestStateStoreIsolatesKeys(t *testing.T) { + store := newStateStore(5 * time.Second) + store.set("a", map[string][]byte{"txid": {0x01}}) + store.set("b", map[string][]byte{"txid": {0x02}}) + + varsA, ok := store.get("a") + if !ok || len(varsA["txid"]) != 1 || varsA["txid"][0] != 0x01 { + t.Fatalf("unexpected vars for key a: %v", varsA) + } + varsB, ok := store.get("b") + if !ok || len(varsB["txid"]) != 1 || varsB["txid"][0] != 0x02 { + t.Fatalf("unexpected vars for key b: %v", varsB) + } +} + +func TestStateStoreExpiresEntries(t *testing.T) { + store := newStateStore(10 * time.Millisecond) + store.set("a", map[string][]byte{"txid": {0x01}}) + + time.Sleep(20 * time.Millisecond) + + if _, ok := store.get("a"); ok { + t.Fatal("expected expired state entry to be removed") + } +} diff --git a/transport/internet/finalmask/header/custom/tcp.go b/transport/internet/finalmask/header/custom/tcp.go index ef631984..f41c3dac 100644 --- a/transport/internet/finalmask/header/custom/tcp.go +++ b/transport/internet/finalmask/header/custom/tcp.go @@ -14,6 +14,7 @@ import ( type tcpCustomClient struct { clients []*TCPSequence servers []*TCPSequence + state *stateStore } type tcpCustomClientConn struct { @@ -31,6 +32,7 @@ func NewConnClientTCP(c *TCPConfig, raw net.Conn) (net.Conn, error) { header: &tcpCustomClient{ clients: c.Clients, servers: c.Servers, + state: newStateStore(5 * time.Second), }, } @@ -63,16 +65,20 @@ func (c *tcpCustomClientConn) Read(p []byte) (n int, err error) { func (c *tcpCustomClientConn) Write(p []byte) (n int, err error) { c.once.Do(func() { + ctx := newEvalContextWithAddrs(c.LocalAddr(), c.RemoteAddr()) + if vars, ok := c.header.state.get(tcpStateKey(c.LocalAddr(), c.RemoteAddr())); ok { + ctx.vars = cloneVars(vars) + } i := 0 j := 0 for i = range c.header.clients { - if !writeSequence(c.Conn, c.header.clients[i]) { + if !writeSequenceWithContext(c.Conn, c.header.clients[i], ctx) { c.wg.Done() return } if j < len(c.header.servers) { - if !readSequence(c.Conn, c.header.servers[j]) { + if !readSequenceWithContext(c.Conn, c.header.servers[j], ctx) { c.wg.Done() return } @@ -81,13 +87,14 @@ func (c *tcpCustomClientConn) Write(p []byte) (n int, err error) { } for j < len(c.header.servers) { - if !readSequence(c.Conn, c.header.servers[j]) { + if !readSequenceWithContext(c.Conn, c.header.servers[j], ctx) { c.wg.Done() return } j++ } + c.header.state.set(tcpStateKey(c.LocalAddr(), c.RemoteAddr()), ctx.vars) c.auth = true c.wg.Done() }) @@ -105,6 +112,7 @@ type tcpCustomServer struct { clients []*TCPSequence servers []*TCPSequence errors []*TCPSequence + state *stateStore } type tcpCustomServerConn struct { @@ -123,6 +131,7 @@ func NewConnServerTCP(c *TCPConfig, raw net.Conn) (net.Conn, error) { clients: c.Clients, servers: c.Servers, errors: c.Errors, + state: newStateStore(5 * time.Second), }, } @@ -145,19 +154,23 @@ func (c *tcpCustomServerConn) Splice() bool { func (c *tcpCustomServerConn) Read(p []byte) (n int, err error) { c.once.Do(func() { + ctx := newEvalContextWithAddrs(c.LocalAddr(), c.RemoteAddr()) + if vars, ok := c.header.state.get(tcpStateKey(c.LocalAddr(), c.RemoteAddr())); ok { + ctx.vars = cloneVars(vars) + } i := 0 j := 0 for i = range c.header.clients { - if !readSequence(c.Conn, c.header.clients[i]) { + if !readSequenceWithContext(c.Conn, c.header.clients[i], ctx) { if i < len(c.header.errors) { - writeSequence(c.Conn, c.header.errors[i]) + writeSequenceWithContext(c.Conn, c.header.errors[i], ctx) } c.wg.Done() return } if j < len(c.header.servers) { - if !writeSequence(c.Conn, c.header.servers[j]) { + if !writeSequenceWithContext(c.Conn, c.header.servers[j], ctx) { c.wg.Done() return } @@ -166,13 +179,14 @@ func (c *tcpCustomServerConn) Read(p []byte) (n int, err error) { } for j < len(c.header.servers) { - if !writeSequence(c.Conn, c.header.servers[j]) { + if !writeSequenceWithContext(c.Conn, c.header.servers[j], ctx) { c.wg.Done() return } j++ } + c.header.state.set(tcpStateKey(c.LocalAddr(), c.RemoteAddr()), ctx.vars) c.auth = true c.wg.Done() }) @@ -197,24 +211,56 @@ func (c *tcpCustomServerConn) Write(p []byte) (n int, err error) { } func readSequence(r io.Reader, sequence *TCPSequence) bool { + return readSequenceWithContext(r, sequence, newEvalContext()) +} + +func readSequenceWithContext(r io.Reader, sequence *TCPSequence, ctx *evalContext) bool { for _, item := range sequence.Sequence { - length := max(int(item.Rand), len(item.Packet)) + length, err := measureItem(item.Rand, item.Packet, item.Save, item.Var, item.Expr, sizeMapFromEvalContext(ctx)) + if err != nil { + return false + } buf := make([]byte, length) n, err := io.ReadFull(r, buf) if err != nil { return false } - if item.Rand > 0 && n != length { + if n != length { return false } - if len(item.Packet) > 0 && !bytes.Equal(item.Packet, buf[:n]) { - return false + switch { + case item.Rand > 0: + case len(item.Packet) > 0: + if !bytes.Equal(item.Packet, buf[:n]) { + return false + } + case item.Var != "": + saved, ok := ctx.vars[item.Var] + if !ok || !bytes.Equal(saved, buf[:n]) { + return false + } + case item.Expr != nil: + evaluated, err := evaluateExpr(item.Expr, ctx) + if err != nil { + return false + } + expected, err := evaluated.asBytes() + if err != nil || !bytes.Equal(expected, buf[:n]) { + return false + } + } + if item.Save != "" { + ctx.vars[item.Save] = append([]byte(nil), buf[:n]...) } } return true } func writeSequence(w io.Writer, sequence *TCPSequence) bool { + return writeSequenceWithContext(w, sequence, newEvalContext()) +} + +func writeSequenceWithContext(w io.Writer, sequence *TCPSequence, ctx *evalContext) bool { var merged []byte for _, item := range sequence.Sequence { if item.DelayMax > 0 { @@ -227,13 +273,11 @@ func writeSequence(w io.Writer, sequence *TCPSequence) bool { } time.Sleep(time.Duration(crypto.RandBetween(item.DelayMin, item.DelayMax)) * time.Millisecond) } - if item.Rand > 0 { - buf := make([]byte, item.Rand) - crypto.RandBytesBetween(buf, byte(item.RandMin), byte(item.RandMax)) - merged = append(merged, buf...) - } else { - merged = append(merged, item.Packet...) + evaluated, err := evaluateItem(item.Rand, item.RandMin, item.RandMax, item.Packet, item.Save, item.Var, item.Expr, ctx) + if err != nil { + return false } + merged = append(merged, evaluated...) } if len(merged) > 0 { _, err := w.Write(merged) @@ -244,3 +288,15 @@ func writeSequence(w io.Writer, sequence *TCPSequence) bool { } return true } + +func tcpStateKey(local, remote net.Addr) string { + localKey := "" + if local != nil { + localKey = local.String() + } + remoteKey := "" + if remote != nil { + remoteKey = remote.String() + } + return localKey + "|" + remoteKey +} diff --git a/transport/internet/finalmask/header/custom/tcp_runtime_test.go b/transport/internet/finalmask/header/custom/tcp_runtime_test.go new file mode 100644 index 00000000..9f10e833 --- /dev/null +++ b/transport/internet/finalmask/header/custom/tcp_runtime_test.go @@ -0,0 +1,150 @@ +package custom + +import ( + "io" + "net" + "strings" + "testing" + "time" +) + +func TestDSLTCPHandshakeReusesCapturedValue(t *testing.T) { + cfg := &TCPConfig{ + Clients: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Rand: 2, + RandMin: 0x2A, + RandMax: 0x2A, + Save: "txid", + }, + }, + }, + }, + Servers: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Var: "txid", + }, + }, + }, + }, + } + + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + client, err := cfg.WrapConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := cfg.WrapConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(time.Second)) + _ = server.SetDeadline(time.Now().Add(time.Second)) + + writeErr := make(chan error, 1) + go func() { + _, err := client.Write([]byte("payload")) + writeErr <- err + }() + + buf := make([]byte, len("payload")) + if _, err := io.ReadFull(server, buf); err != nil { + t.Fatal(err) + } + if string(buf) != "payload" { + t.Fatalf("unexpected payload: %q", buf) + } + if err := <-writeErr; err != nil { + t.Fatal(err) + } +} + +func TestDSLTCPClientRejectsMismatchedResponseSequence(t *testing.T) { + clientCfg := &TCPConfig{ + Clients: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Rand: 2, + RandMin: 0x2A, + RandMax: 0x2A, + Save: "txid", + }, + }, + }, + }, + Servers: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Var: "txid", + }, + }, + }, + }, + } + serverCfg := &TCPConfig{ + Clients: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Rand: 2, + Save: "txid", + }, + }, + }, + }, + Servers: []*TCPSequence{ + { + Sequence: []*TCPItem{ + { + Packet: []byte{0x01, 0x02}, + }, + }, + }, + }, + } + + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + client, err := clientCfg.WrapConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := serverCfg.WrapConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(time.Second)) + _ = server.SetDeadline(time.Now().Add(time.Second)) + + writeErr := make(chan error, 1) + go func() { + _, err := client.Write([]byte("payload")) + writeErr <- err + }() + + buf := make([]byte, len("payload")) + _, readErr := server.Read(buf) + + if err := <-writeErr; err == nil || !strings.Contains(err.Error(), "header auth failed") { + t.Fatalf("expected client auth failure, got %v", err) + } + if readErr == nil { + t.Fatal("expected server read to fail") + } + if ne, ok := readErr.(net.Error); !ok || !ne.Timeout() { + t.Fatalf("expected server timeout after client auth failure, got %v", readErr) + } +} diff --git a/transport/internet/finalmask/header/custom/udp.go b/transport/internet/finalmask/header/custom/udp.go index 033351e1..b3d8d122 100644 --- a/transport/internet/finalmask/header/custom/udp.go +++ b/transport/internet/finalmask/header/custom/udp.go @@ -3,8 +3,8 @@ package custom import ( "bytes" "net" + "time" - "github.com/xtls/xray-core/common/crypto" "github.com/xtls/xray-core/common/errors" ) @@ -12,41 +12,34 @@ type udpCustomClient struct { client []*UDPItem server []*UDPItem merged []byte + read int + addr net.Addr + state *stateStore + vars map[string][]byte } func (h *udpCustomClient) Serialize(b []byte) { - index := 0 - for _, item := range h.client { - if item.Rand > 0 { - crypto.RandBytesBetween(h.merged[index:index+int(item.Rand)], byte(item.RandMin), byte(item.RandMax)) - index += int(item.Rand) - } else { - index += len(item.Packet) - } + evaluated, err := evaluateUDPItems(h.client) + if err != nil || len(evaluated) != len(h.merged) { + copy(b, h.merged) + return } - copy(b, h.merged) + copy(b, evaluated) } func (h *udpCustomClient) Match(b []byte) bool { - if len(b) < len(h.merged) { - return false + var initial map[string][]byte + if h.state != nil { + initial, _ = h.state.get(udpStateKey(h.addr)) } - - data := b - match := true - - for _, item := range h.server { - length := max(int(item.Rand), len(item.Packet)) - - if len(item.Packet) > 0 && !bytes.Equal(item.Packet, data[:length]) { - match = false - break + vars, ok := matchUDPItems(h.server, b, h.read, initial) + if ok { + h.vars = vars + if h.state != nil { + h.state.set(udpStateKey(h.addr), vars) } - - data = data[length:] } - - return match + return ok } type udpCustomClientConn struct { @@ -60,18 +53,19 @@ func NewConnClientUDP(c *UDPConfig, raw net.PacketConn) (net.PacketConn, error) header: &udpCustomClient{ client: c.Client, server: c.Server, + state: newStateStore(5 * time.Second), + vars: make(map[string][]byte), }, } - - index := 0 - for _, item := range conn.header.client { - if item.Rand > 0 { - conn.header.merged = append(conn.header.merged, make([]byte, item.Rand)...) - index += int(item.Rand) - } else { - conn.header.merged = append(conn.header.merged, item.Packet...) - index += len(item.Packet) - } + clientSavedSizes := collectSavedUDPSizes(conn.header.client) + size, err := measureUDPItems(conn.header.client) + if err != nil { + return nil, err + } + conn.header.merged = make([]byte, size) + conn.header.read, err = measureUDPItemsWithFallback(conn.header.server, clientSavedSizes) + if err != nil { + return nil, err } return conn, nil @@ -86,54 +80,69 @@ func (c *udpCustomClientConn) ReadFrom(p []byte) (n int, addr net.Addr, err erro return 0, addr, errors.New("header mismatch") } - return len(p) - len(c.header.merged), addr, nil + return len(p) - c.header.read, addr, nil } func (c *udpCustomClientConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { - c.header.Serialize(p) + var localAddr net.Addr + if c.PacketConn != nil { + localAddr = c.PacketConn.LocalAddr() + } + ctx := newEvalContextWithAddrs(localAddr, addr) + if vars, ok := c.header.state.get(udpStateKey(addr)); ok { + ctx.vars = cloneVars(vars) + } else if len(c.header.vars) > 0 { + ctx.vars = cloneVars(c.header.vars) + } + evaluated, err := evaluateUDPItemsWithContext(c.header.client, ctx) + if err != nil { + return 0, err + } + if len(evaluated) != len(c.header.merged) { + return 0, errors.New("header size mismatch") + } + c.header.state.set(udpStateKey(addr), ctx.vars) + copy(p, evaluated) return len(p), nil } +func (c *udpCustomClientConn) SetReadAddr(addr net.Addr) { + c.header.addr = addr +} + type udpCustomServer struct { client []*UDPItem server []*UDPItem merged []byte + read int + addr net.Addr + state *stateStore + vars map[string][]byte } func (h *udpCustomServer) Serialize(b []byte) { - index := 0 - for _, item := range h.server { - if item.Rand > 0 { - crypto.RandBytesBetween(h.merged[index:index+int(item.Rand)], byte(item.RandMin), byte(item.RandMax)) - index += int(item.Rand) - } else { - index += len(item.Packet) - } + evaluated, err := evaluateUDPItems(h.server) + if err != nil || len(evaluated) != len(h.merged) { + copy(b, h.merged) + return } - copy(b, h.merged) + copy(b, evaluated) } func (h *udpCustomServer) Match(b []byte) bool { - if len(b) < len(h.merged) { - return false + var initial map[string][]byte + if h.state != nil { + initial, _ = h.state.get(udpStateKey(h.addr)) } - - data := b - match := true - - for _, item := range h.client { - length := max(int(item.Rand), len(item.Packet)) - - if len(item.Packet) > 0 && !bytes.Equal(item.Packet, data[:length]) { - match = false - break + vars, ok := matchUDPItems(h.client, b, h.read, initial) + if ok { + h.vars = vars + if h.state != nil { + h.state.set(udpStateKey(h.addr), vars) } - - data = data[length:] } - - return match + return ok } type udpCustomServerConn struct { @@ -147,18 +156,19 @@ func NewConnServerUDP(c *UDPConfig, raw net.PacketConn) (net.PacketConn, error) header: &udpCustomServer{ client: c.Client, server: c.Server, + state: newStateStore(5 * time.Second), + vars: make(map[string][]byte), }, } - - index := 0 - for _, item := range conn.header.server { - if item.Rand > 0 { - conn.header.merged = append(conn.header.merged, make([]byte, item.Rand)...) - index += int(item.Rand) - } else { - conn.header.merged = append(conn.header.merged, item.Packet...) - index += len(item.Packet) - } + clientSavedSizes := collectSavedUDPSizes(conn.header.client) + size, err := measureUDPItemsWithFallback(conn.header.server, clientSavedSizes) + if err != nil { + return nil, err + } + conn.header.merged = make([]byte, size) + conn.header.read, err = measureUDPItems(conn.header.client) + if err != nil { + return nil, err } return conn, nil @@ -173,11 +183,87 @@ func (c *udpCustomServerConn) ReadFrom(p []byte) (n int, addr net.Addr, err erro return 0, addr, errors.New("header mismatch") } - return len(p) - len(c.header.merged), addr, nil + return len(p) - c.header.read, addr, nil } func (c *udpCustomServerConn) WriteTo(p []byte, addr net.Addr) (n int, err error) { - c.header.Serialize(p) + var localAddr net.Addr + if c.PacketConn != nil { + localAddr = c.PacketConn.LocalAddr() + } + ctx := newEvalContextWithAddrs(localAddr, addr) + if vars, ok := c.header.state.get(udpStateKey(addr)); ok { + ctx.vars = cloneVars(vars) + } else if len(c.header.vars) > 0 { + ctx.vars = cloneVars(c.header.vars) + } + evaluated, err := evaluateUDPItemsWithContext(c.header.server, ctx) + if err != nil { + return 0, err + } + if len(evaluated) != len(c.header.merged) { + return 0, errors.New("header size mismatch") + } + c.header.state.set(udpStateKey(addr), ctx.vars) + copy(p, evaluated) return len(p), nil } + +func (c *udpCustomServerConn) SetReadAddr(addr net.Addr) { + c.header.addr = addr +} + +func matchUDPItems(items []*UDPItem, data []byte, totalSize int, initial map[string][]byte) (map[string][]byte, bool) { + if len(data) < totalSize { + return nil, false + } + + ctx := newEvalContext() + ctx.vars = cloneVars(initial) + offset := 0 + for _, item := range items { + length, err := measureItem(item.Rand, item.Packet, item.Save, item.Var, item.Expr, sizeMapFromEvalContext(ctx)) + if err != nil { + return nil, false + } + if len(data[offset:]) < length { + return nil, false + } + segment := append([]byte(nil), data[offset:offset+length]...) + switch { + case item.Rand > 0: + case len(item.Packet) > 0: + if !bytes.Equal(item.Packet, segment) { + return nil, false + } + case item.Var != "": + saved, ok := ctx.vars[item.Var] + if !ok || !bytes.Equal(saved, segment) { + return nil, false + } + case item.Expr != nil: + evaluated, err := evaluateExpr(item.Expr, ctx) + if err != nil { + return nil, false + } + expected, err := evaluated.asBytes() + if err != nil || !bytes.Equal(expected, segment) { + return nil, false + } + } + if item.Save != "" { + ctx.vars[item.Save] = segment + } + offset += length + } + + return ctx.vars, true +} + +func udpStateKey(addr net.Addr) string { + if addr == nil { + return "" + } + return addr.String() +} diff --git a/transport/internet/finalmask/header/custom/udp_runtime_test.go b/transport/internet/finalmask/header/custom/udp_runtime_test.go new file mode 100644 index 00000000..c66d4c41 --- /dev/null +++ b/transport/internet/finalmask/header/custom/udp_runtime_test.go @@ -0,0 +1,83 @@ +package custom + +import "testing" + +func TestDSLUDPClientSizeTracksEvaluatedItems(t *testing.T) { + conn, err := NewConnClientUDP(&UDPConfig{ + Client: []*UDPItem{ + { + Rand: 2, + RandMin: 0x2A, + RandMax: 0x2A, + Save: "txid", + }, + { + Var: "txid", + }, + { + Expr: &Expr{ + Op: "concat", + Args: []*ExprArg{ + {Value: &ExprArg_Bytes{Bytes: []byte{0xAB}}}, + {Value: &ExprArg_Bytes{Bytes: []byte{0xCD}}}, + }, + }, + }, + }, + }, nil) + if err != nil { + t.Fatal(err) + } + + if got := conn.(*udpCustomClientConn).Size(); got != 6 { + t.Fatalf("unexpected header size: got=%d want=6", got) + } +} + +func TestDSLUDPServerMatchCapturesSavedValues(t *testing.T) { + conn, err := NewConnServerUDP(&UDPConfig{ + Client: []*UDPItem{ + { + Rand: 2, + Save: "txid", + }, + { + Var: "txid", + }, + }, + }, nil) + if err != nil { + t.Fatal(err) + } + + server := conn.(*udpCustomServerConn) + if !server.header.Match([]byte{0x01, 0x02, 0x01, 0x02}) { + t.Fatal("expected packet to match") + } + + if got := string(server.header.vars["txid"]); got != string([]byte{0x01, 0x02}) { + t.Fatalf("unexpected saved txid: %x", server.header.vars["txid"]) + } +} + +func TestDSLUDPServerRejectsMalformedVarReference(t *testing.T) { + conn, err := NewConnServerUDP(&UDPConfig{ + Client: []*UDPItem{ + { + Rand: 2, + Save: "txid", + }, + { + Var: "txid", + }, + }, + }, nil) + if err != nil { + t.Fatal(err) + } + + server := conn.(*udpCustomServerConn) + if server.header.Match([]byte{0x01, 0x02, 0x03, 0x04}) { + t.Fatal("expected packet mismatch") + } +} diff --git a/transport/internet/finalmask/tcp_test.go b/transport/internet/finalmask/tcp_test.go index 7febb185..c6125aa9 100644 --- a/transport/internet/finalmask/tcp_test.go +++ b/transport/internet/finalmask/tcp_test.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "net" + "strings" "testing" "time" @@ -121,3 +122,128 @@ func TestConnReadWrite(t *testing.T) { }) } } + +func TestTCPcustomStaticHandshakeRoundTrip(t *testing.T) { + cfg := &custom.TCPConfig{ + Clients: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte("cli")}, + {Rand: 2, RandMin: 0x10, RandMax: 0x20}, + }, + }, + }, + Servers: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte("srv")}, + {Rand: 1, RandMin: 0x30, RandMax: 0x40}, + }, + }, + }, + } + maskManager := finalmask.NewTcpmaskManager([]finalmask.Tcpmask{cfg}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + clientRaw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + serverRaw, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + defer serverRaw.Close() + + client, err := maskManager.WrapConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := maskManager.WrapConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(time.Second)) + _ = server.SetDeadline(time.Now().Add(time.Second)) + + mustSendRecvTcp(t, client, server, []byte("custom tcp payload")) + mustSendRecvTcp(t, server, client, []byte("custom tcp response")) +} + +func TestTCPcustomClientRejectsMismatchedServerSequence(t *testing.T) { + clientCfg := &custom.TCPConfig{ + Clients: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte{0x01}}, + }, + }, + }, + Servers: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte{0x02}}, + }, + }, + }, + } + serverCfg := &custom.TCPConfig{ + Clients: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte{0x01}}, + }, + }, + }, + Servers: []*custom.TCPSequence{ + { + Sequence: []*custom.TCPItem{ + {Packet: []byte{0x03}}, + }, + }, + }, + } + + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + client, err := clientCfg.WrapConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + server, err := serverCfg.WrapConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = client.SetDeadline(time.Now().Add(time.Second)) + _ = server.SetDeadline(time.Now().Add(time.Second)) + + writeErr := make(chan error, 1) + go func() { + _, err := client.Write([]byte("boom")) + writeErr <- err + }() + + buf := make([]byte, 4) + _, readErr := server.Read(buf) + + if err := <-writeErr; err == nil || !strings.Contains(err.Error(), "header auth failed") { + t.Fatalf("expected client header auth failure, got %v", err) + } + if readErr == nil { + t.Fatal("expected server read to fail") + } + if ne, ok := readErr.(net.Error); !ok || !ne.Timeout() { + t.Fatalf("expected server timeout after client auth failure, got %v", readErr) + } +} diff --git a/transport/internet/finalmask/udp_test.go b/transport/internet/finalmask/udp_test.go index f2f18f2a..f7a40f2d 100644 --- a/transport/internet/finalmask/udp_test.go +++ b/transport/internet/finalmask/udp_test.go @@ -215,6 +215,108 @@ func TestPacketConnReadWrite(t *testing.T) { } } +func TestUDPcustomStaticHeaderWireShape(t *testing.T) { + cfg := &custom.UDPConfig{ + Client: []*custom.UDPItem{ + {Packet: []byte{0xAA, 0xBB}}, + {Rand: 2, RandMin: 0x10, RandMax: 0x20}, + }, + Server: []*custom.UDPItem{ + {Packet: []byte{0xCC}}, + {Rand: 1, RandMin: 0x30, RandMax: 0x40}, + }, + } + maskManager := finalmask.NewUdpmaskManager([]finalmask.Udpmask{cfg}) + + clientRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + serverRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer serverRaw.Close() + + client, err := maskManager.WrapPacketConnClient(clientRaw) + if err != nil { + t.Fatal(err) + } + + payload := []byte("udp-custom-wire") + if _, err := client.WriteTo(payload, serverRaw.LocalAddr()); err != nil { + t.Fatal(err) + } + + buf := make([]byte, 1024) + _ = serverRaw.SetDeadline(time.Now().Add(time.Second)) + n, _, err := serverRaw.ReadFrom(buf) + if err != nil { + t.Fatal(err) + } + + if n != len(payload)+4 { + t.Fatalf("unexpected wire size: got=%d want=%d", n, len(payload)+4) + } + if !bytes.Equal(buf[:2], []byte{0xAA, 0xBB}) { + t.Fatalf("unexpected static header prefix: %x", buf[:2]) + } + for i, b := range buf[2:4] { + if b < 0x10 || b > 0x20 { + t.Fatalf("rand byte %d out of range: %x", i, b) + } + } + if !bytes.Equal(buf[4:n], payload) { + t.Fatalf("unexpected payload: %q", buf[4:n]) + } +} + +func TestUDPcustomServerRejectsMismatchedStaticHeader(t *testing.T) { + cfg := &custom.UDPConfig{ + Client: []*custom.UDPItem{ + {Packet: []byte{0x01, 0x02}}, + }, + Server: []*custom.UDPItem{ + {Packet: []byte{0x03}}, + }, + } + maskManager := finalmask.NewUdpmaskManager([]finalmask.Udpmask{cfg}) + + clientRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer clientRaw.Close() + + serverRaw, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer serverRaw.Close() + + server, err := maskManager.WrapPacketConnServer(serverRaw) + if err != nil { + t.Fatal(err) + } + + _ = server.SetDeadline(time.Now().Add(200 * time.Millisecond)) + + if _, err := clientRaw.WriteTo([]byte{0x09, 0x09, 'b', 'a', 'd'}, server.LocalAddr()); err != nil { + t.Fatal(err) + } + + buf := make([]byte, 128) + n, _, err := server.ReadFrom(buf) + if n != 0 { + t.Fatalf("expected no payload on mismatched header, got %d bytes", n) + } + if err != nil { + t.Fatalf("expected mismatch to be dropped without surfaced error, got %v", err) + } +} + func TestSudokuBDD(t *testing.T) { t.Run("GivenSudokuTCPMask_WhenRoundTripWithAsciiPreference_ThenPayloadMatches", func(t *testing.T) { cfg := &sudoku.Config{