1373 lines
39 KiB
Diff
Executable File
1373 lines
39 KiB
Diff
Executable File
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 = `<!DOCTYPE html>
|
|
+<html lang="en">
|
|
+<head>
|
|
+<meta charset="utf-8">
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
+<title>dedikar.online</title>
|
|
+<style>
|
|
+ body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f6f7f9; color: #222; }
|
|
+ .wrap { max-width: 720px; margin: 0 auto; padding: 64px 24px; }
|
|
+ h1 { font-size: 28px; margin-bottom: 8px; }
|
|
+ p { line-height: 1.6; color: #444; }
|
|
+ .card { background: #fff; border: 1px solid #e3e5e8; border-radius: 10px; padding: 24px; margin-top: 24px; }
|
|
+ .muted { color: #888; font-size: 13px; }
|
|
+</style>
|
|
+</head>
|
|
+<body>
|
|
+<div class="wrap">
|
|
+ <h1>dedikar.online</h1>
|
|
+ <p>Personal infrastructure and utility services.</p>
|
|
+ <div class="card">
|
|
+ <p>Status: operational.</p>
|
|
+ <p class="muted">This is a private host. If you reached this page by accident, nothing to see here.</p>
|
|
+ </div>
|
|
+</div>
|
|
+</body>
|
|
+</html>
|
|
+`
|
|
+
|
|
+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))
|
|
+}
|