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 = ` + + + + +dedikar.online + + + +
+

dedikar.online

+

Personal infrastructure and utility services.

+
+

Status: operational.

+

This is a private host. If you reached this page by accident, nothing to see here.

+
+
+ + +` + +type mirrorHostKey struct{} + + +var urlAttrRe = regexp.MustCompile(`(?i)(href|src|action|poster|data-src|data-lazy-src|srcset|content)\s*=\s*("([^"]*)"|'([^']*)')`) + +func isCSSResponse(resp *http.Response) bool { + return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/css") +} + +func isHTMLResponse(resp *http.Response) bool { + return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") +} + +func rewriteURLRef(v, targetHost, ourHost string) string { + v = strings.ReplaceAll(v, "https://"+targetHost, "https://"+ourHost) + v = strings.ReplaceAll(v, "http://"+targetHost, "https://"+ourHost) + v = strings.ReplaceAll(v, "//"+targetHost, "//"+ourHost) + return v +} + +func rewriteHTMLRefs(body []byte, targetHost, ourHost string) []byte { + if targetHost == "" || ourHost == "" || strings.EqualFold(targetHost, ourHost) { + return body + } + return urlAttrRe.ReplaceAllFunc(body, func(m []byte) []byte { + sm := urlAttrRe.FindSubmatch(m) + var val []byte + if len(sm) > 3 && sm[3] != nil { + val = sm[3] + } else if len(sm) > 4 && sm[4] != nil { + val = sm[4] + } + if val == nil { + return m + } + nv := []byte(rewriteURLRef(string(val), targetHost, ourHost)) + return bytes.Replace(m, val, nv, 1) + }) +} + +var cssURLRe = regexp.MustCompile(`(?i)url\s*\(\s*(?:'([^']*)'|"([^"]*)"|([^'")\s]+))\s*\)`) + +func rewriteCSSRefs(body []byte, targetHost, ourHost string) []byte { + if targetHost == "" || ourHost == "" || strings.EqualFold(targetHost, ourHost) { + return body + } + return cssURLRe.ReplaceAllFunc(body, func(m []byte) []byte { + sm := cssURLRe.FindSubmatch(m) + var val []byte + if len(sm) > 1 && sm[1] != nil { + val = sm[1] + } else if len(sm) > 2 && sm[2] != nil { + val = sm[2] + } else if len(sm) > 3 && sm[3] != nil { + val = sm[3] + } + if val == nil { + return m + } + nv := []byte(rewriteURLRef(string(val), targetHost, ourHost)) + return bytes.Replace(m, val, nv, 1) + }) +} + +func rewriteBodyRefs(body []byte, targetHost, ourHost string) []byte { + return rewriteCSSRefs(rewriteHTMLRefs(body, targetHost, ourHost), targetHost, ourHost) +} + +type Listener struct { + server *http.Server + listener net.Listener + config *Config + paths []string + addConn internet.ConnHandler + proxy *httputil.ReverseProxy + target *url.URL +} + +func ListenStealth(ctx context.Context, address xnet.Address, port xnet.Port, streamSettings *internet.MemoryStreamConfig, handler internet.ConnHandler) (internet.Listener, error) { + errors.LogInfo(ctx, "stealth listening on ", address, ":", port) + cfg, ok := streamSettings.ProtocolSettings.(*Config) + if !ok { + return nil, errors.New("stealth: invalid stream settings") + } + + listener, err := internet.ListenSystem(ctx, &net.TCPAddr{IP: address.IP(), Port: int(port)}, streamSettings.SocketSettings) + if err != nil { + return nil, err + } + + l := &Listener{ + listener: listener, + config: cfg, + paths: cfg.GetNormalizedPaths(), + addConn: handler, + } + if target := cfg.GetNormalizedTarget(); target != nil { + // custom director: NewSingleHostReverseProxy in go>=1.26 does not + // rewrite req.Host, which makes real sites reject the request + rp := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + req.Header.Set("Accept-Encoding", "identity") + }, + ModifyResponse: func(resp *http.Response) error { + host, _ := resp.Request.Context().Value(mirrorHostKey{}).(string) + if host == "" { + return nil + } + loc := resp.Header.Get("Location") + if loc != "" { + u, err := url.Parse(loc) + if err == nil && u.Host != "" { + // keep redirects inside our domain so the mirror feels seamless + if strings.EqualFold(u.Host, target.Host) { + u.Scheme = "https" + u.Host = host + resp.Header.Set("Location", u.String()) + } + } + } + // rewrite absolute links in HTML/CSS so browsing stays on our domain + if isHTMLResponse(resp) || isCSSResponse(resp) { + if body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)); err == nil && len(body) < 8<<20 { + var nb []byte + if isCSSResponse(resp) { + nb = rewriteCSSRefs(body, target.Host, host) + } else { + nb = rewriteBodyRefs(body, target.Host, host) + } + // always re-inject the body: io.ReadAll drained the + // original, so even an unchanged body must be re-wrapped + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(nb)) + resp.ContentLength = int64(len(nb)) + resp.Header.Set("Content-Length", strconv.Itoa(len(nb))) + resp.Header.Del("Content-Encoding") + resp.Header.Del("Transfer-Encoding") + } + } + return nil + }, + } + l.proxy = rp + l.target = target + errors.LogInfo(ctx, "stealth mirroring real site at ", target.String()) + } + + // wrap the raw listener with TLS or REALITY; the inner stream is served as + // plaintext HTTP/1.1 + h2c (same pattern as XHTTP) + if config := tls.ConfigFromStreamSettings(streamSettings); config != nil { + if tlsConfig := config.GetTLSConfig(); tlsConfig != nil { + tlsConfig.NextProtos = []string{"h2"} + if len(tlsConfig.Certificates) > 0 { + certs := tlsConfig.Certificates + tlsConfig.GetCertificate = func(*gotls.ClientHelloInfo) (*gotls.Certificate, error) { + return &certs[0], nil + } + } + listener = gotls.NewListener(listener, tlsConfig) + errors.LogInfo(ctx, "stealth TLS listener enabled") + } + } + if config := reality.ConfigFromStreamSettings(streamSettings); config != nil { + listener = goreality.NewListener(listener, config.GetREALITYConfig()) + errors.LogInfo(ctx, "stealth REALITY listener enabled") + } + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Handler: l, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 0, + Protocols: protocols, + } + _ = http2.ConfigureServer(server, &http2.Server{}) + l.server = server + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errors.LogWarningInner(context.Background(), err, "stealth: http server stopped") + } + }() + return l, nil +} + +func (l *Listener) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !containsString(l.paths, r.URL.Path) { + // probes and normal browsing see a real website (reverse-proxied), + // falling back to a built-in landing page when no target is set + if l.proxy != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + l.proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), mirrorHostKey{}, r.Host))) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, landingPage) + return + } + + ftype, payload, err := readFrame(r.Body) + if err != nil || ftype != frameTypeAuth || string(payload) != l.config.Key { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusOK) + var flush func() + if flusher, ok := w.(http.Flusher); ok { + flush = flusher.Flush + flusher.Flush() + } + + var local, remote net.Addr + if v, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { + local = v + } + if raddr, err := net.ResolveTCPAddr("tcp", r.RemoteAddr); err == nil { + remote = raddr + } + if local == nil { + local = &net.TCPAddr{IP: net.IPv4zero} + } + if remote == nil { + remote = local + } + conn := newH2Conn(r.Body, w, nil, flush, l.config, local, remote) + l.addConn(stat.Connection(conn)) + + select { + case <-conn.Done(): + case <-r.Context().Done(): + } + _ = conn.Close() +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func (l *Listener) Addr() net.Addr { + return l.listener.Addr() +} + +func (l *Listener) Close() error { + _ = l.server.Close() + return l.listener.Close() +} + +func init() { + common.Must(internet.RegisterTransportListener(protocolName, ListenStealth)) +}