diff --git a/internal/netxlite/filtering/dns_test.go b/internal/netxlite/filtering/dns_test.go index 861bdf7..c5a0817 100644 --- a/internal/netxlite/filtering/dns_test.go +++ b/internal/netxlite/filtering/dns_test.go @@ -29,7 +29,7 @@ func TestDNSProxy(t *testing.T) { return r } - t.Run("DNSActionProxy with default proxy", func(t *testing.T) { + t.Run("DNSActionPass", func(t *testing.T) { ctx := context.Background() listener, done, err := newproxy(DNSActionPass) if err != nil { diff --git a/internal/netxlite/filtering/http.go b/internal/netxlite/filtering/http.go new file mode 100644 index 0000000..c39fe1b --- /dev/null +++ b/internal/netxlite/filtering/http.go @@ -0,0 +1,116 @@ +package filtering + +import ( + "net" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/ooni/probe-cli/v3/internal/runtimex" +) + +// HTTPAction is an HTTP filtering action that this proxy should take. +type HTTPAction string + +const ( + // HTTPActionPass passes the traffic to the destination. + HTTPActionPass = HTTPAction("pass") + + // HTTPActionReset resets the connection. + HTTPActionReset = HTTPAction("reset") + + // HTTPActionTimeout causes the connection to timeout. + HTTPActionTimeout = HTTPAction("timeout") + + // HTTPActionEOF causes the connection to EOF. + HTTPActionEOF = HTTPAction("eof") + + // HTTPAction451 causes the proxy to return a 451 error. + HTTPAction451 = HTTPAction("451") +) + +// HTTPProxy is a proxy that routes traffic depending on the +// host header and may implement filtering policies. +type HTTPProxy struct { + // OnIncomingHost is the MANDATORY hook called whenever we have + // successfully received an HTTP request. + OnIncomingHost func(host string) HTTPAction +} + +// Start starts the proxy. +func (p *HTTPProxy) Start(address string) (net.Listener, error) { + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, err + } + server := &http.Server{Handler: p} + go server.Serve(listener) + return listener, nil +} + +var httpBlockpage451 = []byte(`
+This content is not available in your jurisdiction.
+ +`) + +const httpProxyProduct = "jafar/0.1.0" + +// ServeHTTP serves HTTP requests +func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Implementation note: use Via header to detect in a loose way + // requests originated by us and directed to us. + if r.Header.Get("Via") == httpProxyProduct || r.Host == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + p.handle(w, r) +} + +func (p *HTTPProxy) handle(w http.ResponseWriter, r *http.Request) { + switch policy := p.OnIncomingHost(r.Host); policy { + case HTTPActionPass: + p.proxy(w, r) + case HTTPActionReset, HTTPActionTimeout, HTTPActionEOF: + p.hijack(w, r, policy) + case HTTPAction451: + w.WriteHeader(http.StatusUnavailableForLegalReasons) + w.Write(httpBlockpage451) + default: + w.WriteHeader(http.StatusInternalServerError) + } +} + +func (p *HTTPProxy) hijack(w http.ResponseWriter, r *http.Request, policy HTTPAction) { + // Note: + // + // 1. we assume we can hihack the connection + // + // 2. Hijack won't fail the first time it's invoked + hijacker := w.(http.Hijacker) + conn, _, err := hijacker.Hijack() + runtimex.PanicOnError(err, "hijacker.Hijack failed") + defer conn.Close() + switch policy { + case HTTPActionReset: + if tc, ok := conn.(*net.TCPConn); ok { + tc.SetLinger(0) + } + case HTTPActionTimeout: + <-r.Context().Done() + case HTTPActionEOF: + // nothing + } +} + +func (p *HTTPProxy) proxy(w http.ResponseWriter, r *http.Request) { + r.Header.Add("Via", httpProxyProduct) // see ServeHTTP + proxy := httputil.NewSingleHostReverseProxy(&url.URL{ + Host: r.Host, + Scheme: "http", + }) + proxy.Transport = http.DefaultTransport + proxy.ServeHTTP(w, r) +} diff --git a/internal/netxlite/filtering/http_test.go b/internal/netxlite/filtering/http_test.go new file mode 100644 index 0000000..025ee74 --- /dev/null +++ b/internal/netxlite/filtering/http_test.go @@ -0,0 +1,169 @@ +package filtering + +import ( + "context" + "net" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/apex/log" + "github.com/ooni/probe-cli/v3/internal/netxlite" + "github.com/ooni/probe-cli/v3/internal/runtimex" +) + +func TestHTTPProxy(t *testing.T) { + newproxy := func(action HTTPAction) (net.Listener, error) { + p := &HTTPProxy{ + OnIncomingHost: func(host string) HTTPAction { + return action + }, + } + return p.Start("127.0.0.1:0") + } + + httpGET := func(ctx context.Context, addr net.Addr, host string) (*http.Response, error) { + txp := netxlite.NewHTTPTransportStdlib(log.Log) + clnt := &http.Client{Transport: txp} + URL := &url.URL{ + Scheme: "http", + Host: addr.String(), + Path: "/", + } + req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil) + runtimex.PanicOnError(err, "http.NewRequest failed") + req.Host = host + return clnt.Do(req) + } + + t.Run("HTTPActionPass", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy(HTTPActionPass) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatal("unexpected status code", resp.StatusCode) + } + resp.Body.Close() + listener.Close() + }) + + t.Run("HTTPActionPass with self connect", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy(HTTPActionPass) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 400 { + t.Fatal("unexpected status code", resp.StatusCode) + } + resp.Body.Close() + listener.Close() + }) + + t.Run("HTTPActionReset", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy(HTTPActionReset) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err == nil || !strings.HasSuffix(err.Error(), netxlite.FailureConnectionReset) { + t.Fatal("unexpected err", err) + } + if resp != nil { + t.Fatal("expected nil resp") + } + listener.Close() + }) + + t.Run("HTTPActionTimeout", func(t *testing.T) { + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, 1*time.Second) + defer cancel() + listener, err := newproxy(HTTPActionTimeout) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err == nil || !strings.HasSuffix(err.Error(), "context deadline exceeded") { + t.Fatal("unexpected err", err) + } + if resp != nil { + t.Fatal("expected nil resp") + } + listener.Close() + }) + + t.Run("HTTPActionEOF", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy(HTTPActionEOF) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err == nil || !strings.HasSuffix(err.Error(), netxlite.FailureEOFError) { + t.Fatal("unexpected err", err) + } + if resp != nil { + t.Fatal("expected nil resp") + } + listener.Close() + }) + + t.Run("HTTPAction451", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy(HTTPAction451) + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 451 { + t.Fatal("unexpected status code", resp.StatusCode) + } + resp.Body.Close() + listener.Close() + }) + + t.Run("unknown action", func(t *testing.T) { + ctx := context.Background() + listener, err := newproxy("") + if err != nil { + t.Fatal(err) + } + resp, err := httpGET(ctx, listener.Addr(), "nexa.polito.it") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 500 { + t.Fatal("unexpected status code", resp.StatusCode) + } + resp.Body.Close() + listener.Close() + }) + + t.Run("Start fails on an invalid address", func(t *testing.T) { + p := &HTTPProxy{} + listener, err := p.Start("127.0.0.1") + if err == nil { + t.Fatal("expected an error") + } + if listener != nil { + t.Fatal("expected nil listener") + } + }) +} diff --git a/internal/netxlite/filtering/tls.go b/internal/netxlite/filtering/tls.go index 1811bb5..7bdc836 100644 --- a/internal/netxlite/filtering/tls.go +++ b/internal/netxlite/filtering/tls.go @@ -48,7 +48,6 @@ func (p *TLSProxy) Start(address string) (net.Listener, error) { return listener, err } -// Start starts the proxy. func (p *TLSProxy) start(address string) (net.Listener, <-chan interface{}, error) { listener, err := net.Listen("tcp", address) if err != nil { diff --git a/internal/netxlite/filtering/tls_test.go b/internal/netxlite/filtering/tls_test.go index 111460f..162010e 100644 --- a/internal/netxlite/filtering/tls_test.go +++ b/internal/netxlite/filtering/tls_test.go @@ -34,7 +34,7 @@ func TestTLSProxy(t *testing.T) { return tdx.DialTLSContext(ctx, "tcp", endpoint) } - t.Run("TLSActionProxy with default proxy", func(t *testing.T) { + t.Run("TLSActionPass", func(t *testing.T) { ctx := context.Background() listener, done, err := newproxy(TLSActionPass) if err != nil { @@ -159,7 +159,7 @@ func TestTLSProxy(t *testing.T) { <-done // wait for background goroutine to exit }) - t.Run("TLSActionProxy fails because we don't have SNI", func(t *testing.T) { + t.Run("TLSActionPass fails because we don't have SNI", func(t *testing.T) { ctx := context.Background() listener, done, err := newproxy(TLSActionPass) if err != nil { @@ -176,7 +176,7 @@ func TestTLSProxy(t *testing.T) { <-done // wait for background goroutine to exit }) - t.Run("TLSActionProxy fails because we can't dial", func(t *testing.T) { + t.Run("TLSActionPass fails because we can't dial", func(t *testing.T) { ctx := context.Background() listener, done, err := newproxy(TLSActionPass) if err != nil {