Skip to content

feat(gooserelay): add GooseRelay outbound under protocol/hiddify/gooserelay#22

Open
hiddifyafk wants to merge 10 commits into
hiddify:extendedfrom
hiddifyafk:claude/exciting-williams-e000eb
Open

feat(gooserelay): add GooseRelay outbound under protocol/hiddify/gooserelay#22
hiddifyafk wants to merge 10 commits into
hiddify:extendedfrom
hiddifyafk:claude/exciting-williams-e000eb

Conversation

@hiddifyafk
Copy link
Copy Markdown

Summary

Adds a new gooserelay outbound that wraps the GooseRelayVPN carrier — domain-fronted HTTPS to a Google Apps Script endpoint, AES-256-GCM-encrypted frames forwarded to a VPS — as a native Hiddify-only sing-box outbound. Lives at protocol/hiddify/gooserelay/ next to dnstt, registered through the //H block in constant/proxy.go and include/registry.go. Zero touch to vanilla protocol/ paths so upstream merges stay clean.

What's in the JSON config

{
  "type": "gooserelay",
  "tag": "goose-out",
  "script_keys": ["AKfycbx<deployment-id>"],
  "tunnel_key": "<64-char hex AES-256 key>",
  "google_host": "216.239.38.120:443",
  "sni": ["www.google.com"],
  "udp_over_tcp": {"enabled": false},
  "handshake_timeout": "10s"
}

script_keys are deployment IDs (not full URLs); the outbound builds the https://script.google.com/macros/s/.../exec URLs internally.

Upstream fork dependency

Depends on a Hiddify fork of Kianmhz/GooseRelayVPN because the carrier's reusable types live in internal/* upstream. The fork ships a single thin wrapper package (goose/sdk.go) that re-exports Config, FrontingConfig, and a Client with the four methods this outbound actually needs (Run, Diagnose, Shutdown, Dial). The fork's tree is otherwise byte-identical to upstream so future upstream merges land conflict-free.

go.mod changes:

  • require github.com/kianmhz/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c
  • replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c

Pseudo-version pins to merge commit 0e68c2a3ae4c on hiddify/GooseRelayVPN main (PR #2 there: switch to SDK wrapper).

Architecture notes

  • One *goose.Client per outbound. The carrier handles per-endpoint round-robin and exponential-backoff blacklisting internally, so we don't need a tester.go analog of the dnstt outbound. Significantly less code than dnstt for equivalent reliability.
  • Multi-endpoint diagnose at startup. Each script_key is probed concurrently via a throwaway one-endpoint *goose.Client.Diagnose(); the outbound flips ready on the first success and cancels remaining probes. Mirrors dnstt's "any healthy resolver wins" semantics.
  • Lifecycle. PostStart launches client.Run(runCtx) and the diagnose probes (both tied to the same runCtx so Close() cancels them). Close() calls goose.Client.Shutdown first to send graceful RSTs, then cancels runCtx to stop the workers.
  • TCP-only at the carrier level. UDP works via uot.Client when udp_over_tcp.enabled is set, using the outbound itself as the underlying TCP dialer.

Commits

Self-contained, ordered to minimize churn in diagnoseAndMarkReady:

# SHA Subject
1 2d8f97b3 feat: add GooseRelay outbound under protocol/hiddify/gooserelay
2 6bc48c0b style(constant): re-align //H comment column after adding TypeGooseRelay
3 c7cf64d6 refactor(gooserelay): rename defaultHandshakeBudgetdefaultDiagnoseTimeout
4 8bf7a0a1 fix(gooserelay): check tunnel_key length before attempting hex decode
5 25a163de fix(gooserelay): reject script_keys containing URL separators
6 1a01efb5 refactor(gooserelay): use context-aware logger methods
7 63f71e49 fix(gooserelay): tie diagnose probe to runCtx so Close cancels it
8 fbd73fb0 feat(gooserelay): probe all script_keys concurrently, ready on first pass
9 71acba52 test(gooserelay): add option-validation table tests
10 a73acd51 refactor(gooserelay): switch to upstream's goose SDK package

Commits 2–10 are review-fixes/migration on top of commit 1. Happy to squash on merge if preferred.

Test plan

  • go build ./... — passes (only the unrelated macOS -lobjc linker warning).
  • go test -race ./protocol/hiddify/gooserelay/... — passes (10 sub-cases in TestNew_OptionValidation covering missing/invalid script_keys and tunnel_key, URL-separator rejection, and the happy path).
  • go vet ./protocol/hiddify/gooserelay/... — clean.
  • Runtime check. Built cmd/sing-box, ran sing-box check -c <config> against:
    • valid minimal gooserelay config → exits 0 ✓
    • tunnel_key length 8 → tunnel_key must be 64 hex characters (AES-256)
    • script_keys[0] = "abc/def"URL separator (/?#); paste the deployment ID only
    • missing script_keysscript_keys is required
  • End-to-end (out of scope for this PR). Requires deploying the GooseRelayVPN Apps Script + running cmd/server of the fork on a VPS, then pointing real script_keys + tunnel_key at it. The outbound is structurally complete; this is just a deployment step the operator runs.

Reviewer notes

  • Opened from hiddifyafk/hiddify-sing-box because hiddifyafk lacks push access on the org repo (same constraint hit on the GooseRelayVPN fork). Mirroring the workflow used for PR update to latest #1/new: add KDE set system proxy support #2 there.
  • Display name in ProxyDisplayName: GooseRelay — single-token style matching DNSTT.
  • The goose/sdk.go wrapper currently uses type aliases (type Config = carrier.Config), which keeps us coupled to upstream's struct shape. If upstream renames a Config field, this PR's outbound code may need to follow. Concrete-struct wrappers would insulate us further; deferred until upstream signals instability.

🤖 Generated with Claude Code

QuantIntellect and others added 10 commits April 29, 2026 14:40
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…eTimeout

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…pass

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants