diff --git a/constant/proxy.go b/constant/proxy.go index 6d34811780..bd5a3e611a 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -40,12 +40,13 @@ const ( TypeACME = "acme" TypeCloudflareOriginCA = "cloudflare-origin-ca" - TypeHInvalidConfig = "hinvalid" //H - TypeXray = "xray" //H - TypeCustom = "custom" //H - TypeAwg = "awg" //H - TypeBalancer = "balancer" //H - TypeDNSTT = "dnstt" //H + TypeHInvalidConfig = "hinvalid" //H + TypeXray = "xray" //H + TypeCustom = "custom" //H + TypeAwg = "awg" //H + TypeBalancer = "balancer" //H + TypeDNSTT = "dnstt" //H + TypeGooseRelay = "gooserelay" //H ) const ( @@ -131,6 +132,8 @@ func ProxyDisplayName(proxyType string) string { return "Balancer" case TypeDNSTT: return "DNSTT" + case TypeGooseRelay: + return "GooseRelay" default: return "Unknown" } diff --git a/go.mod b/go.mod index 961abcfdc1..a7a20bc922 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 github.com/jsimonetti/rtnetlink v1.4.0 github.com/keybase/go-keychain v0.0.1 + github.com/kianmhz/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 @@ -228,6 +229,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/things-go/go-socks5 v0.1.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect @@ -269,3 +271,5 @@ replace github.com/Psiphon-Labs/quic-go => ./replace/psiphon-quic-go replace github.com/Psiphon-Labs/psiphon-tls => ./replace/psiphon-tls replace github.com/net2share/vaydns => github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 + +replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c diff --git a/go.sum b/go.sum index 6e1b61c276..4b68a107f0 100644 --- a/go.sum +++ b/go.sum @@ -216,6 +216,8 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c h1:9Zc0hJ14G+SNJvGxtaSCSsvGB/RdLltrgjgR//9zgTQ= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 h1:KXnaABX8hHmkcL0jbL769hEIGI5+z/DajCrlO+Bkzcc= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9/go.mod h1:+8kEfQsZJn7/4aIppVekrSuqhrKjGBIgnacTJkdAlS8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -532,6 +534,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY= +github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/include/registry.go b/include/registry.go index dc00bdc8ff..637d3589ae 100644 --- a/include/registry.go +++ b/include/registry.go @@ -25,6 +25,7 @@ import ( "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/group/balancer" "github.com/sagernet/sing-box/protocol/hiddify/dnstt" + "github.com/sagernet/sing-box/protocol/hiddify/gooserelay" "github.com/sagernet/sing-box/protocol/hiddify/hinvalid" "github.com/sagernet/sing-box/protocol/hiddify/xray" @@ -109,6 +110,7 @@ func OutboundRegistry() *outbound.Registry { hinvalid.RegisterOutbound(registry) xray.RegisterOutbound(registry) dnstt.RegisterOutbound(registry) + gooserelay.RegisterOutbound(registry) balancer.RegisterLoadBalance(registry) registerQUICOutbounds(registry) diff --git a/option/gooserelay.go b/option/gooserelay.go new file mode 100644 index 0000000000..16cf007e70 --- /dev/null +++ b/option/gooserelay.go @@ -0,0 +1,19 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type GooseRelayOptions struct { + DialerOptions + + ScriptKeys []string `json:"script_keys,omitempty"` + TunnelKey string `json:"tunnel_key,omitempty"` + GoogleHost string `json:"google_host,omitempty"` + SNI []string `json:"sni,omitempty"` + DebugTiming bool `json:"debug_timing,omitempty"` + + UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` + + HandshakeTimeout *badoption.Duration `json:"handshake_timeout,omitempty"` +} diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go new file mode 100644 index 0000000000..91922d3ab5 --- /dev/null +++ b/protocol/hiddify/gooserelay/outbound.go @@ -0,0 +1,275 @@ +// Package gooserelay wraps the GooseRelayVPN carrier as a sing-box outbound. +// +// Traffic is multiplexed over a domain-fronted HTTPS connection to a Google +// Apps Script endpoint, which forwards encrypted frames to the user's VPS. +// The carrier handles per-endpoint health and round-robin internally; this +// outbound only manages the lifecycle and wraps each session as a net.Conn. +package gooserelay + +import ( + "context" + "encoding/hex" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/kianmhz/GooseRelayVPN/goose" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" +) + +const ( + defaultGoogleHost = "216.239.38.120:443" + defaultDiagnoseTimeout = 10 * time.Second +) + +var defaultSNIHosts = []string{"www.google.com"} + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.GooseRelayOptions](registry, C.TypeGooseRelay, New) +} + +var _ adapter.Outbound = (*Outbound)(nil) + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger logger.ContextLogger + options option.GooseRelayOptions + client *goose.Client + uotClient *uot.Client + + mu sync.Mutex + runCancel context.CancelFunc + started int +} + +func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.GooseRelayOptions) (adapter.Outbound, error) { + if len(options.ScriptKeys) == 0 { + return nil, E.New("script_keys is required") + } + if options.TunnelKey == "" { + return nil, E.New("tunnel_key is required") + } + if len(options.TunnelKey) != 64 { + return nil, E.New("tunnel_key must be 64 hex characters (AES-256)") + } + if _, err := hex.DecodeString(options.TunnelKey); err != nil { + return nil, E.Cause(err, "tunnel_key not valid hex") + } + + googleHost := options.GoogleHost + if googleHost == "" { + googleHost = defaultGoogleHost + } + sniHosts := options.SNI + if len(sniHosts) == 0 { + sniHosts = defaultSNIHosts + } + + scriptURLs := make([]string, 0, len(options.ScriptKeys)) + for i, key := range options.ScriptKeys { + key = strings.TrimSpace(key) + if key == "" { + return nil, E.New("script_keys[", i, "] is empty") + } + if strings.ContainsAny(key, "/?#") { + return nil, E.New("script_keys[", i, "] contains a URL separator (/?#); paste the deployment ID only") + } + scriptURLs = append(scriptURLs, fmt.Sprintf("https://script.google.com/macros/s/%s/exec", key)) + } + + client, err := goose.New(goose.Config{ + ScriptURLs: scriptURLs, + Fronting: goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts}, + AESKeyHex: options.TunnelKey, + DebugTiming: options.DebugTiming, + }) + if err != nil { + return nil, E.Cause(err, "construct goose client") + } + + out := &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeGooseRelay, tag, []string{N.NetworkTCP}, options.DialerOptions), + ctx: ctx, + logger: logger, + options: options, + client: client, + } + if options.UDPOverTCP != nil && options.UDPOverTCP.Enabled { + out.uotClient = &uot.Client{ + Dialer: singDialerAdapter{out: out}, + Version: options.UDPOverTCP.Version, + } + } + return out, nil +} + +func (h *Outbound) PostStart() error { + runCtx, cancel := context.WithCancel(h.ctx) + h.mu.Lock() + h.runCancel = cancel + h.mu.Unlock() + + go func() { + if err := h.client.Run(runCtx); err != nil && runCtx.Err() == nil { + h.logger.ErrorContext(runCtx, "carrier run exited: ", err) + } + }() + go h.diagnoseAndMarkReady(runCtx) + return nil +} + +// diagnoseAndMarkReady probes every configured script_key concurrently using a +// throwaway one-endpoint carrier per probe, and flips h.started: +// - +1 (ready) as soon as ANY endpoint passes Diagnose, +// - -1 (failed) only if ALL endpoints fail. +// +// Throwaway probes are cheap: carrier.New only allocates HTTP clients and +// returns; no goroutines are launched until Run is called, which we never do +// for probes. Cancelling probeCtx on first success aborts the remaining +// in-flight HTTP requests. +func (h *Outbound) diagnoseAndMarkReady(runCtx context.Context) { + budget := defaultDiagnoseTimeout + if h.options.HandshakeTimeout != nil { + if d := h.options.HandshakeTimeout.Build(); d > 0 { + budget = d + } + } + probeCtx, cancel := context.WithTimeout(runCtx, budget) + defer cancel() + + googleHost := h.options.GoogleHost + if googleHost == "" { + googleHost = defaultGoogleHost + } + sniHosts := h.options.SNI + if len(sniHosts) == 0 { + sniHosts = defaultSNIHosts + } + fronting := goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts} + + type probeResult struct { + key string + err error + } + keys := h.options.ScriptKeys + results := make(chan probeResult, len(keys)) + for _, key := range keys { + go func(k string) { + trimmed := strings.TrimSpace(k) + probe, err := goose.New(goose.Config{ + ScriptURLs: []string{fmt.Sprintf("https://script.google.com/macros/s/%s/exec", trimmed)}, + Fronting: fronting, + AESKeyHex: h.options.TunnelKey, + }) + if err != nil { + results <- probeResult{trimmed, err} + return + } + results <- probeResult{trimmed, probe.Diagnose(probeCtx)} + }(key) + } + + for i := 0; i < len(keys); i++ { + select { + case r := <-results: + if r.err == nil { + h.mu.Lock() + h.started = 1 + h.mu.Unlock() + h.logger.InfoContext(runCtx, "goose-relay ready (first healthy endpoint: ", r.key, ")") + return + } + h.logger.WarnContext(probeCtx, "goose-relay endpoint ", r.key, " diagnose failed: ", r.err) + case <-runCtx.Done(): + return + } + } + + h.mu.Lock() + h.started = -1 + h.mu.Unlock() + h.logger.ErrorContext(runCtx, "goose-relay: all ", len(keys), " endpoints failed diagnose") +} + +func (h *Outbound) IsReady() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.started > 0 +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !h.IsReady() { + return nil, E.New("outbound is not started") + } + switch N.NetworkName(network) { + case N.NetworkTCP: + default: + return nil, E.New("network ", network, " not supported by goose-relay") + } + return h.client.Dial(destination.String()), nil +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if h.uotClient == nil { + return nil, E.New("UDP over TCP is not enabled for this outbound") + } + if !h.IsReady() { + return nil, E.New("outbound is not started") + } + return h.uotClient.ListenPacket(ctx, destination) +} + +func (h *Outbound) DisplayType() string { + str := C.ProxyDisplayName(h.Type()) + h.mu.Lock() + state := h.started + h.mu.Unlock() + switch { + case state == 0: + return str + " ⚠️ Connecting..." + case state < 0: + return str + " ❌ Failed!" + default: + return fmt.Sprint(str, " ✔️ ", len(h.options.ScriptKeys), " endpoints") + } +} + +func (h *Outbound) Close() error { + h.mu.Lock() + cancel := h.runCancel + h.runCancel = nil + h.mu.Unlock() + if cancel != nil { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + h.client.Shutdown(shutdownCtx) + shutdownCancel() + cancel() + } + return nil +} + +// singDialerAdapter bridges Outbound back to N.Dialer so uot.Client can dial +// its underlying TCP carrier session via DialContext. +type singDialerAdapter struct { + out *Outbound +} + +func (a singDialerAdapter) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + return a.out.DialContext(ctx, network, destination) +} + +func (a singDialerAdapter) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return nil, E.New("not supported") +} diff --git a/protocol/hiddify/gooserelay/outbound_test.go b/protocol/hiddify/gooserelay/outbound_test.go new file mode 100644 index 0000000000..411c14dd70 --- /dev/null +++ b/protocol/hiddify/gooserelay/outbound_test.go @@ -0,0 +1,127 @@ +package gooserelay_test + +import ( + "context" + "strings" + "testing" + + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/protocol/hiddify/gooserelay" +) + +const validHexKey = "0000000000000000000000000000000000000000000000000000000000000000" + +func TestNew_OptionValidation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + opts option.GooseRelayOptions + wantErr string // substring expected in the error; "" = expect success + wantSkip bool + }{ + { + name: "missing script_keys", + opts: option.GooseRelayOptions{TunnelKey: validHexKey}, + wantErr: "script_keys is required", + }, + { + name: "missing tunnel_key", + opts: option.GooseRelayOptions{ScriptKeys: []string{"abc"}}, + wantErr: "tunnel_key is required", + }, + { + name: "tunnel_key wrong length", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc"}, + TunnelKey: "deadbeef", + }, + wantErr: "64 hex characters", + }, + { + name: "tunnel_key not hex", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc"}, + TunnelKey: strings.Repeat("Z", 64), + }, + wantErr: "not valid hex", + }, + { + name: "empty entry in script_keys", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc", " "}, + TunnelKey: validHexKey, + }, + wantErr: "script_keys[1] is empty", + }, + { + name: "script_keys contains URL separator /", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc/def"}, + TunnelKey: validHexKey, + }, + wantErr: "URL separator", + }, + { + name: "script_keys contains URL separator ?", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc?evil=1"}, + TunnelKey: validHexKey, + }, + wantErr: "URL separator", + }, + { + name: "script_keys contains URL separator #", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"abc#frag"}, + TunnelKey: validHexKey, + }, + wantErr: "URL separator", + }, + { + name: "valid minimal options", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"AKfycbxFakeDeploymentID"}, + TunnelKey: validHexKey, + }, + wantErr: "", + }, + { + name: "valid with custom GoogleHost and SNI defaults applied", + opts: option.GooseRelayOptions{ + ScriptKeys: []string{"AKfycbxFakeDeploymentID"}, + TunnelKey: validHexKey, + GoogleHost: "1.2.3.4:443", + }, + wantErr: "", + }, + } + + logger := log.NewNOPFactory().Logger() + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if tc.wantSkip { + t.Skip() + } + out, err := gooserelay.New(context.Background(), nil, logger, "goose-test", tc.opts) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if out == nil { + t.Fatal("expected non-nil outbound on success") + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +}