From 2d8f97b3a12fc975efee0edf15e008401ce57f96 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 14:40:22 +0330 Subject: [PATCH 01/10] feat: add GooseRelay outbound under protocol/hiddify/gooserelay Wraps the GooseRelayVPN carrier (hiddify/GooseRelayVPN fork) as a native sing-box outbound. Domain-fronted HTTPS to a Google Apps Script endpoint relays AES-256-GCM-encrypted frames to a VPS, mirroring how dnstt is embedded as a Hiddify-only outbound. The carrier already round-robins across script_keys and blacklists failing endpoints internally, so the outbound only owns lifecycle: PostStart launches client.Run(ctx), Diagnose() gates IsReady, Close sends RST frames via client.Shutdown. TCP only at the carrier level; UDP works via uot.Client when udp_over_tcp.enabled is set on the outbound. Co-Authored-By: Claude Opus 4.7 (1M context) --- constant/proxy.go | 5 +- go.mod | 4 + go.sum | 4 + include/registry.go | 2 + option/gooserelay.go | 19 ++ protocol/hiddify/gooserelay/outbound.go | 221 ++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 option/gooserelay.go create mode 100644 protocol/hiddify/gooserelay/outbound.go diff --git a/constant/proxy.go b/constant/proxy.go index 6d34811780..4dd24ba58f 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -45,7 +45,8 @@ const ( TypeCustom = "custom" //H TypeAwg = "awg" //H TypeBalancer = "balancer" //H - TypeDNSTT = "dnstt" //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..7f10a5f167 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-20260429110100-fc78a0e0328b 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-20260429110100-fc78a0e0328b diff --git a/go.sum b/go.sum index 6e1b61c276..4cb6679a2d 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-20260429110100-fc78a0e0328b h1:gBPBXr9FwpIOOpHXqnLzdLDZ6RStSEKYx9D7d/s7n7A= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429110100-fc78a0e0328b/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..44ac8a8436 --- /dev/null +++ b/protocol/hiddify/gooserelay/outbound.go @@ -0,0 +1,221 @@ +// 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" + + carrier "github.com/kianmhz/GooseRelayVPN/pkg/carrier" + gsocks "github.com/kianmhz/GooseRelayVPN/pkg/socks" + "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" + defaultHandshakeBudget = 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 *carrier.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 _, err := hex.DecodeString(options.TunnelKey); err != nil || len(options.TunnelKey) != 64 { + return nil, E.New("tunnel_key must be 64 hex characters (AES-256)") + } + + 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") + } + scriptURLs = append(scriptURLs, fmt.Sprintf("https://script.google.com/macros/s/%s/exec", key)) + } + + client, err := carrier.New(carrier.Config{ + ScriptURLs: scriptURLs, + Fronting: carrier.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts}, + AESKeyHex: options.TunnelKey, + DebugTiming: options.DebugTiming, + }) + if err != nil { + return nil, E.Cause(err, "construct carrier") + } + + 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.Error("carrier run exited: ", err) + } + }() + go h.diagnoseAndMarkReady() + return nil +} + +func (h *Outbound) diagnoseAndMarkReady() { + budget := defaultHandshakeBudget + if h.options.HandshakeTimeout != nil { + if d := h.options.HandshakeTimeout.Build(); d > 0 { + budget = d + } + } + probeCtx, cancel := context.WithTimeout(h.ctx, budget) + defer cancel() + + if err := h.client.Diagnose(probeCtx); err != nil { + h.logger.Error("goose-relay diagnose failed: ", err) + h.mu.Lock() + h.started = -1 + h.mu.Unlock() + return + } + h.mu.Lock() + h.started = 1 + h.mu.Unlock() + h.logger.Info("goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") +} + +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") + } + sess := h.client.NewSession(destination.String()) + return gsocks.NewVirtualConn(sess), 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") +} From 6bc48c0b5ea65f1308a44d9ca7214c8fb9f9db1e Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:04:58 +0330 Subject: [PATCH 02/10] style(constant): re-align //H comment column after adding TypeGooseRelay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gofmt aligns the // line-comment column to the longest entry in a const group. TypeGooseRelay is the longest //H entry, so this shifts the //H column for the prior lines. No semantic change — pure whitespace. Co-Authored-By: Claude Opus 4.7 (1M context) --- constant/proxy.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/constant/proxy.go b/constant/proxy.go index 4dd24ba58f..bd5a3e611a 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -40,11 +40,11 @@ const ( TypeACME = "acme" TypeCloudflareOriginCA = "cloudflare-origin-ca" - TypeHInvalidConfig = "hinvalid" //H - TypeXray = "xray" //H - TypeCustom = "custom" //H - TypeAwg = "awg" //H - TypeBalancer = "balancer" //H + TypeHInvalidConfig = "hinvalid" //H + TypeXray = "xray" //H + TypeCustom = "custom" //H + TypeAwg = "awg" //H + TypeBalancer = "balancer" //H TypeDNSTT = "dnstt" //H TypeGooseRelay = "gooserelay" //H ) From c7cf64d6a3cfac4975915c1db8fcf081f201aac9 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:05:33 +0330 Subject: [PATCH 03/10] refactor(gooserelay): rename defaultHandshakeBudget to defaultDiagnoseTimeout The constant gates the carrier.Diagnose() probe specifically, not a generic handshake. The new name makes its purpose obvious at the call site without grepping the function it controls. Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 44ac8a8436..8f7d42f5cc 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -31,7 +31,7 @@ import ( const ( defaultGoogleHost = "216.239.38.120:443" - defaultHandshakeBudget = 10 * time.Second + defaultDiagnoseTimeout = 10 * time.Second ) var defaultSNIHosts = []string{"www.google.com"} @@ -126,7 +126,7 @@ func (h *Outbound) PostStart() error { } func (h *Outbound) diagnoseAndMarkReady() { - budget := defaultHandshakeBudget + budget := defaultDiagnoseTimeout if h.options.HandshakeTimeout != nil { if d := h.options.HandshakeTimeout.Build(); d > 0 { budget = d From 8bf7a0a1d63f9977a432f7abc626dd51e6e89136 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:05:55 +0330 Subject: [PATCH 04/10] fix(gooserelay): check tunnel_key length before attempting hex decode hex.DecodeString runs allocator and parser even on obviously-wrong input. Cheap to short-circuit with a length check, and the separated paths give the user a more specific error: "wrong length" vs "not valid hex". Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 8f7d42f5cc..b9df9410e4 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -62,9 +62,12 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t if options.TunnelKey == "" { return nil, E.New("tunnel_key is required") } - if _, err := hex.DecodeString(options.TunnelKey); err != nil || len(options.TunnelKey) != 64 { + 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 == "" { From 25a163debd16e9fc1105fae05b2c0ee07ea20503 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:06:15 +0330 Subject: [PATCH 05/10] fix(gooserelay): reject script_keys containing URL separators A deployment ID with /, ?, or # would inject path/query/fragment into the constructed Apps Script URL and route the request somewhere other than the user's deployment. Apps Script wouldn't honor it usefully, but defensively reject upfront with a message that points the user at the likely cause (they pasted a full URL instead of the deployment ID). Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index b9df9410e4..01fb87b4a8 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -84,6 +84,9 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t 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)) } From 1a01efb527ee9c064b63653c95eb44741f7c5610 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:20:58 +0330 Subject: [PATCH 06/10] refactor(gooserelay): use context-aware logger methods Matches the dnstt sibling's style: ErrorContext/InfoContext propagate the relevant context (runCtx for the carrier run goroutine, probeCtx for the diagnose probe, h.ctx for the lifecycle-level ready message) so log lines carry tracing/cancel signals through the logger pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 01fb87b4a8..822a24420b 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -124,7 +124,7 @@ func (h *Outbound) PostStart() error { go func() { if err := h.client.Run(runCtx); err != nil && runCtx.Err() == nil { - h.logger.Error("carrier run exited: ", err) + h.logger.ErrorContext(runCtx, "carrier run exited: ", err) } }() go h.diagnoseAndMarkReady() @@ -142,7 +142,7 @@ func (h *Outbound) diagnoseAndMarkReady() { defer cancel() if err := h.client.Diagnose(probeCtx); err != nil { - h.logger.Error("goose-relay diagnose failed: ", err) + h.logger.ErrorContext(probeCtx, "goose-relay diagnose failed: ", err) h.mu.Lock() h.started = -1 h.mu.Unlock() @@ -151,7 +151,7 @@ func (h *Outbound) diagnoseAndMarkReady() { h.mu.Lock() h.started = 1 h.mu.Unlock() - h.logger.Info("goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") + h.logger.InfoContext(h.ctx, "goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") } func (h *Outbound) IsReady() bool { From 63f71e495aaba9150b844329224de5a5de0eb061 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:21:23 +0330 Subject: [PATCH 07/10] fix(gooserelay): tie diagnose probe to runCtx so Close cancels it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the probe used h.ctx as parent, so a Close() call mid-probe left the diagnose goroutine running until its own 10s timeout — and worse, it could flip h.started to 1 after Close had already torn down the carrier. Threading runCtx through means cancelling runCancel (which Close already does) propagates to the probe immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 822a24420b..0a34642cab 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -127,18 +127,18 @@ func (h *Outbound) PostStart() error { h.logger.ErrorContext(runCtx, "carrier run exited: ", err) } }() - go h.diagnoseAndMarkReady() + go h.diagnoseAndMarkReady(runCtx) return nil } -func (h *Outbound) diagnoseAndMarkReady() { +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(h.ctx, budget) + probeCtx, cancel := context.WithTimeout(runCtx, budget) defer cancel() if err := h.client.Diagnose(probeCtx); err != nil { @@ -151,7 +151,7 @@ func (h *Outbound) diagnoseAndMarkReady() { h.mu.Lock() h.started = 1 h.mu.Unlock() - h.logger.InfoContext(h.ctx, "goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") + h.logger.InfoContext(runCtx, "goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") } func (h *Outbound) IsReady() bool { From fbd73fb005df190b94e61f3fc9b94b5c83c6dd74 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:22:35 +0330 Subject: [PATCH 08/10] feat(gooserelay): probe all script_keys concurrently, ready on first pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously diagnose only checked endpoints[0], so a misconfigured first key kept the outbound in failed state even when subsequent keys would have worked at runtime. Now each key gets its own throwaway *carrier.Client (no Run goroutines, just one Diagnose HTTP round-trip) and the outbound flips ready on first success — matching dnstt's "any healthy resolver wins" semantics. When one probe succeeds we cancel probeCtx, which aborts the remaining HTTP probes mid-flight. If all probes fail, we surface every per-key error as a Warn before flipping to failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound.go | 66 ++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 0a34642cab..33211a2923 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -131,6 +131,15 @@ func (h *Outbound) PostStart() error { 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 { @@ -141,17 +150,58 @@ func (h *Outbound) diagnoseAndMarkReady(runCtx context.Context) { probeCtx, cancel := context.WithTimeout(runCtx, budget) defer cancel() - if err := h.client.Diagnose(probeCtx); err != nil { - h.logger.ErrorContext(probeCtx, "goose-relay diagnose failed: ", err) - h.mu.Lock() - h.started = -1 - h.mu.Unlock() - return + googleHost := h.options.GoogleHost + if googleHost == "" { + googleHost = defaultGoogleHost } + sniHosts := h.options.SNI + if len(sniHosts) == 0 { + sniHosts = defaultSNIHosts + } + fronting := carrier.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 := carrier.New(carrier.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.started = -1 h.mu.Unlock() - h.logger.InfoContext(runCtx, "goose-relay ready (", len(h.options.ScriptKeys), " endpoints)") + h.logger.ErrorContext(runCtx, "goose-relay: all ", len(keys), " endpoints failed diagnose") } func (h *Outbound) IsReady() bool { From 71acba529e0b378966d84dca20cfaa833b544fb0 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 15:23:34 +0330 Subject: [PATCH 09/10] test(gooserelay): add option-validation table tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the constructor's reject paths (missing/invalid script_keys and tunnel_key, URL separators in keys) plus the happy path that defaults Fronting and constructs a real *carrier.Client. No network I/O — Run() is never called, so the carrier just sits idle in memory until the test ends. Co-Authored-By: Claude Opus 4.7 (1M context) --- protocol/hiddify/gooserelay/outbound_test.go | 127 +++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 protocol/hiddify/gooserelay/outbound_test.go 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) + } + }) + } +} From a73acd51500598ec2819d9370ef7948a3ae2e873 Mon Sep 17 00:00:00 2001 From: Persian ROss <37_privacy.blends@icloud.com> Date: Wed, 29 Apr 2026 16:28:57 +0330 Subject: [PATCH 10/10] refactor(gooserelay): switch to upstream's goose SDK package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream fork (hiddify/GooseRelayVPN) reverted the internal/->pkg/ rename and now exposes a thin SDK at .../goose. Swap our imports to that single package, which: - replaces *carrier.Client with *goose.Client (a thin wrapper) - replaces carrier.Config / carrier.FrontingConfig with type aliases exported from goose - collapses NewSession + socks.NewVirtualConn into client.Dial(target) Net effect on this side: 14 lines changed in outbound.go, no behavior change. Net effect on the fork: upstream Kianmhz/GooseRelayVPN merges land cleanly forever — only the goose package is fork-specific. Pseudo-version pins to merge commit 0e68c2a3ae4c on hiddify main. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 4 ++-- go.sum | 4 ++-- protocol/hiddify/gooserelay/outbound.go | 18 ++++++++---------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index 7f10a5f167..a7a20bc922 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +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-20260429110100-fc78a0e0328b + 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 @@ -272,4 +272,4 @@ 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-20260429110100-fc78a0e0328b +replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c diff --git a/go.sum b/go.sum index 4cb6679a2d..4b68a107f0 100644 --- a/go.sum +++ b/go.sum @@ -216,8 +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-20260429110100-fc78a0e0328b h1:gBPBXr9FwpIOOpHXqnLzdLDZ6RStSEKYx9D7d/s7n7A= -github.com/hiddify/GooseRelayVPN v0.0.0-20260429110100-fc78a0e0328b/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ= +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= diff --git a/protocol/hiddify/gooserelay/outbound.go b/protocol/hiddify/gooserelay/outbound.go index 33211a2923..91922d3ab5 100644 --- a/protocol/hiddify/gooserelay/outbound.go +++ b/protocol/hiddify/gooserelay/outbound.go @@ -15,8 +15,7 @@ import ( "sync" "time" - carrier "github.com/kianmhz/GooseRelayVPN/pkg/carrier" - gsocks "github.com/kianmhz/GooseRelayVPN/pkg/socks" + "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" @@ -47,7 +46,7 @@ type Outbound struct { ctx context.Context logger logger.ContextLogger options option.GooseRelayOptions - client *carrier.Client + client *goose.Client uotClient *uot.Client mu sync.Mutex @@ -90,14 +89,14 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t scriptURLs = append(scriptURLs, fmt.Sprintf("https://script.google.com/macros/s/%s/exec", key)) } - client, err := carrier.New(carrier.Config{ + client, err := goose.New(goose.Config{ ScriptURLs: scriptURLs, - Fronting: carrier.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts}, + Fronting: goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts}, AESKeyHex: options.TunnelKey, DebugTiming: options.DebugTiming, }) if err != nil { - return nil, E.Cause(err, "construct carrier") + return nil, E.Cause(err, "construct goose client") } out := &Outbound{ @@ -158,7 +157,7 @@ func (h *Outbound) diagnoseAndMarkReady(runCtx context.Context) { if len(sniHosts) == 0 { sniHosts = defaultSNIHosts } - fronting := carrier.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts} + fronting := goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts} type probeResult struct { key string @@ -169,7 +168,7 @@ func (h *Outbound) diagnoseAndMarkReady(runCtx context.Context) { for _, key := range keys { go func(k string) { trimmed := strings.TrimSpace(k) - probe, err := carrier.New(carrier.Config{ + 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, @@ -219,8 +218,7 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination default: return nil, E.New("network ", network, " not supported by goose-relay") } - sess := h.client.NewSession(destination.String()) - return gsocks.NewVirtualConn(sess), nil + return h.client.Dial(destination.String()), nil } func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {