mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
Merge main into codex/tempdir-media-spooling
This commit is contained in:
commit
5342be16d9
32 files changed
+1709
-46
No files matched your search
@@ -0,0 +1,62 @@
|
||||
# Extension browser host configuration
|
||||
|
||||
The downloader wraps each provider with the extension's stable identity before
|
||||
constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are
|
||||
host-only fields. `parseOpenOptions` never reads them from extension JavaScript.
|
||||
The extension API is unchanged. The Flutter host uses the fork's public
|
||||
`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped
|
||||
`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge.
|
||||
Only the Gopeed extension JavaScript boundary keeps these options host-only.
|
||||
|
||||
Profiles are persistent and shared across invocations/pages of the same extension.
|
||||
Different identities use different native stores. Go providers use a SHA-256-based
|
||||
path under `<StorageDir>/webview`; Apple RPC hosts use a stable UUID data store;
|
||||
Android uses a named WebView profile. Cookie operations select that same store,
|
||||
including `clearCookies`. Closing an execution closes its pages, not its profile.
|
||||
Uninstall closes the extension's pages, prevents its old runtimes from opening
|
||||
new pages, and removes its native profile before deleting the extension record.
|
||||
Cleanup failures abort uninstall and can be retried. Reinstallation starts with
|
||||
empty browser data; an ordinary extension update keeps the existing profile.
|
||||
Windows/Linux delete the profile directory; Apple removes the named WebKit store.
|
||||
Android cannot delete a profile already loaded by the current process: when
|
||||
DELETE_BROWSING_DATA is supported it clears all site data immediately, and records
|
||||
an empty-profile deletion in SharedPreferences for the next process start. A
|
||||
reinstallation may reuse that cleared profile and cancels the pending deletion.
|
||||
If the full-data clearing API is unavailable, uninstall fails with a restart
|
||||
instruction; the pending profile is deleted at startup before it can be loaded.
|
||||
No removal operation is exposed through extension JavaScript.
|
||||
|
||||
Existing shared browser data is not copied into every extension's new profile.
|
||||
|
||||
The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5
|
||||
listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP
|
||||
CONNECT configuration may bypass plain HTTP. The forwarder reads the current
|
||||
Gopeed proxy config for each new request/tunnel and supports direct, system,
|
||||
HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5.
|
||||
Credentials remain in Go and are never sent over the WebView RPC.
|
||||
|
||||
Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their
|
||||
route until closed; opening another page in the same profile does not reset it.
|
||||
SOCKS carries host/port rather than a full URL: system proxy/PAC selection for
|
||||
Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS.
|
||||
Path-dependent PAC rules cannot be reproduced from a SOCKS connection.
|
||||
|
||||
Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE.
|
||||
Unsupported hosts fail explicitly instead of silently sharing browser storage or
|
||||
ignoring the proxy.
|
||||
|
||||
## Validation
|
||||
|
||||
- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview`
|
||||
- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'`
|
||||
- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`).
|
||||
- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`.
|
||||
- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`).
|
||||
|
||||
The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages,
|
||||
HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening,
|
||||
and removal without affecting another profile. HTTPS routing is checked with a
|
||||
rejecting upstream tunnel; no test-only certificate bypass is added to the app.
|
||||
Go proxy unit tests separately cover successful TLS forwarding and authentication.
|
||||
These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs
|
||||
compile the Android/iOS bridge. No separate Flutter test application is required.
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build linux && cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#include <gtk/gtk.h>
|
||||
static void pumpGTK(void) {
|
||||
// Bound each batch so Go creation/removal requests cannot be starved.
|
||||
for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i)
|
||||
g_main_context_iteration(NULL, FALSE);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var gtkThreadOnce sync.Once
|
||||
var gtkThreadTasks = make(chan func())
|
||||
|
||||
func RunMainThreadLoop(run func() int) int { return run() }
|
||||
|
||||
func postMainThreadTask(task func()) bool {
|
||||
gtkThreadOnce.Do(func() {
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
// WebKit must always use its original UI thread, including after
|
||||
// the last page closes. A page cannot stop this shared event loop.
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case task := <-gtkThreadTasks:
|
||||
task()
|
||||
case <-ticker.C:
|
||||
}
|
||||
C.pumpGTK()
|
||||
}
|
||||
}()
|
||||
})
|
||||
gtkThreadTasks <- task
|
||||
return true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && (!linux || !cgo)
|
||||
|
||||
package goprovider
|
||||
|
||||
|
||||
@@ -105,7 +105,15 @@ func (p *pageWrapper) start() error {
|
||||
defer runtimepkg.UnlockOSThread()
|
||||
|
||||
var w webview.WebView
|
||||
if p.opts.Headless {
|
||||
if p.opts.DataPath != "" {
|
||||
var err error
|
||||
w, err = webview.NewWithOptions(webview.Options{Debug: p.opts.Debug, Headless: p.opts.Headless, DataPath: p.opts.DataPath, ProxyURL: p.opts.ProxyURL})
|
||||
if err != nil {
|
||||
p.ready <- err
|
||||
close(p.done)
|
||||
return
|
||||
}
|
||||
} else if p.opts.Headless {
|
||||
w = webview.NewHeadless(p.opts.Debug)
|
||||
} else {
|
||||
w = webview.New(p.opts.Debug)
|
||||
@@ -127,12 +135,18 @@ func (p *pageWrapper) start() error {
|
||||
if err := w.Bind(p.callbackName, func(payload string) error {
|
||||
return p.handleCallback(payload)
|
||||
}); err != nil {
|
||||
w.Destroy()
|
||||
p.ready <- err
|
||||
close(p.done)
|
||||
return
|
||||
}
|
||||
|
||||
applyWindowOptions(w, p.opts)
|
||||
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
|
||||
if runtimepkg.GOOS == "linux" {
|
||||
// All GTK pages share the process-wide event loop below.
|
||||
return
|
||||
}
|
||||
w.Run()
|
||||
w.Destroy()
|
||||
close(p.done)
|
||||
@@ -318,7 +332,12 @@ func (p *pageWrapper) Close() error {
|
||||
// the quit message lands on the correct queue.
|
||||
terminateDone := make(chan struct{})
|
||||
w.Dispatch(func() {
|
||||
w.Terminate()
|
||||
if runtimepkg.GOOS == "linux" {
|
||||
w.Destroy()
|
||||
close(p.done)
|
||||
} else {
|
||||
w.Terminate()
|
||||
}
|
||||
close(terminateDone)
|
||||
})
|
||||
<-terminateDone
|
||||
@@ -610,3 +629,12 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Provider) RemoveProfile(profileID, dataPath string) error {
|
||||
done := make(chan error, 1)
|
||||
run := func() { done <- webview.RemoveProfile(dataPath) }
|
||||
if !postMainThreadTask(run) {
|
||||
run()
|
||||
}
|
||||
return <-done
|
||||
}
|
||||
@@ -13,3 +13,5 @@ func TestProviderContract(t *testing.T) {
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
})
|
||||
}
|
||||
|
||||
func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) }
|
||||
@@ -1,10 +1,14 @@
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -219,7 +223,19 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
runtime := enginewebview.NewRuntime(provider, true)
|
||||
proxy, err := webviewproxy.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { proxy.Close() })
|
||||
path := filepath.Join(t.TempDir(), "profile")
|
||||
sum := sha256.Sum256([]byte(path))
|
||||
profileID := fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
|
||||
proxyURL := proxy.URL()
|
||||
if runtime.GOOS == "darwin" {
|
||||
proxyURL = proxy.SOCKSURL()
|
||||
}
|
||||
runtime := enginewebview.NewRuntime(&configuredOpener{provider: provider, profileID: profileID, dataPath: path, proxyURL: proxyURL}, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": opts.Headless,
|
||||
"debug": opts.Debug,
|
||||
@@ -232,7 +248,14 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv
|
||||
t.Fatalf("failed to open webview: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = runtime.Close()
|
||||
if err := runtime.Close(); err != nil {
|
||||
t.Errorf("close contract page: %v", err)
|
||||
}
|
||||
if remover, ok := provider.(enginewebview.ProfileRemover); ok {
|
||||
if err := remover.RemoveProfile(profileID, path); err != nil {
|
||||
t.Errorf("remove contract profile: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return page, server, server.URL
|
||||
@@ -380,3 +403,15 @@ func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type configuredOpener struct {
|
||||
provider enginewebview.Provider
|
||||
profileID, dataPath, proxyURL string
|
||||
}
|
||||
|
||||
func (o *configuredOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
opts.ProfileID = o.profileID
|
||||
opts.DataPath = o.dataPath
|
||||
opts.ProxyURL = o.proxyURL
|
||||
return o.provider.Open(opts)
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
|
||||
webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
// RunProfileContract exercises the same host API through both native and RPC
|
||||
// providers. It never bypasses the host bridge with direct Flutter plugin calls.
|
||||
func RunProfileContract(t *testing.T, provider webview.Provider) {
|
||||
t.Helper()
|
||||
waitForProviderAvailable(t, provider, defaultAvailabilityTimeout)
|
||||
remover, ok := provider.(webview.ProfileRemover)
|
||||
if !ok {
|
||||
t.Fatal("provider does not implement host profile removal")
|
||||
}
|
||||
proxyURL, secureHits := profileProxy(t)
|
||||
a := newContractProfile(t, provider, remover, proxyURL)
|
||||
b := newContractProfile(t, provider, remover, proxyURL)
|
||||
first := a.open(t)
|
||||
assertProfileValue(t, first, `async () => { localStorage.setItem('owner','a'); return 'a'; }`, "a")
|
||||
assertProfileValue(t, first, profileDatabaseScript(true), "saved")
|
||||
if err := first.SetCookie(webview.Cookie{Name: "session", Value: "a", Domain: "profiles.invalid", Path: "/", HTTPOnly: true, Expires: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := b.open(t)
|
||||
assertProfileValue(t, second, `() => localStorage.getItem('owner')`, nil)
|
||||
assertProfileValue(t, second, profileDatabaseScript(false), nil)
|
||||
assertProfileCookies(t, second, "", "new profile B")
|
||||
assertProfileValue(t, second, `() => { localStorage.setItem('owner','b'); return 'b'; }`, "b")
|
||||
if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared := a.open(t)
|
||||
assertProfileValue(t, shared, `() => localStorage.getItem('owner')`, "a")
|
||||
assertProfileValue(t, shared, profileDatabaseScript(false), "saved")
|
||||
assertProfileCookies(t, shared, "a", "shared profile A")
|
||||
storedCookies, err := shared.GetCookies()
|
||||
if err != nil || len(storedCookies) != 1 || storedCookies[0].Expires.Before(time.Now()) {
|
||||
t.Fatalf("persistent cookie lost its expiration: %#v, error %v", storedCookies, err)
|
||||
}
|
||||
assertProfileValue(t, shared, `() => document.cookie`, "") // HttpOnly stays hidden.
|
||||
if err := second.ClearCookies(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertProfileCookies(t, first, "a", "clearing B leaves A intact")
|
||||
if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.close(t)
|
||||
reopened := a.open(t)
|
||||
assertProfileValue(t, reopened, `() => localStorage.getItem('owner')`, "a")
|
||||
assertProfileValue(t, reopened, profileDatabaseScript(false), "saved")
|
||||
assertProfileCookies(t, reopened, "a", "reopened profile A")
|
||||
// Include a session cookie immediately before deletion to catch stale
|
||||
// in-process CookieStore caches when the same profile is recreated.
|
||||
if err := reopened.SetCookie(webview.Cookie{Name: "session", Value: "remove-me", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.close(t)
|
||||
a.remove(t)
|
||||
a.remove(t) // Retrying a successful uninstall is harmless.
|
||||
reinstalled := a.open(t)
|
||||
assertProfileValue(t, reinstalled, `() => localStorage.getItem('owner')`, nil)
|
||||
assertProfileValue(t, reinstalled, profileDatabaseScript(false), nil)
|
||||
assertProfileCookies(t, reinstalled, "", "reinstalled profile A")
|
||||
assertProfileValue(t, second, `() => localStorage.getItem('owner')`, "b")
|
||||
assertProfileCookies(t, second, "b", "B after removing A")
|
||||
// Refuse the HTTPS tunnel deliberately. Seeing it at our proxy proves HTTPS
|
||||
// routing without adding a certificate-validation bypass to the test host.
|
||||
// WebView2 may commit its built-in error page and report navigation as
|
||||
// complete. The invariant here is that the HTTPS tunnel reaches the proxy.
|
||||
_ = reinstalled.Goto("https://profiles.invalid/", webview.GotoOptions{TimeoutMS: 5000})
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for secureHits.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if secureHits.Load() == 0 {
|
||||
t.Fatal("HTTPS bypassed the host proxy")
|
||||
}
|
||||
}
|
||||
|
||||
type contractProfile struct {
|
||||
provider webview.Provider
|
||||
remover webview.ProfileRemover
|
||||
options webview.OpenOptions
|
||||
pages []webview.Page
|
||||
}
|
||||
|
||||
func newContractProfile(t *testing.T, provider webview.Provider, remover webview.ProfileRemover, proxyURL string) *contractProfile {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "profile")
|
||||
hash := sha256.Sum256([]byte(path))
|
||||
id := fmt.Sprintf("%x-%x-%x-%x-%x", hash[:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16])
|
||||
p := &contractProfile{provider: provider, remover: remover, options: webview.OpenOptions{ProfileID: id, DataPath: path, ProxyURL: proxyURL, Headless: true, Title: "Gopeed profile contract"}}
|
||||
t.Cleanup(func() { p.close(t); p.remove(t) })
|
||||
return p
|
||||
}
|
||||
func (p *contractProfile) open(t *testing.T) webview.Page {
|
||||
t.Helper()
|
||||
page, err := p.provider.Open(p.options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.pages = append(p.pages, page)
|
||||
if err := page.Goto("http://profiles.invalid/", webview.GotoOptions{TimeoutMS: 10000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return page
|
||||
}
|
||||
func (p *contractProfile) close(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, page := range p.pages {
|
||||
if err := page.Close(); err != nil {
|
||||
t.Errorf("close profile page: %v", err)
|
||||
}
|
||||
}
|
||||
p.pages = nil
|
||||
}
|
||||
func (p *contractProfile) remove(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := p.remover.RemoveProfile(p.options.ProfileID, p.options.DataPath); err != nil {
|
||||
t.Errorf("remove profile: %v", err)
|
||||
}
|
||||
}
|
||||
func assertProfileValue(t *testing.T, page webview.Page, script string, want any) {
|
||||
t.Helper()
|
||||
got, err := page.Execute(script)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("script %s: got %#v, want %#v", script, got, want)
|
||||
}
|
||||
}
|
||||
func assertProfileCookies(t *testing.T, page webview.Page, want, stage string) {
|
||||
t.Helper()
|
||||
// Native cookie managers propagate updates across browser processes
|
||||
// asynchronously. Wait for the expected state rather than assuming the
|
||||
// first IPC read already includes the preceding write.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", stage, err)
|
||||
}
|
||||
matches := len(cookies) == 0 && want == "" || len(cookies) == 1 && want != "" && cookies[0].Name == "session" && cookies[0].Value == want && cookies[0].HTTPOnly
|
||||
if matches {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s: cookies %#v, want %q", stage, cookies, want)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
func profileDatabaseScript(write bool) string {
|
||||
operation := "store.get('owner')"
|
||||
if write {
|
||||
operation = "store.put('saved','owner')"
|
||||
}
|
||||
return fmt.Sprintf(`async () => await new Promise((resolve,reject)=>{
|
||||
const request=indexedDB.open('profile-contract',1);
|
||||
request.onupgradeneeded=()=>request.result.createObjectStore('state');
|
||||
request.onerror=()=>reject(String(request.error));
|
||||
request.onsuccess=()=>{
|
||||
const db=request.result, tx=db.transaction('state','readwrite'), store=tx.objectStore('state');
|
||||
const op=%s; let value;
|
||||
op.onsuccess=()=>value=%s;
|
||||
tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value)};
|
||||
tx.onerror=()=>{db.close();reject(String(tx.error))};
|
||||
};
|
||||
})`, operation, map[bool]string{true: "'saved'", false: "op.result"}[write])
|
||||
}
|
||||
|
||||
func profileProxy(t *testing.T) (string, *atomic.Int32) {
|
||||
t.Helper()
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fmt.Fprint(w, "<!doctype html><title>Profile contract</title>")
|
||||
}))
|
||||
t.Cleanup(origin.Close)
|
||||
secureHits := &atomic.Int32{}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect {
|
||||
origin.Config.Handler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if r.Host == "profiles.invalid:443" {
|
||||
secureHits.Add(1)
|
||||
http.Error(w, "HTTPS routing fixture rejects tunnels", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if r.Host != "profiles.invalid:80" {
|
||||
http.Error(w, "unexpected target", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
remote, err := net.DialTimeout("tcp", origin.Listener.Addr().String(), 5*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, "origin unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
client, buffer, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := buffer.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
go func() { io.Copy(remote, buffer); remote.Close() }()
|
||||
io.Copy(client, remote)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
upstreamURL, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy, err := webviewproxy.New(func(*http.Request) (*url.URL, error) { return upstreamURL, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { proxy.Close() })
|
||||
if runtime.GOOS == "darwin" {
|
||||
return proxy.SOCKSURL(), secureHits
|
||||
}
|
||||
return proxy.URL(), secureHits
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Package proxy provides the host-owned WebView forward proxy. Browser engines
|
||||
// always use this endpoint; Gopeed decides the upstream route for each request.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/armon/go-socks5"
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type Selector func(*http.Request) (*url.URL, error)
|
||||
|
||||
type Server struct {
|
||||
listener net.Listener
|
||||
socksListener net.Listener
|
||||
server *http.Server
|
||||
transport *http.Transport
|
||||
selectProxy Selector
|
||||
mu sync.Mutex
|
||||
connections map[io.Closer]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(selectProxy Selector) (*Server, error) {
|
||||
if selectProxy == nil {
|
||||
selectProxy = func(*http.Request) (*url.URL, error) { return nil, nil }
|
||||
}
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{listener: listener, selectProxy: selectProxy, connections: make(map[io.Closer]struct{})}
|
||||
s.transport = &http.Transport{Proxy: selectProxy, DisableKeepAlives: true, TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 30 * time.Second}
|
||||
s.server = &http.Server{Handler: s, ReadHeaderTimeout: 15 * time.Second}
|
||||
s.socksListener, err = net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
socks, err := socks5.New(&socks5.Config{Logger: log.New(io.Discard, "", 0), Resolver: unresolvedDNS{}, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
_, port, _ := net.SplitHostPort(address)
|
||||
scheme := "https"
|
||||
if port == "80" {
|
||||
scheme = "http"
|
||||
}
|
||||
request := &http.Request{URL: &url.URL{Scheme: scheme, Host: address}, Header: make(http.Header)}
|
||||
upstream, err := selectProxy(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timed, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
conn, err := dialTunnel(timed, address, upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.track(conn)
|
||||
}})
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
s.socksListener.Close()
|
||||
return nil, err
|
||||
}
|
||||
go s.server.Serve(listener)
|
||||
go socks.Serve(&trackedListener{Listener: s.socksListener, server: s})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) URL() string { return "http://" + s.listener.Addr().String() }
|
||||
|
||||
func (s *Server) SOCKSURL() string { return "socks5://" + s.socksListener.Addr().String() }
|
||||
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
connections := make([]io.Closer, 0, len(s.connections))
|
||||
for c := range s.connections {
|
||||
connections = append(connections, c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, c := range connections {
|
||||
c.Close()
|
||||
}
|
||||
s.socksListener.Close()
|
||||
s.transport.CloseIdleConnections()
|
||||
return s.server.Close()
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
s.connect(w, r)
|
||||
return
|
||||
}
|
||||
if !r.URL.IsAbs() || (r.URL.Scheme != "http" && r.URL.Scheme != "https") {
|
||||
http.Error(w, "absolute HTTP URL required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out := r.Clone(r.Context())
|
||||
out.RequestURI = ""
|
||||
upgrade := out.Header.Get("Upgrade")
|
||||
stripHopHeaders(out.Header)
|
||||
if upgrade != "" {
|
||||
out.Header.Set("Connection", "Upgrade")
|
||||
out.Header.Set("Upgrade", upgrade)
|
||||
}
|
||||
resp, err := s.transport.RoundTrip(out)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusSwitchingProtocols {
|
||||
remote, ok := resp.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
http.Error(w, "invalid upstream upgrade", 502)
|
||||
return
|
||||
}
|
||||
client, buffer, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
resp.Body = nil
|
||||
if err := resp.Write(buffer); err != nil {
|
||||
return
|
||||
}
|
||||
if err := buffer.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
s.bridge(client, buffer, remote)
|
||||
return
|
||||
}
|
||||
stripHopHeaders(resp.Header)
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func stripHopHeaders(h http.Header) {
|
||||
for _, key := range strings.Split(h.Get("Connection"), ",") {
|
||||
h.Del(strings.TrimSpace(key))
|
||||
}
|
||||
for _, key := range []string{"Connection", "Proxy-Connection", "Proxy-Authorization", "Proxy-Authenticate", "Keep-Alive", "TE", "Trailer", "Transfer-Encoding", "Upgrade"} {
|
||||
h.Del(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) connect(w http.ResponseWriter, r *http.Request) {
|
||||
if _, _, err := net.SplitHostPort(r.Host); err != nil {
|
||||
http.Error(w, "invalid tunnel destination", 400)
|
||||
return
|
||||
}
|
||||
target := r.Clone(r.Context())
|
||||
target.URL = &url.URL{Scheme: "https", Host: r.Host}
|
||||
upstream, err := s.selectProxy(target)
|
||||
if err != nil {
|
||||
http.Error(w, "proxy selection failed", 502)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
remote, err := dialTunnel(ctx, r.Host, upstream)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream connection failed", 502)
|
||||
return
|
||||
}
|
||||
defer remote.Close()
|
||||
client, buffer, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := buffer.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
s.bridge(client, buffer, remote)
|
||||
}
|
||||
|
||||
func (s *Server) bridge(client net.Conn, buffer *bufio.ReadWriter, remote io.ReadWriteCloser) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.connections[client] = struct{}{}
|
||||
s.connections[remote] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
defer func() { s.mu.Lock(); delete(s.connections, client); delete(s.connections, remote); s.mu.Unlock() }()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { io.Copy(remote, buffer); remote.Close(); close(done) }()
|
||||
io.Copy(client, remote)
|
||||
client.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) }
|
||||
|
||||
func dialTunnel(ctx context.Context, target string, upstream *url.URL) (net.Conn, error) {
|
||||
dialer := &net.Dialer{}
|
||||
if upstream == nil {
|
||||
return dialer.DialContext(ctx, "tcp", target)
|
||||
}
|
||||
if upstream.Scheme == "socks5" || upstream.Scheme == "socks5h" {
|
||||
u := *upstream
|
||||
u.Scheme = "socks5"
|
||||
d, err := xproxy.FromURL(&u, dialer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.(xproxy.ContextDialer).DialContext(ctx, "tcp", target)
|
||||
}
|
||||
if upstream.Scheme != "http" && upstream.Scheme != "https" {
|
||||
return nil, errors.New("unsupported proxy scheme")
|
||||
}
|
||||
host := upstream.Host
|
||||
if upstream.Port() == "" {
|
||||
port := "80"
|
||||
if upstream.Scheme == "https" {
|
||||
port = "443"
|
||||
}
|
||||
host = net.JoinHostPort(upstream.Hostname(), port)
|
||||
}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
if deadline, exists := ctx.Deadline(); exists {
|
||||
conn.SetDeadline(deadline)
|
||||
}
|
||||
if upstream.Scheme == "https" {
|
||||
secure := tls.Client(conn, &tls.Config{ServerName: upstream.Hostname(), MinVersion: tls.VersionTLS12})
|
||||
if err := secure.HandshakeContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn = secure
|
||||
}
|
||||
request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
|
||||
if upstream.User != nil {
|
||||
password, _ := upstream.User.Password()
|
||||
request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(upstream.User.Username()+":"+password)))
|
||||
}
|
||||
if err := request.Write(conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
response, err := http.ReadResponse(reader, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("upstream CONNECT status %d", response.StatusCode)
|
||||
}
|
||||
conn.SetDeadline(time.Time{})
|
||||
ok = true
|
||||
return &bufferedConn{Conn: conn, reader: reader}, nil
|
||||
}
|
||||
|
||||
// Leave hostname resolution to the selected upstream (SOCKS remote DNS / CONNECT).
|
||||
type unresolvedDNS struct{}
|
||||
|
||||
func (unresolvedDNS) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
|
||||
return ctx, nil, nil
|
||||
}
|
||||
|
||||
type trackedListener struct {
|
||||
net.Listener
|
||||
server *Server
|
||||
}
|
||||
|
||||
func (l *trackedListener) Accept() (net.Conn, error) {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.server.track(c)
|
||||
}
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
server *Server
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
err := c.Conn.Close()
|
||||
c.server.mu.Lock()
|
||||
delete(c.server.connections, c)
|
||||
c.server.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
func (s *Server) track(c net.Conn) (net.Conn, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
c.Close()
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
tracked := &trackedConn{Conn: c, server: s}
|
||||
s.connections[tracked] = struct{}{}
|
||||
return tracked, nil
|
||||
}
|
||||
|
||||
func (c *trackedConn) CloseWrite() error { return closeWrite(c.Conn) }
|
||||
func (c *bufferedConn) CloseWrite() error { return closeWrite(c.Conn) }
|
||||
func closeWrite(c net.Conn) error {
|
||||
if writer, ok := c.(interface{ CloseWrite() error }); ok {
|
||||
return writer.CloseWrite()
|
||||
}
|
||||
return c.Close()
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/armon/go-socks5"
|
||||
)
|
||||
|
||||
func TestRejectedProxyRequests(t *testing.T) {
|
||||
secret := "upstream-password"
|
||||
for _, tc := range []struct {
|
||||
name, method, target string
|
||||
status int
|
||||
}{
|
||||
{"relative URL", "GET", "/relative", 400},
|
||||
{"unsupported URL", "GET", "ftp://example.com/file", 400},
|
||||
{"invalid CONNECT", "CONNECT", "example.com", 400},
|
||||
{"HTTP selection failure", "GET", "http://example.com/", 502},
|
||||
{"CONNECT selection failure", "CONNECT", "example.com:443", 502},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &Server{selectProxy: func(*http.Request) (*url.URL, error) { return nil, errors.New(secret) }}
|
||||
s.transport = &http.Transport{Proxy: s.selectProxy}
|
||||
defer s.transport.CloseIdleConnections()
|
||||
response := httptest.NewRecorder()
|
||||
s.ServeHTTP(response, httptest.NewRequest(tc.method, tc.target, nil))
|
||||
if response.Code != tc.status || strings.Contains(response.Body.String(), secret) {
|
||||
t.Fatalf("unexpected proxy rejection: %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelRejectsUnsafeUpstreams(t *testing.T) {
|
||||
rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "authentication required", http.StatusProxyAuthRequired)
|
||||
}))
|
||||
defer rejected.Close()
|
||||
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("untrusted TLS proxy received a request")
|
||||
}))
|
||||
defer secure.Close()
|
||||
for _, upstream := range []string{"ftp://127.0.0.1", rejected.URL, secure.URL} {
|
||||
t.Run(upstream, func(t *testing.T) {
|
||||
selected, err := url.Parse(upstream)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, err := dialTunnel(ctx, "example.com:443", selected)
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("unsafe upstream unexpectedly accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesAndAuthentication(t *testing.T) {
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Proxy-Authorization") != "" {
|
||||
t.Error("proxy credentials leaked to origin")
|
||||
}
|
||||
w.Write([]byte("origin"))
|
||||
}))
|
||||
defer origin.Close()
|
||||
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("secure")) }))
|
||||
defer secure.Close()
|
||||
upstream, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer upstream.Close()
|
||||
var authenticated atomic.Int32
|
||||
authProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" {
|
||||
http.Error(w, "authentication required", 407)
|
||||
return
|
||||
}
|
||||
authenticated.Add(1)
|
||||
upstream.ServeHTTP(w, r)
|
||||
}))
|
||||
defer authProxy.Close()
|
||||
socks, err := socks5.New(&socks5.Config{Credentials: socks5.StaticCredentials{"user": "pass"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go socks.Serve(listener)
|
||||
for _, route := range []string{"", authProxy.URL, "socks5://" + listener.Addr().String()} {
|
||||
t.Run(route, func(t *testing.T) {
|
||||
var selected *url.URL
|
||||
if route != "" {
|
||||
selected, _ = url.Parse(route)
|
||||
selected.User = url.UserPassword("user", "pass")
|
||||
}
|
||||
local, err := New(func(r *http.Request) (*url.URL, error) { return selected, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer local.Close()
|
||||
proxyURL, _ := url.Parse(local.URL())
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
|
||||
for _, test := range []struct{ url, want string }{{origin.URL, "origin"}, {secure.URL, "secure"}} {
|
||||
response, err := client.Get(test.url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if string(body) != test.want {
|
||||
t.Fatalf("got status %d, body %q", response.StatusCode, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if authenticated.Load() != 2 {
|
||||
t.Fatalf("expected authenticated HTTP and CONNECT, got %d", authenticated.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutingChangesAndFailureDoesNotBypassProxy(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1); w.Write([]byte("direct")) }))
|
||||
defer origin.Close()
|
||||
var useProxy atomic.Bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("proxied")) }))
|
||||
upstreamURL, _ := url.Parse(upstream.URL)
|
||||
local, err := New(func(*http.Request) (*url.URL, error) {
|
||||
if useProxy.Load() {
|
||||
return upstreamURL, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer local.Close()
|
||||
endpoint, _ := url.Parse(local.URL())
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
for _, want := range []string{"direct", "proxied", "upstream request failed\n"} {
|
||||
resp, err := client.Get(origin.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if string(body) != want {
|
||||
t.Fatalf("got %q want %q", body, want)
|
||||
}
|
||||
if want == "direct" {
|
||||
useProxy.Store(true)
|
||||
} else if want == "proxied" {
|
||||
upstream.Close()
|
||||
}
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatal("failed proxy must not fall back to direct")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTerminatesTunnels(t *testing.T) {
|
||||
echo, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer echo.Close()
|
||||
go func() {
|
||||
c, e := echo.Accept()
|
||||
if e == nil {
|
||||
defer c.Close()
|
||||
io.Copy(c, c)
|
||||
}
|
||||
}()
|
||||
s, err := New(func(*http.Request) (*url.URL, error) { return nil, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, _ := url.Parse(s.URL())
|
||||
c, err := dialTunnel(context.Background(), echo.Addr().String(), endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
c.Write([]byte("hello\n"))
|
||||
line, err := bufio.NewReader(c).ReadString('\n')
|
||||
if err != nil || line != "hello\n" {
|
||||
t.Fatalf("echo %q %v", line, err)
|
||||
}
|
||||
s.Close()
|
||||
if _, err := c.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("tunnel stayed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOCKSIngressPreservesRemoteDNS(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "CONNECT" || r.Host != "unresolved.invalid:80" {
|
||||
t.Errorf("lost destination: %s %s", r.Method, r.Host)
|
||||
}
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
b.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
b.Flush()
|
||||
request, err := http.ReadRequest(b.Reader)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
request.Body.Close()
|
||||
b.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\nproxied")
|
||||
b.Flush()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
target, _ := url.Parse(upstream.URL)
|
||||
local, err := New(func(r *http.Request) (*url.URL, error) {
|
||||
if r.URL.Host != "unresolved.invalid:80" {
|
||||
t.Errorf("selector lost hostname: %s", r.URL)
|
||||
}
|
||||
return target, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer local.Close()
|
||||
endpoint, _ := url.Parse(local.SOCKSURL())
|
||||
transport := &http.Transport{Proxy: http.ProxyURL(endpoint)}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
|
||||
response, err := client.Get("http://unresolved.invalid/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if string(body) != "proxied" {
|
||||
t.Fatalf("unexpected body %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPUpgradeIsForwarded(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Upgrade") != "fixture" {
|
||||
http.Error(w, "upgrade lost", 400)
|
||||
return
|
||||
}
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
b.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n")
|
||||
b.Flush()
|
||||
line, err := b.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.Flush()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
local, err := New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer local.Close()
|
||||
endpoint, _ := url.Parse(local.URL())
|
||||
c, err := net.Dial("tcp", endpoint.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
io.WriteString(c, "GET "+upstream.URL+"/ HTTP/1.1\r\nHost: fixture\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n")
|
||||
reader := bufio.NewReader(c)
|
||||
response, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", response.Status)
|
||||
}
|
||||
io.WriteString(c, "hello\n")
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil || line != "hello\n" {
|
||||
t.Fatalf("upgraded echo %q %v", line, err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# rpcprovider
|
||||
|
||||
The private Flutter native profile bridge is maintained in the
|
||||
[GopeedLab flutter_inappwebview fork](https://github.com/GopeedLab/flutter_inappwebview/tree/codex/gopeed-profiles).
|
||||
All three platform overrides pin one commit in `ui/flutter/pubspec.yaml`;
|
||||
see the fork’s [GOPEED.md](https://github.com/GopeedLab/flutter_inappwebview/blob/codex/gopeed-profiles/GOPEED.md)
|
||||
for patch scope and upstream synchronization.
|
||||
|
||||
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
|
||||
|
||||
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -109,9 +110,14 @@ func (p *page) ClearCookies() error {
|
||||
}
|
||||
|
||||
func (p *page) Close() error {
|
||||
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||
err := p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
var rpcErr *enginewebview.RPCError
|
||||
if errors.As(err, &rpcErr) && rpcErr.Code == enginewebview.ErrorCodePageNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -193,3 +199,7 @@ func (c *Client) Call(method string, params any, result any) error {
|
||||
}
|
||||
return json.Unmarshal(body.Result, result)
|
||||
}
|
||||
|
||||
func (p *Provider) RemoveProfile(profileID, dataPath string) error {
|
||||
return p.client.Call(enginewebview.MethodProfileRemove, enginewebview.ProfileRemoveParams{ProfileID: profileID}, nil)
|
||||
}
|
||||
@@ -37,3 +37,8 @@ func defaultWebViewRPCAddress() string {
|
||||
}
|
||||
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
|
||||
}
|
||||
|
||||
func TestProviderProfiles(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken})
|
||||
integrationtest.RunProfileContract(t, provider)
|
||||
}
|
||||
@@ -184,3 +184,29 @@ func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
|
||||
t.Fatalf("expected expires in payload: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProfileUsesPrivateHostRPC(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Method string `json:"method"`
|
||||
Params map[string]string `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if request.Method != enginewebview.MethodProfileRemove || request.Params["profileId"] != "extension-profile" || len(request.Params) != 1 {
|
||||
t.Errorf("unexpected profile removal request: %+v", request)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Error("missing host authentication")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":{"code":"PROFILE_REMOVE_FAILED","message":"store busy"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
provider := New(enginewebview.RPCConfig{Network: "tcp", Address: strings.TrimPrefix(server.URL, "http://"), Token: "secret"})
|
||||
err := provider.(enginewebview.ProfileRemover).RemoveProfile("extension-profile", "/host-only/path")
|
||||
if err == nil || err.Error() != "store busy" {
|
||||
t.Fatalf("cleanup failure was lost: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user