Skip to content

Commit d755577

Browse files
authored
Merge pull request #22 from hiddifyafk/claude/exciting-williams-e000eb
feat(gooserelay): add GooseRelay outbound under protocol/hiddify/gooserelay
2 parents 2f648d7 + 7196f71 commit d755577

7 files changed

Lines changed: 434 additions & 0 deletions

File tree

constant/proxy.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const (
4646
TypeAwg = "awg" //H
4747
TypeBalancer = "balancer" //H
4848
TypeDNSTT = "dnstt" //H
49+
TypeGooseRelay = "gooserelay" //H
4950
TypeSmartDNSPool = "smart_dns_pool" //H — local recursive-resolver pool with AIMD throttling + recovery probing (github.com/hiddify/hmrd_multi_resolver_dns)
5051
)
5152

@@ -132,6 +133,8 @@ func ProxyDisplayName(proxyType string) string {
132133
return "Balancer"
133134
case TypeDNSTT:
134135
return "DNSTT"
136+
case TypeGooseRelay:
137+
return "GooseRelay"
135138
default:
136139
return "Unknown"
137140
}

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91
2222
github.com/jsimonetti/rtnetlink v1.4.0
2323
github.com/keybase/go-keychain v0.0.1
24+
github.com/kianmhz/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c
2425
github.com/libdns/acmedns v0.5.0
2526
github.com/libdns/alidns v1.0.6
2627
github.com/libdns/cloudflare v0.2.2
@@ -229,6 +230,7 @@ require (
229230
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
230231
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
231232
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
233+
github.com/things-go/go-socks5 v0.1.1 // indirect
232234
github.com/tidwall/sjson v1.2.5 // indirect
233235
github.com/tjfoc/gmsm v1.4.1 // indirect
234236
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
@@ -270,3 +272,5 @@ replace github.com/Psiphon-Labs/quic-go => ./replace/psiphon-quic-go
270272
replace github.com/Psiphon-Labs/psiphon-tls => ./replace/psiphon-tls
271273

272274
replace github.com/net2share/vaydns => github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9
275+
276+
replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
216216
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
217217
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
218218
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
219+
github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c h1:9Zc0hJ14G+SNJvGxtaSCSsvGB/RdLltrgjgR//9zgTQ=
220+
github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ=
219221
github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e h1:GsHpSccRtSFe1l3hq0WtBjqsKjJdU+an2D+uCsXm+xk=
220222
github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e/go.mod h1:7u9Ece+I3gvMFzcpqhROWaykNUyoE21g/fYQMBasAWY=
221223
github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 h1:KXnaABX8hHmkcL0jbL769hEIGI5+z/DajCrlO+Bkzcc=
@@ -534,6 +536,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
534536
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
535537
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
536538
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
539+
github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY=
540+
github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0=
537541
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
538542
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
539543
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=

include/registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/sagernet/sing-box/protocol/group"
2626
"github.com/sagernet/sing-box/protocol/group/balancer"
2727
"github.com/sagernet/sing-box/protocol/hiddify/dnstt"
28+
"github.com/sagernet/sing-box/protocol/hiddify/gooserelay"
2829
"github.com/sagernet/sing-box/protocol/hiddify/hinvalid"
2930

3031
"github.com/sagernet/sing-box/protocol/hiddify/xray"
@@ -110,6 +111,7 @@ func OutboundRegistry() *outbound.Registry {
110111
hinvalid.RegisterOutbound(registry)
111112
xray.RegisterOutbound(registry)
112113
dnstt.RegisterOutbound(registry)
114+
gooserelay.RegisterOutbound(registry)
113115
balancer.RegisterLoadBalance(registry)
114116

115117
registerQUICOutbounds(registry)

option/gooserelay.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package option
2+
3+
import (
4+
"github.com/sagernet/sing/common/json/badoption"
5+
)
6+
7+
type GooseRelayOptions struct {
8+
DialerOptions
9+
10+
ScriptKeys []string `json:"script_keys,omitempty"`
11+
TunnelKey string `json:"tunnel_key,omitempty"`
12+
GoogleHost string `json:"google_host,omitempty"`
13+
SNI []string `json:"sni,omitempty"`
14+
DebugTiming bool `json:"debug_timing,omitempty"`
15+
16+
UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"`
17+
18+
HandshakeTimeout *badoption.Duration `json:"handshake_timeout,omitempty"`
19+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Package gooserelay wraps the GooseRelayVPN carrier as a sing-box outbound.
2+
//
3+
// Traffic is multiplexed over a domain-fronted HTTPS connection to a Google
4+
// Apps Script endpoint, which forwards encrypted frames to the user's VPS.
5+
// The carrier handles per-endpoint health and round-robin internally; this
6+
// outbound only manages the lifecycle and wraps each session as a net.Conn.
7+
package gooserelay
8+
9+
import (
10+
"context"
11+
"encoding/hex"
12+
"fmt"
13+
"net"
14+
"strings"
15+
"sync"
16+
"time"
17+
18+
"github.com/kianmhz/GooseRelayVPN/goose"
19+
"github.com/sagernet/sing-box/adapter"
20+
"github.com/sagernet/sing-box/adapter/outbound"
21+
C "github.com/sagernet/sing-box/constant"
22+
"github.com/sagernet/sing-box/log"
23+
"github.com/sagernet/sing-box/option"
24+
E "github.com/sagernet/sing/common/exceptions"
25+
"github.com/sagernet/sing/common/logger"
26+
M "github.com/sagernet/sing/common/metadata"
27+
N "github.com/sagernet/sing/common/network"
28+
"github.com/sagernet/sing/common/uot"
29+
)
30+
31+
const (
32+
defaultGoogleHost = "216.239.38.120:443"
33+
defaultDiagnoseTimeout = 10 * time.Second
34+
)
35+
36+
var defaultSNIHosts = []string{"www.google.com"}
37+
38+
func RegisterOutbound(registry *outbound.Registry) {
39+
outbound.Register[option.GooseRelayOptions](registry, C.TypeGooseRelay, New)
40+
}
41+
42+
var _ adapter.Outbound = (*Outbound)(nil)
43+
44+
type Outbound struct {
45+
outbound.Adapter
46+
ctx context.Context
47+
logger logger.ContextLogger
48+
options option.GooseRelayOptions
49+
client *goose.Client
50+
uotClient *uot.Client
51+
52+
mu sync.Mutex
53+
runCancel context.CancelFunc
54+
started int
55+
}
56+
57+
func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.GooseRelayOptions) (adapter.Outbound, error) {
58+
if len(options.ScriptKeys) == 0 {
59+
return nil, E.New("script_keys is required")
60+
}
61+
if options.TunnelKey == "" {
62+
return nil, E.New("tunnel_key is required")
63+
}
64+
if len(options.TunnelKey) != 64 {
65+
return nil, E.New("tunnel_key must be 64 hex characters (AES-256)")
66+
}
67+
if _, err := hex.DecodeString(options.TunnelKey); err != nil {
68+
return nil, E.Cause(err, "tunnel_key not valid hex")
69+
}
70+
71+
googleHost := options.GoogleHost
72+
if googleHost == "" {
73+
googleHost = defaultGoogleHost
74+
}
75+
sniHosts := options.SNI
76+
if len(sniHosts) == 0 {
77+
sniHosts = defaultSNIHosts
78+
}
79+
80+
scriptURLs := make([]string, 0, len(options.ScriptKeys))
81+
for i, key := range options.ScriptKeys {
82+
key = strings.TrimSpace(key)
83+
if key == "" {
84+
return nil, E.New("script_keys[", i, "] is empty")
85+
}
86+
if strings.ContainsAny(key, "/?#") {
87+
return nil, E.New("script_keys[", i, "] contains a URL separator (/?#); paste the deployment ID only")
88+
}
89+
scriptURLs = append(scriptURLs, fmt.Sprintf("https://script.google.com/macros/s/%s/exec", key))
90+
}
91+
92+
client, err := goose.New(goose.Config{
93+
ScriptURLs: scriptURLs,
94+
Fronting: goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts},
95+
AESKeyHex: options.TunnelKey,
96+
DebugTiming: options.DebugTiming,
97+
})
98+
if err != nil {
99+
return nil, E.Cause(err, "construct goose client")
100+
}
101+
102+
out := &Outbound{
103+
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeGooseRelay, tag, []string{N.NetworkTCP}, options.DialerOptions),
104+
ctx: ctx,
105+
logger: logger,
106+
options: options,
107+
client: client,
108+
}
109+
if options.UDPOverTCP != nil && options.UDPOverTCP.Enabled {
110+
out.uotClient = &uot.Client{
111+
Dialer: singDialerAdapter{out: out},
112+
Version: options.UDPOverTCP.Version,
113+
}
114+
}
115+
return out, nil
116+
}
117+
118+
func (h *Outbound) PostStart() error {
119+
runCtx, cancel := context.WithCancel(h.ctx)
120+
h.mu.Lock()
121+
h.runCancel = cancel
122+
h.mu.Unlock()
123+
124+
go func() {
125+
if err := h.client.Run(runCtx); err != nil && runCtx.Err() == nil {
126+
h.logger.ErrorContext(runCtx, "carrier run exited: ", err)
127+
}
128+
}()
129+
go h.diagnoseAndMarkReady(runCtx)
130+
return nil
131+
}
132+
133+
// diagnoseAndMarkReady probes every configured script_key concurrently using a
134+
// throwaway one-endpoint carrier per probe, and flips h.started:
135+
// - +1 (ready) as soon as ANY endpoint passes Diagnose,
136+
// - -1 (failed) only if ALL endpoints fail.
137+
//
138+
// Throwaway probes are cheap: carrier.New only allocates HTTP clients and
139+
// returns; no goroutines are launched until Run is called, which we never do
140+
// for probes. Cancelling probeCtx on first success aborts the remaining
141+
// in-flight HTTP requests.
142+
func (h *Outbound) diagnoseAndMarkReady(runCtx context.Context) {
143+
budget := defaultDiagnoseTimeout
144+
if h.options.HandshakeTimeout != nil {
145+
if d := h.options.HandshakeTimeout.Build(); d > 0 {
146+
budget = d
147+
}
148+
}
149+
probeCtx, cancel := context.WithTimeout(runCtx, budget)
150+
defer cancel()
151+
152+
googleHost := h.options.GoogleHost
153+
if googleHost == "" {
154+
googleHost = defaultGoogleHost
155+
}
156+
sniHosts := h.options.SNI
157+
if len(sniHosts) == 0 {
158+
sniHosts = defaultSNIHosts
159+
}
160+
fronting := goose.FrontingConfig{GoogleIP: googleHost, SNIHosts: sniHosts}
161+
162+
type probeResult struct {
163+
key string
164+
err error
165+
}
166+
keys := h.options.ScriptKeys
167+
results := make(chan probeResult, len(keys))
168+
for _, key := range keys {
169+
go func(k string) {
170+
trimmed := strings.TrimSpace(k)
171+
probe, err := goose.New(goose.Config{
172+
ScriptURLs: []string{fmt.Sprintf("https://script.google.com/macros/s/%s/exec", trimmed)},
173+
Fronting: fronting,
174+
AESKeyHex: h.options.TunnelKey,
175+
})
176+
if err != nil {
177+
results <- probeResult{trimmed, err}
178+
return
179+
}
180+
results <- probeResult{trimmed, probe.Diagnose(probeCtx)}
181+
}(key)
182+
}
183+
184+
for i := 0; i < len(keys); i++ {
185+
select {
186+
case r := <-results:
187+
if r.err == nil {
188+
h.mu.Lock()
189+
h.started = 1
190+
h.mu.Unlock()
191+
h.logger.InfoContext(runCtx, "goose-relay ready (first healthy endpoint: ", r.key, ")")
192+
return
193+
}
194+
h.logger.WarnContext(probeCtx, "goose-relay endpoint ", r.key, " diagnose failed: ", r.err)
195+
case <-runCtx.Done():
196+
return
197+
}
198+
}
199+
200+
h.mu.Lock()
201+
h.started = -1
202+
h.mu.Unlock()
203+
h.logger.ErrorContext(runCtx, "goose-relay: all ", len(keys), " endpoints failed diagnose")
204+
}
205+
206+
func (h *Outbound) IsReady() bool {
207+
h.mu.Lock()
208+
defer h.mu.Unlock()
209+
return h.started > 0
210+
}
211+
212+
func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
213+
if !h.IsReady() {
214+
return nil, E.New("outbound is not started")
215+
}
216+
switch N.NetworkName(network) {
217+
case N.NetworkTCP:
218+
default:
219+
return nil, E.New("network ", network, " not supported by goose-relay")
220+
}
221+
return h.client.Dial(destination.String()), nil
222+
}
223+
224+
func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
225+
if h.uotClient == nil {
226+
return nil, E.New("UDP over TCP is not enabled for this outbound")
227+
}
228+
if !h.IsReady() {
229+
return nil, E.New("outbound is not started")
230+
}
231+
return h.uotClient.ListenPacket(ctx, destination)
232+
}
233+
234+
func (h *Outbound) DisplayType() string {
235+
str := C.ProxyDisplayName(h.Type())
236+
h.mu.Lock()
237+
state := h.started
238+
h.mu.Unlock()
239+
switch {
240+
case state == 0:
241+
return str + " ⚠️ Connecting..."
242+
case state < 0:
243+
return str + " ❌ Failed!"
244+
default:
245+
return fmt.Sprint(str, " ✔️ ", len(h.options.ScriptKeys), " endpoints")
246+
}
247+
}
248+
249+
func (h *Outbound) Close() error {
250+
h.mu.Lock()
251+
cancel := h.runCancel
252+
h.runCancel = nil
253+
h.mu.Unlock()
254+
if cancel != nil {
255+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
256+
h.client.Shutdown(shutdownCtx)
257+
shutdownCancel()
258+
cancel()
259+
}
260+
return nil
261+
}
262+
263+
// singDialerAdapter bridges Outbound back to N.Dialer so uot.Client can dial
264+
// its underlying TCP carrier session via DialContext.
265+
type singDialerAdapter struct {
266+
out *Outbound
267+
}
268+
269+
func (a singDialerAdapter) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
270+
return a.out.DialContext(ctx, network, destination)
271+
}
272+
273+
func (a singDialerAdapter) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
274+
return nil, E.New("not supported")
275+
}

0 commit comments

Comments
 (0)