diff --git a/.github/workflows/bridge-proxy.yml b/.github/workflows/bridge-proxy.yml new file mode 100644 index 0000000..74b2ca6 --- /dev/null +++ b/.github/workflows/bridge-proxy.yml @@ -0,0 +1,80 @@ +name: Build Bridge Proxy + +on: + push: + branches: [main, master] + paths: + - bridge-proxy/** + - .github/workflows/bridge-proxy.yml + pull_request: + branches: [main, master] + paths: + - bridge-proxy/** + - .github/workflows/bridge-proxy.yml + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/bridge-proxy + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + defaults: + run: + working-directory: bridge-proxy + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: bridge-proxy/go.mod + cache-dependency-path: bridge-proxy/go.mod + + - name: Test + run: go test ./... + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./bridge-proxy + file: ./bridge-proxy/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/bridge-proxy/Dockerfile b/bridge-proxy/Dockerfile new file mode 100644 index 0000000..e5eb7d5 --- /dev/null +++ b/bridge-proxy/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY go.mod ./ +COPY *.go ./ +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bridge-proxy . + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /bridge-proxy /usr/local/bin/bridge-proxy +ENV ADDR=:8080 +EXPOSE 8080 +ENTRYPOINT ["bridge-proxy"] diff --git a/bridge-proxy/README.md b/bridge-proxy/README.md new file mode 100644 index 0000000..5bb9284 --- /dev/null +++ b/bridge-proxy/README.md @@ -0,0 +1,24 @@ +# Bridge Proxy + +Small Go transparent proxy for subscription links blocked by client IP region. + +## Run + +```bash +BRIDGE_TOKEN=your-token go run . +``` + +Request format: + +```text +http://host:8080/your-token?url=https%3A%2F%2Fsub.sslinks.co.in%2Fem9knZQ6ximoi9hhnKzJT3FayVZSb2PxKmPWzhzk%3Ftoken%3Da8483f280b990786fe607e04d2724dd2 +``` + +The service forwards the request to `url`, returns the upstream status, response headers, and body, and strips client IP forwarding headers before sending the upstream request. + +## Docker + +```bash +docker build -t bridge-proxy . +docker run --rm -p 8080:8080 -e BRIDGE_TOKEN=your-token bridge-proxy +``` diff --git a/bridge-proxy/go.mod b/bridge-proxy/go.mod new file mode 100644 index 0000000..348c947 --- /dev/null +++ b/bridge-proxy/go.mod @@ -0,0 +1,3 @@ +module bridge-proxy + +go 1.26.4 diff --git a/bridge-proxy/main.go b/bridge-proxy/main.go new file mode 100644 index 0000000..2ba97ff --- /dev/null +++ b/bridge-proxy/main.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +const maxBodyBytes = 64 << 20 + +var hopHeaders = map[string]struct{}{ + "Connection": {}, + "Keep-Alive": {}, + "Proxy-Authenticate": {}, + "Proxy-Authorization": {}, + "Te": {}, + "Trailer": {}, + "Transfer-Encoding": {}, + "Upgrade": {}, +} + +var clientIPHeaders = map[string]struct{}{ + "Cf-Connecting-Ip": {}, + "Forwarded": {}, + "X-Forwarded-For": {}, + "X-Real-Ip": {}, +} + +type proxy struct { + tokenHash [32]byte + client *http.Client +} + +func main() { + token := os.Getenv("BRIDGE_TOKEN") + if token == "" { + log.Fatal("BRIDGE_TOKEN is required") + } + + addr := env("ADDR", ":8080") + p := &proxy{ + tokenHash: sha256.Sum256([]byte(token)), + client: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + DisableCompression: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + }, + }, + } + + server := &http.Server{ + Addr: addr, + Handler: p, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("bridge proxy listening on %s", addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + +func (p *proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + return + } + + if !p.validToken(strings.Trim(r.URL.Path, "/")) { + http.Error(w, "not found", http.StatusNotFound) + return + } + + target, err := parseTarget(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := p.fetch(r, target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + copyHeader(w.Header(), resp.Header) + if resp.ContentLength >= 0 { + w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func (p *proxy) validToken(got string) bool { + gotHash := sha256.Sum256([]byte(got)) + return subtle.ConstantTimeCompare(gotHash[:], p.tokenHash[:]) == 1 +} + +func parseTarget(r *http.Request) (string, error) { + target := r.URL.Query().Get("url") + if target == "" { + return "", errors.New("missing url") + } + + u, err := url.Parse(target) + if err != nil || u.Host == "" { + return "", errors.New("invalid url") + } + if u.Scheme != "http" && u.Scheme != "https" { + return "", errors.New("url must start with http:// or https://") + } + + return u.String(), nil +} + +func (p *proxy) fetch(in *http.Request, target string) (*http.Response, error) { + body := http.MaxBytesReader(nil, in.Body, maxBodyBytes) + out, err := http.NewRequestWithContext(in.Context(), in.Method, target, body) + if err != nil { + return nil, fmt.Errorf("create upstream request: %w", err) + } + + copyHeader(out.Header, in.Header) + stripRequestHeaders(out.Header) + + return p.client.Do(out) +} + +func copyHeader(dst, src http.Header) { + for key, values := range src { + if _, ok := hopHeaders[http.CanonicalHeaderKey(key)]; ok { + continue + } + for _, value := range values { + dst.Add(key, value) + } + } +} + +func stripRequestHeaders(h http.Header) { + for key := range hopHeaders { + h.Del(key) + } + for key := range clientIPHeaders { + h.Del(key) + } +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/bridge-proxy/main_test.go b/bridge-proxy/main_test.go new file mode 100644 index 0000000..c381812 --- /dev/null +++ b/bridge-proxy/main_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "crypto/sha256" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestProxyForwardsResponseAndStripsClientIPHeaders(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Forwarded-For"); got != "" { + t.Fatalf("forwarded client IP header leaked: %q", got) + } + w.Header().Set("Subscription-Userinfo", "upload=1; download=2; total=3") + w.Header().Add("Set-Cookie", "a=b") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("raw-subscription")) + })) + defer upstream.Close() + + p := &proxy{ + tokenHash: sha256ForTest("secret"), + client: upstream.Client(), + } + + req := httptest.NewRequest(http.MethodGet, "/secret?url="+url.QueryEscape(upstream.URL+"/sub?token=abc"), nil) + req.Header.Set("X-Forwarded-For", "1.2.3.4") + rec := httptest.NewRecorder() + + p.ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d", rec.Code) + } + if got := rec.Body.String(); got != "raw-subscription" { + t.Fatalf("body = %q", got) + } + if got := rec.Header().Get("Subscription-Userinfo"); got == "" { + t.Fatal("missing upstream response header") + } + if got := rec.Header().Values("Set-Cookie"); len(got) != 1 || got[0] != "a=b" { + t.Fatalf("set-cookie = %#v", got) + } +} + +func TestRejectsBadToken(t *testing.T) { + p := &proxy{tokenHash: sha256ForTest("secret")} + req := httptest.NewRequest(http.MethodGet, "/bad?url=https://example.com", nil) + rec := httptest.NewRecorder() + + p.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d", rec.Code) + } +} + +func sha256ForTest(s string) [32]byte { + return sha256.Sum256([]byte(s)) +}