Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(netemx): adapt NetStackServerFactory from telegram #1222

Merged
merged 1 commit into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 8 additions & 76 deletions internal/experiment/telegram/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"sync"
"testing"

"github.com/apex/log"
Expand All @@ -18,7 +16,6 @@ import (
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/netemx"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)

func TestNewExperimentMeasurer(t *testing.T) {
Expand Down Expand Up @@ -279,82 +276,17 @@ func configureDNSWithDefaults(config *netem.DNSConfig) {
configureDNSWithAddr(config, telegramWebAddr)
}

// telegramHTTPServerNetStackServerFactory is a [netemx.NetStackServerFactory] that serves HTTP requests
// on the given addr and ports 443 and 80 as required by the telegram nettest
type telegramHTTPServerNetStackServerFactory struct{}

var _ netemx.NetStackServerFactory = &telegramHTTPServerNetStackServerFactory{}

// MustNewServer implements netemx.NetStackServerFactory.
func (f *telegramHTTPServerNetStackServerFactory) MustNewServer(stack *netem.UNetStack) netemx.NetStackServer {
return &telegramHTTPServerNetStackServer{
closers: []io.Closer{},
mu: sync.Mutex{},
unet: stack,
}
}

// telegramHTTPServerNetStackServer is a [netemx.NetStackServer] that serves HTTP requests
// on the given addr and ports 443 and 80 as required by the telegram nettest
type telegramHTTPServerNetStackServer struct {
closers []io.Closer
mu sync.Mutex
unet *netem.UNetStack
}

// Close implements netemx.NetStackServer.
func (nsh *telegramHTTPServerNetStackServer) Close() error {
// make the method locked as requested by the documentation
defer nsh.mu.Unlock()
nsh.mu.Lock()

// close each of the closers
for _, closer := range nsh.closers {
_ = closer.Close()
}

// be idempotent
nsh.closers = []io.Closer{}
return nil
}

// MustStart implements netemx.NetStackServer.
func (nsh *telegramHTTPServerNetStackServer) MustStart() {
// make the method locked as requested by the documentation
defer nsh.mu.Unlock()
nsh.mu.Lock()

// we create an empty mux, which should cause a 404 for each webpage, which seems what
// the servers used by telegram DC do as of 2023-07-11
mux := http.NewServeMux()

// listen on port 80
nsh.listenPort(nsh.unet, mux, 80)

// listen on port 443
nsh.listenPort(nsh.unet, mux, 443)
}

func (nsh *telegramHTTPServerNetStackServer) listenPort(stack *netem.UNetStack, mux *http.ServeMux, port uint16) {
// create the listening address
ipAddr := net.ParseIP(stack.IPAddress())
runtimex.Assert(ipAddr != nil, "expected valid IP address")
addr := &net.TCPAddr{IP: ipAddr, Port: int(port)}
listener := runtimex.Try1(stack.ListenTCP("tcp", addr))
srvr := &http.Server{Handler: mux}

// serve requests in a background goroutine
go srvr.Serve(listener)

// make sure we track the server (the .Serve method will close the
// listener once we close the server itself)
nsh.closers = append(nsh.closers, srvr)
}

// newQAEnvironment creates a QA environment for testing using the given addresses.
func newQAEnvironment(ipaddrs ...string) *netemx.QAEnv {
// create a single factory for handling all the requests
factory := &telegramHTTPServerNetStackServerFactory{}
factory := &netemx.HTTPCleartextServerFactory{
Factory: netemx.HTTPHandlerFactoryFunc(func() http.Handler {
// we create an empty mux, which should cause a 404 for each webpage, which seems what
// the servers used by telegram DC do as of 2023-07-11
return http.NewServeMux()
}),
Ports: []int{80, 443},
}

// create the options for constructing the env
var options []netemx.QAEnvOption
Expand Down
104 changes: 104 additions & 0 deletions internal/netemx/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package netemx

import (
"io"
"net"
"net/http"
"sync"

"github.com/ooni/netem"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)

// HTTPHandlerFactory constructs an [http.Handler].
type HTTPHandlerFactory interface {
NewHandler() http.Handler
}

// HTTPHandlerFactoryFunc allows a func to become an [HTTPHandlerFactory].
type HTTPHandlerFactoryFunc func() http.Handler

var _ HTTPHandlerFactory = HTTPHandlerFactoryFunc(nil)

// NewHandler implements HTTPHandlerFactory.
func (fx HTTPHandlerFactoryFunc) NewHandler() http.Handler {
return fx()
}

// HTTPCleartextServerFactory implements [NetStackServerFactory] for cleartext HTTP.
type HTTPCleartextServerFactory struct {
// Factory is the MANDATORY factory for creating the [http.Handler].
Factory HTTPHandlerFactory

// Ports is the MANDATORY list of ports where to listen.
Ports []int
}

var _ NetStackServerFactory = &HTTPCleartextServerFactory{}

// MustNewServer implements NetStackServerFactory.
func (f *HTTPCleartextServerFactory) MustNewServer(stack *netem.UNetStack) NetStackServer {
return &httpCleartextServer{
closers: []io.Closer{},
factory: f.Factory,
mu: sync.Mutex{},
ports: f.Ports,
unet: stack,
}
}

type httpCleartextServer struct {
closers []io.Closer
factory HTTPHandlerFactory
mu sync.Mutex
ports []int
unet *netem.UNetStack
}

// Close implements NetStackServer.
func (srv *httpCleartextServer) Close() error {
// make the method locked as requested by the documentation
defer srv.mu.Unlock()
srv.mu.Lock()

// close each of the closers
for _, closer := range srv.closers {
_ = closer.Close()
}

// be idempotent
srv.closers = []io.Closer{}
return nil
}

// MustStart implements NetStackServer.
func (srv *httpCleartextServer) MustStart() {
// make the method locked as requested by the documentation
defer srv.mu.Unlock()
srv.mu.Lock()

// create the handler
handler := srv.factory.NewHandler()

// create the listening address
ipAddr := net.ParseIP(srv.unet.IPAddress())
runtimex.Assert(ipAddr != nil, "expected valid IP address")

for _, port := range srv.ports {
srv.mustListenPortLocked(handler, ipAddr, port)
}
}

func (srv *httpCleartextServer) mustListenPortLocked(handler http.Handler, ipAddr net.IP, port int) {
// create the listening socket
addr := &net.TCPAddr{IP: ipAddr, Port: port}
listener := runtimex.Try1(srv.unet.ListenTCP("tcp", addr))

// serve requests in a background goroutine
srvr := &http.Server{Handler: handler}
go srvr.Serve(listener)

// make sure we track the server (the .Serve method will close the
// listener once we close the server itself)
srv.closers = append(srv.closers, srvr)
}
45 changes: 45 additions & 0 deletions internal/netemx/http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package netemx

import (
"net/http"
"testing"

"github.com/apex/log"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)

func TestHTTPCleartextServerFactory(t *testing.T) {
env := MustNewQAEnv(
QAEnvOptionNetStack(AddressWwwExampleCom, &HTTPCleartextServerFactory{
Factory: HTTPHandlerFactoryFunc(func() http.Handler {
return ExampleWebPageHandler()
}),
Ports: []int{80},
}),
)
defer env.Close()

env.AddRecordToAllResolvers("www.example.com", "", AddressWwwExampleCom)

env.Do(func() {
client := netxlite.NewHTTPClientStdlib(log.Log)
req := runtimex.Try1(http.NewRequest("GET", "http://www.example.com/", nil))
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatal("unexpected StatusCode", resp.StatusCode)
}
data, err := netxlite.ReadAllContext(req.Context(), resp.Body)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(ExampleWebPage, string(data)); diff != "" {
t.Fatal(diff)
}
})
}
64 changes: 35 additions & 29 deletions internal/netemx/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,40 +22,46 @@ const ExampleWebPage = `<!doctype html>
</html>
`

// ExampleWebPageHandlerFactory returns a webpage similar to example.org's one when the domain is
// www.example.{com,org} and redirects to www.example.{com,org} when it is example.{com,org}.
func ExampleWebPageHandlerFactory() QAEnvHTTPHandlerFactory {
return QAEnvHTTPHandlerFactoryFunc(func(_ netem.UnderlyingNetwork) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Alt-Svc", `h3=":443"`)
w.Header().Add("Date", "Thu, 24 Aug 2023 14:35:29 GMT")
// ExampleWebPageHandler returns a handler returning a webpage similar to example.org's one when the domain
// is www.example.{com,org} and redirecting to www. when the domain is example.{com,org}.
func ExampleWebPageHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Alt-Svc", `h3=":443"`)
w.Header().Add("Date", "Thu, 24 Aug 2023 14:35:29 GMT")

// According to Go documentation, the host header is removed from the
// header fields and included as (*Request).Host
//
// Empirically, this field could either contain an host name or it could
// be an endpoint, i.e., it could also contain an optional port
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
// According to Go documentation, the host header is removed from the
// header fields and included as (*Request).Host
//
// Empirically, this field could either contain an host name or it could
// be an endpoint, i.e., it could also contain an optional port
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}

switch host {
case "www.example.com", "www.example.org":
w.Write([]byte(ExampleWebPage))
switch host {
case "www.example.com", "www.example.org":
w.Write([]byte(ExampleWebPage))

case "example.com":
w.Header().Add("Location", "https://www.example.com/")
w.WriteHeader(http.StatusPermanentRedirect)
case "example.com":
w.Header().Add("Location", "https://www.example.com/")
w.WriteHeader(http.StatusPermanentRedirect)

case "example.org":
w.Header().Add("Location", "https://www.example.org/")
w.WriteHeader(http.StatusPermanentRedirect)
case "example.org":
w.Header().Add("Location", "https://www.example.org/")
w.WriteHeader(http.StatusPermanentRedirect)

default:
w.WriteHeader(http.StatusBadRequest)
}
})
default:
w.WriteHeader(http.StatusBadRequest)
}
})
}

// ExampleWebPageHandlerFactory returns a webpage similar to example.org's one when the domain is
// www.example.{com,org} and redirects to www.example.{com,org} when it is example.{com,org}.
func ExampleWebPageHandlerFactory() QAEnvHTTPHandlerFactory {
return QAEnvHTTPHandlerFactoryFunc(func(_ netem.UnderlyingNetwork) http.Handler {
return ExampleWebPageHandler()
})
}

Expand Down