diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 24b5fba..2faae9c 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -36,6 +36,8 @@ func (p TransportProtocol) Build() (string, error) { return "", errors.PrintRemovedFeatureError("QUIC transport (without web service, etc.)", "XHTTP stream-one H3") case "hysteria": return "hysteria", nil + case "stealth": + return "stealth", nil default: return "", errors.New("Config: unknown transport protocol: ", p) } @@ -59,6 +61,7 @@ type StreamConfig struct { WSSettings *WebSocketConfig `json:"wsSettings"` HTTPUPGRADESettings *HttpUpgradeConfig `json:"httpupgradeSettings"` HysteriaSettings *HysteriaConfig `json:"hysteriaSettings"` + StealthSettings *StealthConfig `json:"stealthSettings"` SocketSettings *SocketConfig `json:"sockopt"` } @@ -97,8 +100,8 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { config.SecuritySettings = append(config.SecuritySettings, tm) config.SecurityType = tm.Type case "reality": - if config.ProtocolName != "tcp" && config.ProtocolName != "splithttp" && config.ProtocolName != "grpc" { - return nil, errors.New("REALITY only supports RAW, XHTTP and gRPC for now.") + if config.ProtocolName != "tcp" && config.ProtocolName != "splithttp" && config.ProtocolName != "grpc" && config.ProtocolName != "stealth" { + return nil, errors.New("REALITY only supports RAW, XHTTP, gRPC and stealth for now.") } if c.REALITYSettings == nil { return nil, errors.New(`REALITY: Empty "realitySettings".`) @@ -142,6 +145,16 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { Settings: serial.ToTypedMessage(hs), }) } + if c.StealthSettings != nil { + hs, err := c.StealthSettings.Build() + if err != nil { + return nil, errors.New("Failed to build stealth config.").Base(err) + } + config.TransportSettings = append(config.TransportSettings, &internet.TransportConfig{ + ProtocolName: "stealth", + Settings: serial.ToTypedMessage(hs), + }) + } if c.KCPSettings != nil { ts, err := c.KCPSettings.Build() if err != nil { diff --git a/infra/conf/transport_method.go b/infra/conf/transport_method.go index 19cb496..d6db814 100644 --- a/infra/conf/transport_method.go +++ b/infra/conf/transport_method.go @@ -22,6 +22,7 @@ import ( "github.com/xtls/xray-core/transport/internet/hysteria" "github.com/xtls/xray-core/transport/internet/kcp" "github.com/xtls/xray-core/transport/internet/splithttp" + "github.com/xtls/xray-core/transport/internet/stealth" "github.com/xtls/xray-core/transport/internet/tcp" "github.com/xtls/xray-core/transport/internet/websocket" "google.golang.org/protobuf/proto" @@ -812,3 +813,44 @@ func readFileOrString(f string, s []string) ([]byte, error) { } return nil, errors.New("both file and bytes are empty.") } + + +// StealthConfig configures the stealth transport. +type StealthConfig struct { + Key string `json:"key"` + PadMax int64 `json:"padMax"` + PadProb int64 `json:"padProb"` + PaceMs int64 `json:"paceMs"` + PaceJitterMs int64 `json:"paceJitterMs"` + Path string `json:"path"` + Paths []string `json:"paths"` + Ports []uint32 `json:"ports"` + Bps int64 `json:"bps"` + Target string `json:"target"` + Decoy bool `json:"decoy"` + DecoyEveryS int64 `json:"decoyEverySecs"` + DecoyTarget string `json:"decoyTarget"` + PadProfile string `json:"padProfile"` + Retries int64 `json:"retries"` +} + +// Build implements Buildable. +func (c *StealthConfig) Build() (proto.Message, error) { + return &stealth.Config{ + Key: c.Key, + PadMax: c.PadMax, + PadProb: c.PadProb, + PaceMs: c.PaceMs, + PaceJitterMs: c.PaceJitterMs, + Path: c.Path, + Paths: c.Paths, + Ports: c.Ports, + Bps: c.Bps, + Target: c.Target, + Decoy: c.Decoy, + DecoyTarget: c.DecoyTarget, + DecoyEverySecs: c.DecoyEveryS, + PadProfile: c.PadProfile, + Retries: c.Retries, + }, nil +} diff --git a/transport/internet/stealth/config.go b/transport/internet/stealth/config.go new file mode 100644 index 0000000..396a04a --- /dev/null +++ b/transport/internet/stealth/config.go @@ -0,0 +1,175 @@ +package stealth + +import ( + "net/url" + "strings" + "time" + + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/transport/internet" +) + +const protocolName = "stealth" + +const authOK = "ok" + +const chromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" + +func init() { + common.Must(internet.RegisterProtocolConfigCreator(protocolName, func() interface{} { + return new(Config) + })) +} + + +func (c *Config) GetNormalizedPaths() []string { + if len(c.Paths) > 0 { + out := make([]string, 0, len(c.Paths)) + for _, p := range c.Paths { + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + out = append(out, p) + } + return out + } + return []string{c.GetNormalizedPath()} +} + +func (c *Config) NormalizedPadProb() int { + if c.PadProb <= 0 { + return 50 + } + if c.PadProb > 100 { + return 100 + } + return int(c.PadProb) +} + + +func (c *Config) GetNormalizedPorts() []uint32 { + if len(c.Ports) == 0 { + return nil + } + return c.Ports +} + +func (c *Config) NormalizedBps() int { + if c.Bps <= 0 { + return 0 + } + if c.Bps > 1<<30 { + return 1 << 30 + } + return int(c.Bps) +} + + + +func (c *Config) DecoyEnabled() bool { + return c.Decoy +} + +func (c *Config) DecoyEvery() time.Duration { + n := c.DecoyEverySecs + if n <= 0 { + n = 60 + } + if n > 86400 { + n = 86400 + } + return time.Duration(n) * time.Second +} + +func (c *Config) NormalizedDecoyTarget() string { + t := strings.TrimSpace(c.DecoyTarget) + if t == "" { + return "https://www.cloudflare.com/cdn-cgi/trace" + } + if !strings.Contains(t, "://") { + t = "https://" + t + } + return t +} + +func (c *Config) GetNormalizedTarget() *url.URL { + t := strings.TrimSpace(c.Target) + if t == "" { + return nil + } + if !strings.Contains(t, "://") { + t = "https://" + t + } + u, err := url.Parse(t) + if err != nil || u.Host == "" { + return nil + } + return u +} + +func (c *Config) GetNormalizedPath() string { + path := c.Path + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + + + +func (c *Config) GetNormalizedRetries() int { + n := c.Retries + if n == 0 { + return 3 + } + if n < 0 { + return 0 + } + if n > 10 { + return 10 + } + return int(n) +} + +func (c *Config) NormalizedPadProfile() string { + switch c.PadProfile { + case "uniform": + return "uniform" + default: + return "browser" + } +} + +func (c *Config) NormalizedPadMax() int { + if c.PadMax <= 0 { + return 0 + } + if c.PadMax > 4096 { + return 4096 + } + return int(c.PadMax) +} + + +func (c *Config) NormalizedPaceJitterMs() time.Duration { + if c.PaceJitterMs <= 0 { + return 0 + } + if c.PaceJitterMs > 5000 { + return 5000 + } + return time.Duration(c.PaceJitterMs) * time.Millisecond +} + +func (c *Config) NormalizedPaceMs() time.Duration { + if c.PaceMs <= 0 { + return 0 + } + if c.PaceMs > 5000 { + return 5000 + } + return time.Duration(c.PaceMs) * time.Millisecond +} diff --git a/transport/internet/stealth/config.pb.go b/transport/internet/stealth/config.pb.go new file mode 100644 index 0000000..289aa60 --- /dev/null +++ b/transport/internet/stealth/config.pb.go @@ -0,0 +1,268 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc v3.21.12 +// source: transport/internet/stealth/config.proto + +package stealth + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Shared auth secret (both sides must match). + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // Max random padding bytes appended after each data frame (0 = disabled). + PadMax int64 `protobuf:"varint,2,opt,name=pad_max,json=padMax,proto3" json:"pad_max,omitempty"` + // Minimum interval between frame writes in ms (0 = disabled). + PaceMs int64 `protobuf:"varint,3,opt,name=pace_ms,json=paceMs,proto3" json:"pace_ms,omitempty"` + // HTTP/2 request path (default "/"). + Path string `protobuf:"bytes,4,opt,name=path,proto3" json:"path,omitempty"` + // Max random extra delay added to pacing (ms, 0 = disabled). + PaceJitterMs int64 `protobuf:"varint,5,opt,name=pace_jitter_ms,json=paceJitterMs,proto3" json:"pace_jitter_ms,omitempty"` + // Multiple tunnel paths for identity rotation; falls back to [path]. + Paths []string `protobuf:"bytes,6,rep,name=paths,proto3" json:"paths,omitempty"` + // Percent chance (0-100) to append a PAD frame after a data frame (default 50). + PadProb int64 `protobuf:"varint,7,opt,name=pad_prob,json=padProb,proto3" json:"pad_prob,omitempty"` + // Candidate ports for identity rotation (client picks one per connection). + // Overrides the outbound port; the server must listen on these ports. + Ports []uint32 `protobuf:"varint,8,rep,packed,name=ports,proto3" json:"ports,omitempty"` + // Optional upload rate limit in bytes/sec (0 = unlimited). + Bps int64 `protobuf:"varint,9,opt,name=bps,proto3" json:"bps,omitempty"` + // Reverse-proxy target for non-tunnel requests (a real website to mirror). + // Empty = serve the built-in landing page only. + Target string `protobuf:"bytes,10,opt,name=target,proto3" json:"target,omitempty"` + // Client-side decoy: periodically make a real request to a benign CDN + // (direct, not through the tunnel) to mask aggregated activity. Default off. + Decoy bool `protobuf:"varint,11,opt,name=decoy,proto3" json:"decoy,omitempty"` + // Decoy interval in seconds (default 60). + DecoyEverySecs int64 `protobuf:"varint,12,opt,name=decoy_every_secs,json=decoyEverySecs,proto3" json:"decoy_every_secs,omitempty"` + // Decoy target URL (default Cloudflare trace endpoint). + DecoyTarget string `protobuf:"bytes,13,opt,name=decoy_target,json=decoyTarget,proto3" json:"decoy_target,omitempty"` + // Padding profile: "browser" (default, biased toward small pads) or "uniform". + PadProfile string `protobuf:"bytes,14,opt,name=pad_profile,json=padProfile,proto3" json:"pad_profile,omitempty"` + // Dial retries with a fresh identity (new path/port) on failure (default 3). + Retries int64 `protobuf:"varint,15,opt,name=retries,proto3" json:"retries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_stealth_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_stealth_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 Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_stealth_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *Config) GetPadMax() int64 { + if x != nil { + return x.PadMax + } + return 0 +} + +func (x *Config) GetPaceMs() int64 { + if x != nil { + return x.PaceMs + } + return 0 +} + +func (x *Config) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Config) GetPaceJitterMs() int64 { + if x != nil { + return x.PaceJitterMs + } + return 0 +} + +func (x *Config) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +func (x *Config) GetPadProb() int64 { + if x != nil { + return x.PadProb + } + return 0 +} + +func (x *Config) GetPorts() []uint32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *Config) GetBps() int64 { + if x != nil { + return x.Bps + } + return 0 +} + +func (x *Config) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *Config) GetDecoy() bool { + if x != nil { + return x.Decoy + } + return false +} + +func (x *Config) GetDecoyEverySecs() int64 { + if x != nil { + return x.DecoyEverySecs + } + return 0 +} + +func (x *Config) GetDecoyTarget() string { + if x != nil { + return x.DecoyTarget + } + return "" +} + +func (x *Config) GetPadProfile() string { + if x != nil { + return x.PadProfile + } + return "" +} + +func (x *Config) GetRetries() int64 { + if x != nil { + return x.Retries + } + return 0 +} + +var File_transport_internet_stealth_config_proto protoreflect.FileDescriptor + +const file_transport_internet_stealth_config_proto_rawDesc = "" + + "\n" + + "'transport/internet/stealth/config.proto\x12\x1fxray.transport.internet.stealth\"\x95\x03\n" + + "\x06Config\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x17\n" + + "\apad_max\x18\x02 \x01(\x03R\x06padMax\x12\x17\n" + + "\apace_ms\x18\x03 \x01(\x03R\x06paceMs\x12\x12\n" + + "\x04path\x18\x04 \x01(\tR\x04path\x12$\n" + + "\x0epace_jitter_ms\x18\x05 \x01(\x03R\fpaceJitterMs\x12\x14\n" + + "\x05paths\x18\x06 \x03(\tR\x05paths\x12\x19\n" + + "\bpad_prob\x18\a \x01(\x03R\apadProb\x12\x14\n" + + "\x05ports\x18\b \x03(\rR\x05ports\x12\x10\n" + + "\x03bps\x18\t \x01(\x03R\x03bps\x12\x16\n" + + "\x06target\x18\n" + + " \x01(\tR\x06target\x12\x14\n" + + "\x05decoy\x18\v \x01(\bR\x05decoy\x12(\n" + + "\x10decoy_every_secs\x18\f \x01(\x03R\x0edecoyEverySecs\x12!\n" + + "\fdecoy_target\x18\r \x01(\tR\vdecoyTarget\x12\x1f\n" + + "\vpad_profile\x18\x0e \x01(\tR\n" + + "padProfile\x12\x18\n" + + "\aretries\x18\x0f \x01(\x03R\aretriesB6Z4github.com/xtls/xray-core/transport/internet/stealthb\x06proto3" + +var ( + file_transport_internet_stealth_config_proto_rawDescOnce sync.Once + file_transport_internet_stealth_config_proto_rawDescData []byte +) + +func file_transport_internet_stealth_config_proto_rawDescGZIP() []byte { + file_transport_internet_stealth_config_proto_rawDescOnce.Do(func() { + file_transport_internet_stealth_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_stealth_config_proto_rawDesc), len(file_transport_internet_stealth_config_proto_rawDesc))) + }) + return file_transport_internet_stealth_config_proto_rawDescData +} + +var file_transport_internet_stealth_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_transport_internet_stealth_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.transport.internet.stealth.Config +} +var file_transport_internet_stealth_config_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_transport_internet_stealth_config_proto_init() } +func file_transport_internet_stealth_config_proto_init() { + if File_transport_internet_stealth_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_stealth_config_proto_rawDesc), len(file_transport_internet_stealth_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_stealth_config_proto_goTypes, + DependencyIndexes: file_transport_internet_stealth_config_proto_depIdxs, + MessageInfos: file_transport_internet_stealth_config_proto_msgTypes, + }.Build() + File_transport_internet_stealth_config_proto = out.File + file_transport_internet_stealth_config_proto_goTypes = nil + file_transport_internet_stealth_config_proto_depIdxs = nil +} diff --git a/transport/internet/stealth/config.proto b/transport/internet/stealth/config.proto new file mode 100644 index 0000000..e367189 --- /dev/null +++ b/transport/internet/stealth/config.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package xray.transport.internet.stealth; +option go_package = "github.com/xtls/xray-core/transport/internet/stealth"; + +message Config { + // Shared auth secret (both sides must match). + string key = 1; + // Max random padding bytes appended after each data frame (0 = disabled). + int64 pad_max = 2; + // Minimum interval between frame writes in ms (0 = disabled). + int64 pace_ms = 3; + // HTTP/2 request path (default "/"). + string path = 4; + // Max random extra delay added to pacing (ms, 0 = disabled). + int64 pace_jitter_ms = 5; + // Multiple tunnel paths for identity rotation; falls back to [path]. + repeated string paths = 6; + // Percent chance (0-100) to append a PAD frame after a data frame (default 50). + int64 pad_prob = 7; + // Candidate ports for identity rotation (client picks one per connection). + // Overrides the outbound port; the server must listen on these ports. + repeated uint32 ports = 8; + // Optional upload rate limit in bytes/sec (0 = unlimited). + int64 bps = 9; + // Reverse-proxy target for non-tunnel requests (a real website to mirror). + // Empty = serve the built-in landing page only. + string target = 10; + // Client-side decoy: periodically make a real request to a benign CDN + // (direct, not through the tunnel) to mask aggregated activity. Default off. + bool decoy = 11; + // Decoy interval in seconds (default 60). + int64 decoy_every_secs = 12; + // Decoy target URL (default Cloudflare trace endpoint). + string decoy_target = 13; + // Padding profile: "browser" (default, biased toward small pads) or "uniform". + string pad_profile = 14; + // Dial retries with a fresh identity (new path/port) on failure (default 3). + int64 retries = 15; +} diff --git a/transport/internet/stealth/conn.go b/transport/internet/stealth/conn.go new file mode 100644 index 0000000..88fb99d --- /dev/null +++ b/transport/internet/stealth/conn.go @@ -0,0 +1,160 @@ +package stealth + +import ( + "context" + "crypto/rand" + "io" + mathrand "math/rand" + "net" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type h2Conn struct { + reader io.Reader + writer io.Writer + cancel context.CancelFunc + flush func() + done chan struct{} + padMax int + padProb int + padUni bool + paceMs time.Duration + paceJit time.Duration + limiter *rate.Limiter + mu sync.Mutex + lastWrite time.Time + pending []byte + localAddr net.Addr + remote net.Addr +} + +func newH2Conn(reader io.Reader, writer io.Writer, cancel context.CancelFunc, flush func(), cfg *Config, local, remote net.Addr) *h2Conn { + return &h2Conn{ + reader: reader, + writer: writer, + cancel: cancel, + flush: flush, + done: make(chan struct{}), + padMax: cfg.NormalizedPadMax(), + padProb: cfg.NormalizedPadProb(), + padUni: cfg.NormalizedPadProfile() == "uniform", + paceMs: cfg.NormalizedPaceMs(), + paceJit: cfg.NormalizedPaceJitterMs(), + limiter: newLimiter(cfg), + localAddr: local, + remote: remote, + } +} + +func newLimiter(cfg *Config) *rate.Limiter { + if bps := cfg.NormalizedBps(); bps > 0 { + return rate.NewLimiter(rate.Limit(bps), bps) + } + return nil +} + +// padSize returns a browser-like (mostly small, occasional large) or uniform size. +func (c *h2Conn) padSize() int { + if c.padMax <= 0 { + return 0 + } + if c.padUni { + return mathrand.Intn(c.padMax + 1) + } + if mathrand.Intn(100) < 80 { + return mathrand.Intn(c.padMax/8 + 1) + } + return mathrand.Intn(c.padMax + 1) +} + +func (c *h2Conn) Read(p []byte) (int, error) { + for { + if len(c.pending) > 0 { + n := copy(p, c.pending) + c.pending = c.pending[n:] + return n, nil + } + ftype, payload, err := readFrame(c.reader) + if err != nil { + return 0, err + } + switch ftype { + case frameTypeData: + n := copy(p, payload) + if n < len(payload) { + c.pending = append(c.pending[:0], payload[n:]...) + } + return n, nil + case frameTypeClose: + return 0, io.EOF + default: + // padding and unknown frames are skipped + } + } +} + +func (c *h2Conn) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + return 0, net.ErrClosed + default: + } + if c.limiter != nil { + if err := c.limiter.WaitN(context.Background(), len(p)); err != nil { + return 0, err + } + } + if c.paceMs > 0 { + d := c.lastWrite.Add(c.paceMs).Sub(time.Now()) + if c.paceJit > 0 { + d += time.Duration(mathrand.Int63n(int64(c.paceJit))) + } + if d > 0 { + time.Sleep(d) + } + } + if err := writeFrame(c.writer, frameTypeData, p); err != nil { + return 0, err + } + if c.padMax > 0 && mathrand.Intn(100) < c.padProb { + pad := make([]byte, c.padSize()) + _, _ = rand.Read(pad) + if err := writeFrame(c.writer, frameTypePad, pad); err != nil { + return 0, err + } + } + if c.flush != nil { + c.flush() + } + c.lastWrite = time.Now() + return len(p), nil +} + +func (c *h2Conn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + select { + case <-c.done: + return nil + default: + close(c.done) + } + if c.cancel != nil { + c.cancel() + } + return nil +} + +// Done returns a channel closed when the connection is closed. +func (c *h2Conn) Done() <-chan struct{} { return c.done } + +func (c *h2Conn) LocalAddr() net.Addr { return c.localAddr } +func (c *h2Conn) RemoteAddr() net.Addr { return c.remote } +func (c *h2Conn) SetDeadline(t time.Time) error { return nil } +func (c *h2Conn) SetReadDeadline(t time.Time) error { return nil } +func (c *h2Conn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/transport/internet/stealth/dialer.go b/transport/internet/stealth/dialer.go new file mode 100644 index 0000000..50a6e38 --- /dev/null +++ b/transport/internet/stealth/dialer.go @@ -0,0 +1,205 @@ +package stealth + +import ( + "context" + gotls "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + mathrand "math/rand" + "sync" + "time" + + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/errors" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/reality" + "github.com/xtls/xray-core/transport/internet/stat" + "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" +) + +var ( + clientCache = make(map[string]*http.Client) + clientCacheMu sync.Mutex + clientCacheAt = make(map[string]time.Time) + + decoyOnce sync.Once +) + +func dialTLS(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) (net.Conn, error) { + conn, err := internet.DialSystem(ctx, dest, streamSettings.SocketSettings) + if err != nil { + return nil, err + } + if config := reality.ConfigFromStreamSettings(streamSettings); config != nil { + return reality.UClient(conn, config, ctx, dest) + } + if config := tls.ConfigFromStreamSettings(streamSettings); config != nil { + tlsConfig := config.GetTLSConfig(tls.WithDestination(dest)) + if fingerprint := tls.GetFingerprint(config.Fingerprint); fingerprint != nil { + uconn := tls.UClient(conn, tlsConfig, fingerprint) + if err := uconn.(*tls.UConn).HandshakeContext(ctx); err != nil { + conn.Close() + return nil, errors.New("stealth: TLS handshake failed").Base(err) + } + conn = uconn + } else { + tconn := tls.Client(conn, tlsConfig) + if err := tconn.(*tls.Conn).HandshakeContext(ctx); err != nil { + conn.Close() + return nil, errors.New("stealth: TLS handshake failed").Base(err) + } + conn = tconn + } + } + return conn, nil +} + +func getHTTPClient(dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) *http.Client { + cfg := streamSettings.ProtocolSettings.(*Config) + fingerprint := "" + if tc := tls.ConfigFromStreamSettings(streamSettings); tc != nil { + fingerprint = tc.Fingerprint + } + key := dest.NetAddr() + "|" + cfg.Key + "|" + cfg.GetNormalizedPath() + "|" + fingerprint + + clientCacheMu.Lock() + defer clientCacheMu.Unlock() + if c, ok := clientCache[key]; ok { + return c + } + transport := &http2.Transport{ + DialTLSContext: func(ctxInner context.Context, network string, addr string, tlsCfg *gotls.Config) (net.Conn, error) { + return dialTLS(ctxInner, dest, streamSettings) + }, + IdleConnTimeout: xnet.ConnIdleTimeout, + } + client := &http.Client{Transport: transport} + clientCache[key] = client + clientCacheAt[key] = time.Now() + return client +} + +func openStream(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig, client *http.Client) (stat.Connection, error) { + cfg := streamSettings.ProtocolSettings.(*Config) + paths := cfg.GetNormalizedPaths() + path := paths[mathrand.Intn(len(paths))] + + streamCtx, cancel := context.WithCancel(ctx) + pr, pw := io.Pipe() + + var localAddr, remoteAddr net.Addr + streamCtx = httptrace.WithClientTrace(streamCtx, &httptrace.ClientTrace{ + GotConn: func(ci httptrace.GotConnInfo) { + if ci.Conn != nil { + localAddr = ci.Conn.LocalAddr() + remoteAddr = ci.Conn.RemoteAddr() + } + }, + }) + + target := "https://" + dest.NetAddr() + path + req, err := http.NewRequestWithContext(streamCtx, http.MethodPost, target, pr) + if err != nil { + cancel() + pw.Close() + return nil, err + } + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("User-Agent", chromeUA) + req.ContentLength = -1 + + // the auth frame must reach the server before it replies 200 + go func() { + _ = writeFrame(pw, frameTypeAuth, []byte(cfg.Key)) + }() + + resp, err := client.Do(req) + if err != nil { + cancel() + pw.Close() + return nil, errors.New("stealth: request failed").Base(err) + } + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + pw.Close() + cancel() + return nil, fmt.Errorf("stealth: auth rejected (status %d)", resp.StatusCode) + } + + if localAddr == nil { + localAddr = &net.TCPAddr{IP: net.IPv4zero} + } + if remoteAddr == nil { + remoteAddr = localAddr + } + cancelStream := func() { + pw.Close() + cancel() + } + c := newH2Conn(resp.Body, pw, cancelStream, nil, cfg, localAddr, remoteAddr) + return stat.Connection(c), nil +} + +func startDecoy(cfg *Config) { + decoyOnce.Do(func() { + if !cfg.DecoyEnabled() { + return + } + interval := cfg.DecoyEvery() + target := cfg.NormalizedDecoyTarget() + client := &http.Client{Timeout: 15 * time.Second} + go func() { + for { + time.Sleep(interval) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err == nil { + req.Header.Set("User-Agent", chromeUA) + resp, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + } + cancel() + } + }() + }) +} + +func Dial(ctx context.Context, dest xnet.Destination, streamSettings *internet.MemoryStreamConfig) (stat.Connection, error) { + cfg, _ := streamSettings.ProtocolSettings.(*Config) + retries := 3 + if cfg != nil { + retries = cfg.GetNormalizedRetries() + startDecoy(cfg) + } + var lastErr error + for attempt := 0; attempt <= retries; attempt++ { + effDest := dest + if cfg != nil { + if ports := cfg.GetNormalizedPorts(); len(ports) > 0 { + effDest.Port = xnet.Port(ports[mathrand.Intn(len(ports))]) + } + } + errors.LogInfo(ctx, "stealth dialing to ", effDest, " (attempt ", attempt+1, ")") + client := getHTTPClient(effDest, streamSettings) + conn, err := openStream(ctx, effDest, streamSettings, client) + if err == nil { + return conn, nil + } + lastErr = err + errors.LogInfo(ctx, "stealth attempt failed, retrying with a new identity: ", err) + } + return nil, lastErr +} + +func init() { + common.Must(internet.RegisterTransportDialer(protocolName, Dial)) +} diff --git a/transport/internet/stealth/framing.go b/transport/internet/stealth/framing.go new file mode 100644 index 0000000..dbf572b --- /dev/null +++ b/transport/internet/stealth/framing.go @@ -0,0 +1,59 @@ +package stealth + +import ( + "encoding/binary" + "errors" + "io" +) + +const ( + frameTypeData byte = 0x01 + frameTypePad byte = 0x02 + frameTypeAuth byte = 0xFE + frameTypeClose byte = 0xFF +) + +const maxFrameSize = 64 * 1024 + +func writeFrame(w io.Writer, ftype byte, payload []byte) error { + n := 1 + len(payload) + if n > maxFrameSize { + return errors.New("stealth: frame too large") + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(n)) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + if _, err := w.Write([]byte{ftype}); err != nil { + return err + } + if len(payload) > 0 { + if _, err := w.Write(payload); err != nil { + return err + } + } + return nil +} + +func readFrame(r io.Reader) (byte, []byte, error) { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return 0, nil, err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n == 0 || n > maxFrameSize { + return 0, nil, errors.New("stealth: bad frame length") + } + var ftype [1]byte + if _, err := io.ReadFull(r, ftype[:]); err != nil { + return 0, nil, err + } + payload := make([]byte, n-1) + if len(payload) > 0 { + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + } + return ftype[0], payload, nil +} diff --git a/transport/internet/stealth/hub.go b/transport/internet/stealth/hub.go new file mode 100644 index 0000000..08c38ac --- /dev/null +++ b/transport/internet/stealth/hub.go @@ -0,0 +1,317 @@ +package stealth + +import ( + "bytes" + "context" + gotls "crypto/tls" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + goreality "github.com/xtls/reality" + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/errors" + xnet "github.com/xtls/xray-core/common/net" + "github.com/xtls/xray-core/transport/internet" + "github.com/xtls/xray-core/transport/internet/reality" + "github.com/xtls/xray-core/transport/internet/stat" + "github.com/xtls/xray-core/transport/internet/tls" + "golang.org/x/net/http2" +) + +const landingPage = ` + +
+ + +Personal infrastructure and utility services.
+Status: operational.
+This is a private host. If you reached this page by accident, nothing to see here.
+