From 3cf6f976ae7dab9f99aced7bc15f8dcb1da35e7a Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sat, 19 Nov 2022 17:18:46 +0100 Subject: [PATCH 01/11] Basic SMTP+StartTLS / SMTPS test --- internal/engine/experiment/smtp/smtp.go | 314 ++++++++++++++++++++++++ internal/registry/smtp.go | 22 ++ 2 files changed, 336 insertions(+) create mode 100644 internal/engine/experiment/smtp/smtp.go create mode 100644 internal/registry/smtp.go diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go new file mode 100644 index 0000000..453807f --- /dev/null +++ b/internal/engine/experiment/smtp/smtp.go @@ -0,0 +1,314 @@ +package smtp + +import ( + "context" + "crypto/tls" + "fmt" + "github.com/pkg/errors" + "net" + "net/smtp" + "net/url" + "strconv" + "time" + + "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" + "github.com/ooni/probe-cli/v3/internal/measurexlite" + "github.com/ooni/probe-cli/v3/internal/model" + "github.com/ooni/probe-cli/v3/internal/tracex" +) + +var ( + // errNoInputProvided indicates you didn't provide any input + errNoInputProvided = errors.New("not input provided") + + // errInputIsNotAnURL indicates that input is not an URL + errInputIsNotAnURL = errors.New("input is not an URL") + + // errInvalidScheme indicates that the scheme is invalid + errInvalidScheme = errors.New("scheme must be smtp(s)") + + // errInvalidPort indicates that the port provided could not be parsed as an int + errInvalidPort = errors.New("Port number is not a valid integer") +) + +const ( + testName = "smtp" + testVersion = "0.0.1" +) + +// Config contains the experiment config. +type Config struct { + host string + port string + forced_tls bool + noop_count uint8 +} + +func config(input model.MeasurementTarget) (*Config, error) { + if input == "" { + // TODO: static input data (eg. gmail/riseup..) + return nil, errNoInputProvided + } + + parsed, err := url.Parse(string(input)) + if err != nil { + return nil, fmt.Errorf("%w: %s", errInputIsNotAnURL, err.Error()) + } + if parsed.Scheme != "smtp" && parsed.Scheme != "smtps" { + return nil, errInvalidScheme + } + + port := "" + + if parsed.Port() == "" { + // Default ports for StartTLS and forced TLS respectively + if parsed.Scheme == "smtp" { + port = "587" + } else { + port = "465" + } + } else { + // Check that requested port is a valid integer + _, err := strconv.Atoi(parsed.Port()) + if err != nil { + return nil, errInvalidPort + } else { + port = parsed.Port() + } + } + + valid_config := Config{ + host: parsed.Hostname(), + forced_tls: parsed.Scheme == "smtps", + port: port, + noop_count: 10, + } + + return &valid_config, nil +} + +// TestKeys contains the experiment results +type TestKeys struct { + Queries []*model.ArchivalDNSLookupResult `json:"queries"` + TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` + TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` + SMTPErrors []*string `json:"smtp"` + NoOpCounter uint8 `json:"successful_noops"` + // Used for global failure (DNS resolution) + Failure string `json:"failure"` +} + +type Measurer struct { + // Config contains the experiment settings. If empty we + // will be using default settings. + Config Config + + // Getter is an optional getter to be used for testing. + Getter urlgetter.MultiGetter +} + +// ExperimentName implements ExperimentMeasurer.ExperimentName +func (m Measurer) ExperimentName() string { + return testName +} + +// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion +func (m Measurer) ExperimentVersion() string { + return testVersion +} + +type SMTPRunner struct { + trace *measurexlite.Trace + logger model.Logger + ctx context.Context + tk *TestKeys + tlsconfig *tls.Config +} + +func (r SMTPRunner) resolve(host string) ([]string, bool) { + r.logger.Infof("Resolving DNS for %s", host) + resolver := r.trace.NewStdlibResolver(r.logger) + addrs, err := resolver.LookupHost(r.ctx, host) + r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...) + if err != nil { + r.tk.Failure = *tracex.NewFailure(err) + return []string{}, false + } + r.logger.Infof("Finished DNS for %s: %v", host, addrs) + + return addrs, true +} + +func (r SMTPRunner) conn(addr string, port string) (net.Conn, bool) { + dialer := r.trace.NewDialerWithoutResolver(r.logger) + conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port)) + r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) + if err != nil { + r.tk.Failure = *tracex.NewFailure(err) + return nil, false + } + return conn, true +} + +func (r SMTPRunner) handshake(conn net.Conn, host string, port string) (net.Conn, bool) { + r.logger.Infof("Starting TLS handshake with %s:%s", host, port) + thx := r.trace.NewTLSHandshakerStdlib(r.logger) + tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig) + r.tk.TLSHandshakes = append(r.tk.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil()) + if err != nil { + return nil, false + } + r.logger.Infof("Handshake succeeded") + return tconn, true +} + +func (r SMTPRunner) starttls(conn net.Conn, host string, port string, message string) (net.Conn, bool) { + r.logger.Infof("Asking for StartTLS upgrade") + conn.Write([]byte(message)) + tconn, success := r.handshake(conn, host, port) + return tconn, success +} + +func (r SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool { + client, err := smtp.NewClient(conn, ehlo) + if err != nil { + r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + return false + } + err = client.Hello(ehlo) + if err != nil { + r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + return false + } + + if noop > 0 { + r.logger.Infof("Trying to generate more no-op traffic") + // TODO: noop counter per IP address + r.tk.NoOpCounter = 0 + for r.tk.NoOpCounter < noop { + r.tk.NoOpCounter += 1 + r.logger.Infof("NoOp Iteration %d", r.tk.NoOpCounter) + err = client.Noop() + if err != nil { + r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + break + } + } + + if r.tk.NoOpCounter == noop { + r.logger.Infof("Successfully generated no-op traffic") + return true + } else { + r.logger.Infof("Failed no-op traffic at iteration %d", r.tk.NoOpCounter) + return false + } + } + + return true +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + + log := sess.Logger() + trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved) + + config, err := config(measurement.Input) + if err != nil { + // Invalid input data, we don't even generate report + return err + } + + tk := new(TestKeys) + measurement.TestKeys = tk + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + tlsconfig := tls.Config{ + InsecureSkipVerify: false, + ServerName: config.host, + } + + runner := SMTPRunner{ + trace: trace, + logger: log, + ctx: ctx, + tk: tk, + tlsconfig: &tlsconfig, + } + + // First resolve DNS + addrs, success := runner.resolve(config.host) + if !success { + return nil + } + + for _, addr := range addrs { + conn, success := runner.conn(addr, config.port) + if !success { + return nil + } + defer conn.Close() + + if config.forced_tls { + // Direct TLS connection + tconn, success := runner.handshake(conn, config.host, config.port) + if !success { + continue + } + defer tconn.Close() + + // Try EHLO + NoOps + if !runner.smtp(tconn, "localhost", 10) { + continue + } + } else { + // StartTLS... first try plaintext EHLO + if !runner.smtp(conn, "localhost", 0) { + continue + } + + // Upgrade via StartTLS and try EHLO + NoOps + tconn, success := runner.starttls(conn, config.host, config.port, "STARTTLS\n") + if !success { + continue + } + defer tconn.Close() + + if !runner.smtp(tconn, "localhost", 10) { + continue + } + } + } + + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with ooniprobe +// therefore we should be careful when changing it. +type SummaryKeys struct { + //DNSBlocking bool `json:"facebook_dns_blocking"` + //TCPBlocking bool `json:"facebook_tcp_blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + _, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + return sk, nil +} diff --git a/internal/registry/smtp.go b/internal/registry/smtp.go new file mode 100644 index 0000000..cb67e45 --- /dev/null +++ b/internal/registry/smtp.go @@ -0,0 +1,22 @@ +package registry + +// +// Registers the `dnsping' experiment. +// + +import ( + "github.com/ooni/probe-cli/v3/internal/engine/experiment/smtp" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func init() { + AllExperiments["smtp"] = &Factory{ + build: func(config interface{}) model.ExperimentMeasurer { + return smtp.NewExperimentMeasurer( + *config.(*smtp.Config), + ) + }, + config: &smtp.Config{}, + inputPolicy: model.InputOrStaticDefault, + } +} From c80dca4a59306b80768b80de509c81c2fed55f1e Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 16:22:00 +0100 Subject: [PATCH 02/11] Failure only for top level failure --- internal/engine/experiment/smtp/smtp.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 453807f..671dd16 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -144,7 +144,6 @@ func (r SMTPRunner) conn(addr string, port string) (net.Conn, bool) { conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port)) r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) if err != nil { - r.tk.Failure = *tracex.NewFailure(err) return nil, false } return conn, true From c35e01f64067b98df30ca055c44fd4fbf6279830 Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 18:22:42 +0100 Subject: [PATCH 03/11] more abstractions for errors --- internal/engine/experiment/smtp/smtp.go | 56 ++++++++++++++++++------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 671dd16..a0d38ac 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -92,7 +92,7 @@ type TestKeys struct { Queries []*model.ArchivalDNSLookupResult `json:"queries"` TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` - SMTPErrors []*string `json:"smtp"` + SMTPErrors map[string][]*string `json:"smtp"` NoOpCounter uint8 `json:"successful_noops"` // Used for global failure (DNS resolution) Failure string `json:"failure"` @@ -117,12 +117,28 @@ func (m Measurer) ExperimentVersion() string { return testVersion } +// Manages sequential SMTP sessions to the same hostname (over different IPs) +// don't use in parallel! type SMTPRunner struct { trace *measurexlite.Trace logger model.Logger ctx context.Context tk *TestKeys tlsconfig *tls.Config + host string + port string + // addr is changed everytime SMTPRunner.conn(addr) is called + addr string +} + +func (r SMTPRunner) smtp_error(err error) { + key := net.JoinHostPort(r.addr, r.port) + errors, exists := r.tk.SMTPErrors[key] + if exists { + r.tk.SMTPErrors[key] = append(errors, tracex.NewFailure(err)) + } else { + r.tk.SMTPErrors[key] = []*string{tracex.NewFailure(err)} + } } func (r SMTPRunner) resolve(host string) ([]string, bool) { @@ -139,44 +155,49 @@ func (r SMTPRunner) resolve(host string) ([]string, bool) { return addrs, true } -func (r SMTPRunner) conn(addr string, port string) (net.Conn, bool) { +func (r SMTPRunner) conn(addr string) (net.Conn, bool) { + r.addr = addr dialer := r.trace.NewDialerWithoutResolver(r.logger) - conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port)) + conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(r.addr, r.port)) r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) if err != nil { + r.smtp_error(err) return nil, false } return conn, true } -func (r SMTPRunner) handshake(conn net.Conn, host string, port string) (net.Conn, bool) { - r.logger.Infof("Starting TLS handshake with %s:%s", host, port) +func (r SMTPRunner) handshake(conn net.Conn) (net.Conn, bool) { + r.logger.Infof("Starting TLS handshake with %s:%s (%s)", r.host, r.port, r.addr) thx := r.trace.NewTLSHandshakerStdlib(r.logger) tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig) r.tk.TLSHandshakes = append(r.tk.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil()) if err != nil { + r.smtp_error(err) return nil, false } r.logger.Infof("Handshake succeeded") return tconn, true } -func (r SMTPRunner) starttls(conn net.Conn, host string, port string, message string) (net.Conn, bool) { - r.logger.Infof("Asking for StartTLS upgrade") - conn.Write([]byte(message)) - tconn, success := r.handshake(conn, host, port) +func (r SMTPRunner) starttls(conn net.Conn, message string) (net.Conn, bool) { + if message != "" { + r.logger.Infof("Asking for StartTLS upgrade") + conn.Write([]byte(message)) + } + tconn, success := r.handshake(conn) return tconn, success } func (r SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool { client, err := smtp.NewClient(conn, ehlo) if err != nil { - r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + r.smtp_error(err) return false } err = client.Hello(ehlo) if err != nil { - r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + r.smtp_error(err) return false } @@ -189,7 +210,7 @@ func (r SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool { r.logger.Infof("NoOp Iteration %d", r.tk.NoOpCounter) err = client.Noop() if err != nil { - r.tk.SMTPErrors = append(r.tk.SMTPErrors, []*string{tracex.NewFailure(err)}...) + r.smtp_error(err) break } } @@ -222,6 +243,8 @@ func (m Measurer) Run( } tk := new(TestKeys) + // TODO: make it so we don't forget it + tk.SMTPErrors = make(map[string][]*string) measurement.TestKeys = tk ctx, cancel := context.WithTimeout(ctx, 60*time.Second) @@ -238,6 +261,9 @@ func (m Measurer) Run( ctx: ctx, tk: tk, tlsconfig: &tlsconfig, + host: config.host, + port: config.port, + addr: "", } // First resolve DNS @@ -247,7 +273,7 @@ func (m Measurer) Run( } for _, addr := range addrs { - conn, success := runner.conn(addr, config.port) + conn, success := runner.conn(addr) if !success { return nil } @@ -255,7 +281,7 @@ func (m Measurer) Run( if config.forced_tls { // Direct TLS connection - tconn, success := runner.handshake(conn, config.host, config.port) + tconn, success := runner.handshake(conn) if !success { continue } @@ -272,7 +298,7 @@ func (m Measurer) Run( } // Upgrade via StartTLS and try EHLO + NoOps - tconn, success := runner.starttls(conn, config.host, config.port, "STARTTLS\n") + tconn, success := runner.starttls(conn, "STARTTLS\n") if !success { continue } From 25ebafa4275ba741d325244aeb2cb70e9ebfe1cf Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 18:30:15 +0100 Subject: [PATCH 04/11] Auto-initialize per-addr keys in SMTPErrors --- internal/engine/experiment/smtp/smtp.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index a0d38ac..09d3484 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -133,12 +133,8 @@ type SMTPRunner struct { func (r SMTPRunner) smtp_error(err error) { key := net.JoinHostPort(r.addr, r.port) - errors, exists := r.tk.SMTPErrors[key] - if exists { - r.tk.SMTPErrors[key] = append(errors, tracex.NewFailure(err)) - } else { - r.tk.SMTPErrors[key] = []*string{tracex.NewFailure(err)} - } + // Key is initialized in conn() no need to check here + r.tk.SMTPErrors[key] = append(r.tk.SMTPErrors[key], tracex.NewFailure(err)) } func (r SMTPRunner) resolve(host string) ([]string, bool) { @@ -156,7 +152,13 @@ func (r SMTPRunner) resolve(host string) ([]string, bool) { } func (r SMTPRunner) conn(addr string) (net.Conn, bool) { + // Initialize addr field and corresponding errors in TestKeys r.addr = addr + if r.tk.SMTPErrors == nil { + r.tk.SMTPErrors = make(map[string][]*string) + } + r.tk.SMTPErrors[net.JoinHostPort(addr, r.port)] = []*string{} + dialer := r.trace.NewDialerWithoutResolver(r.logger) conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(r.addr, r.port)) r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) @@ -243,8 +245,6 @@ func (m Measurer) Run( } tk := new(TestKeys) - // TODO: make it so we don't forget it - tk.SMTPErrors = make(map[string][]*string) measurement.TestKeys = tk ctx, cancel := context.WithTimeout(ctx, 60*time.Second) From d81f22d46e99ef7e2b771521cf844a7976c5608a Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 20:10:00 +0100 Subject: [PATCH 05/11] Failed experiment results in numerous nil pointer dereferences --- internal/engine/experiment/smtp/smtp.go | 189 +++++++++++++++--------- 1 file changed, 121 insertions(+), 68 deletions(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 09d3484..50b66af 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -92,7 +92,7 @@ type TestKeys struct { Queries []*model.ArchivalDNSLookupResult `json:"queries"` TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` - SMTPErrors map[string][]*string `json:"smtp"` + Errors map[string][]*string `json:"smtp"` NoOpCounter uint8 `json:"successful_noops"` // Used for global failure (DNS resolution) Failure string `json:"failure"` @@ -117,9 +117,7 @@ func (m Measurer) ExperimentVersion() string { return testVersion } -// Manages sequential SMTP sessions to the same hostname (over different IPs) -// don't use in parallel! -type SMTPRunner struct { +type TCPRunner struct { trace *measurexlite.Trace logger model.Logger ctx context.Context @@ -127,17 +125,84 @@ type SMTPRunner struct { tlsconfig *tls.Config host string port string - // addr is changed everytime SMTPRunner.conn(addr) is called - addr string } -func (r SMTPRunner) smtp_error(err error) { - key := net.JoinHostPort(r.addr, r.port) - // Key is initialized in conn() no need to check here - r.tk.SMTPErrors[key] = append(r.tk.SMTPErrors[key], tracex.NewFailure(err)) +type TCPSession struct { + addr string + port string + runner *TCPRunner + errors []*string + tls bool + raw_conn *net.Conn + tls_conn *net.Conn } -func (r SMTPRunner) resolve(host string) ([]string, bool) { +func (s TCPSession) Close() { + if s.tls { + var conn = *s.tls_conn + conn.Close() + //*(s.tls_conn).Close() + } else { + var conn = *s.raw_conn + conn.Close() + //*(s.raw_conn).Close() + } +} + +func (s TCPSession) current_conn() net.Conn { + if s.tls { + return *s.tls_conn + } else { + return *s.raw_conn + } +} + +func (r TCPRunner) conn(addr string, port string) (*TCPSession, bool) { + key := net.JoinHostPort(addr, port) + // Initialize errors + if r.tk.Errors == nil { + r.tk.Errors = make(map[string][]*string) + } + r.tk.Errors[key] = []*string{} + + s := new(TCPSession) + if !s.conn(addr, port, r, r.tk.Errors[key]) { + return nil, false + } + return s, true +} + +func (r TCPRunner) dial(addr string, port string) (net.Conn, error) { + dialer := r.trace.NewDialerWithoutResolver(r.logger) + conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port)) + r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) + return conn, err + +} + +func (s TCPSession) conn(addr string, port string, runner TCPRunner, errors []*string) bool { + // Initialize addr field and corresponding errors in TestKeys + s.addr = addr + s.port = port + s.tls = false + s.runner = &runner + s.errors = errors + + conn, err := runner.dial(addr, port) + if err != nil { + s.error(err) + return false + } + s.raw_conn = &conn + + return true +} + +func (s TCPSession) error(err error) { + s.errors = append(s.errors, tracex.NewFailure(err)) +} + +func (r TCPRunner) resolve(host string) ([]string, bool) { r.logger.Infof("Resolving DNS for %s", host) resolver := r.trace.NewStdlibResolver(r.logger) addrs, err := resolver.LookupHost(r.ctx, host) @@ -151,77 +216,70 @@ func (r SMTPRunner) resolve(host string) ([]string, bool) { return addrs, true } -func (r SMTPRunner) conn(addr string) (net.Conn, bool) { - // Initialize addr field and corresponding errors in TestKeys - r.addr = addr - if r.tk.SMTPErrors == nil { - r.tk.SMTPErrors = make(map[string][]*string) +func (s TCPSession) handshake() bool { + if s.tls { + // TLS already initialized... + return true } - r.tk.SMTPErrors[net.JoinHostPort(addr, r.port)] = []*string{} - - dialer := r.trace.NewDialerWithoutResolver(r.logger) - conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(r.addr, r.port)) - r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) + s.runner.logger.Infof("Starting TLS handshake with %s:%s", s.addr, s.port) + thx := s.runner.trace.NewTLSHandshakerStdlib(s.runner.logger) + tconn, _, err := thx.Handshake(s.runner.ctx, *s.raw_conn, s.runner.tlsconfig) + s.runner.tk.TLSHandshakes = append(s.runner.tk.TLSHandshakes, s.runner.trace.FirstTLSHandshakeOrNil()) if err != nil { - r.smtp_error(err) - return nil, false + s.error(err) + return false } - return conn, true + + s.tls = true + s.tls_conn = &tconn + s.runner.logger.Infof("Handshake succeeded") + return true } -func (r SMTPRunner) handshake(conn net.Conn) (net.Conn, bool) { - r.logger.Infof("Starting TLS handshake with %s:%s (%s)", r.host, r.port, r.addr) - thx := r.trace.NewTLSHandshakerStdlib(r.logger) - tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig) - r.tk.TLSHandshakes = append(r.tk.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil()) - if err != nil { - r.smtp_error(err) - return nil, false +func (s TCPSession) starttls(message string) bool { + if s.tls { + // TLS already initialized... + return true } - r.logger.Infof("Handshake succeeded") - return tconn, true -} - -func (r SMTPRunner) starttls(conn net.Conn, message string) (net.Conn, bool) { if message != "" { - r.logger.Infof("Asking for StartTLS upgrade") - conn.Write([]byte(message)) + s.runner.logger.Infof("Asking for StartTLS upgrade") + s.current_conn().Write([]byte(message)) } - tconn, success := r.handshake(conn) - return tconn, success + return s.handshake() } -func (r SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool { - client, err := smtp.NewClient(conn, ehlo) +func (s TCPSession) smtp(ehlo string, noop uint8) bool { + // Auto-choose plaintext/TCP session + client, err := smtp.NewClient(s.current_conn(), ehlo) if err != nil { - r.smtp_error(err) + s.error(err) return false } err = client.Hello(ehlo) if err != nil { - r.smtp_error(err) + s.error(err) return false } if noop > 0 { - r.logger.Infof("Trying to generate more no-op traffic") + s.runner.logger.Infof("Trying to generate more no-op traffic") // TODO: noop counter per IP address - r.tk.NoOpCounter = 0 - for r.tk.NoOpCounter < noop { - r.tk.NoOpCounter += 1 - r.logger.Infof("NoOp Iteration %d", r.tk.NoOpCounter) + s.runner.tk.NoOpCounter = 0 + for s.runner.tk.NoOpCounter < noop { + s.runner.tk.NoOpCounter += 1 + s.runner.logger.Infof("NoOp Iteration %d", s.runner.tk.NoOpCounter) err = client.Noop() if err != nil { - r.smtp_error(err) + s.error(err) break } } - if r.tk.NoOpCounter == noop { - r.logger.Infof("Successfully generated no-op traffic") + if s.runner.tk.NoOpCounter == noop { + s.runner.logger.Infof("Successfully generated no-op traffic") return true } else { - r.logger.Infof("Failed no-op traffic at iteration %d", r.tk.NoOpCounter) + s.runner.logger.Infof("Failed no-op traffic at iteration %d", s.runner.tk.NoOpCounter) return false } } @@ -255,7 +313,7 @@ func (m Measurer) Run( ServerName: config.host, } - runner := SMTPRunner{ + runner := TCPRunner{ trace: trace, logger: log, ctx: ctx, @@ -263,7 +321,6 @@ func (m Measurer) Run( tlsconfig: &tlsconfig, host: config.host, port: config.port, - addr: "", } // First resolve DNS @@ -273,38 +330,34 @@ func (m Measurer) Run( } for _, addr := range addrs { - conn, success := runner.conn(addr) + tcp_session, success := runner.conn(addr, config.port) if !success { - return nil + continue } - defer conn.Close() + defer tcp_session.Close() if config.forced_tls { // Direct TLS connection - tconn, success := runner.handshake(conn) - if !success { + if !tcp_session.handshake() { continue } - defer tconn.Close() // Try EHLO + NoOps - if !runner.smtp(tconn, "localhost", 10) { + if !tcp_session.smtp("localhost", 10) { continue } } else { // StartTLS... first try plaintext EHLO - if !runner.smtp(conn, "localhost", 0) { + if !tcp_session.smtp("localhost", 0) { continue } // Upgrade via StartTLS and try EHLO + NoOps - tconn, success := runner.starttls(conn, "STARTTLS\n") - if !success { + if !tcp_session.starttls("STARTTLS\n") { continue } - defer tconn.Close() - if !runner.smtp(tconn, "localhost", 10) { + if !tcp_session.smtp("localhost", 10) { continue } } From 9783e3bb47b783bbb9946eb3c050c78d970d11a5 Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 21:37:34 +0100 Subject: [PATCH 06/11] Fix broken experiment by properly using pointers --- internal/engine/experiment/smtp/smtp.go | 115 +++++++++++++++--------- 1 file changed, 72 insertions(+), 43 deletions(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 50b66af..97c183f 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -88,14 +88,24 @@ func config(input model.MeasurementTarget) (*Config, error) { } // TestKeys contains the experiment results + type TestKeys struct { - Queries []*model.ArchivalDNSLookupResult `json:"queries"` - TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` - TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` - Errors map[string][]*string `json:"smtp"` - NoOpCounter uint8 `json:"successful_noops"` + Queries []*model.ArchivalDNSLookupResult `json:"queries"` + Runs map[string]*IndividualTestKeys `json:"runs"` // Used for global failure (DNS resolution) Failure string `json:"failure"` + // Indicates global failure or individual test failure + Failed bool `json:"failed"` +} + +// IndividualTestKeys contains results for TCP/IP level stuff for each address found +// in the DNS lookup +type IndividualTestKeys struct { + NoOpCounter uint8 + TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"` + TLSHandshakes []*model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"` + // Individual failure aborting the test run for this address/port combo + Failure *string `json:"failure"` } type Measurer struct { @@ -117,6 +127,8 @@ func (m Measurer) ExperimentVersion() string { return testVersion } +// Manages sequential TCP sessions to the same hostname (over different IPs) +// don't use in parallel! type TCPRunner struct { trace *measurexlite.Trace logger model.Logger @@ -125,31 +137,31 @@ type TCPRunner struct { tlsconfig *tls.Config host string port string + // addr is changed everytime TCPRunner.conn(addr) is called + addr string } type TCPSession struct { addr string port string runner *TCPRunner - errors []*string + tk *IndividualTestKeys tls bool raw_conn *net.Conn tls_conn *net.Conn } -func (s TCPSession) Close() { +func (s *TCPSession) Close() { if s.tls { - var conn = *s.tls_conn - conn.Close() - //*(s.tls_conn).Close() + var conn = *s.tls_conn + conn.Close() } else { - var conn = *s.raw_conn - conn.Close() - //*(s.raw_conn).Close() - } + var conn = *s.raw_conn + conn.Close() + } } -func (s TCPSession) current_conn() net.Conn { +func (s *TCPSession) current_conn() net.Conn { if s.tls { return *s.tls_conn } else { @@ -157,52 +169,69 @@ func (s TCPSession) current_conn() net.Conn { } } -func (r TCPRunner) conn(addr string, port string) (*TCPSession, bool) { - key := net.JoinHostPort(addr, port) - // Initialize errors - if r.tk.Errors == nil { - r.tk.Errors = make(map[string][]*string) - } - r.tk.Errors[key] = []*string{} +func (r *TCPRunner) run_key() string { + return net.JoinHostPort(r.addr, r.port) +} + +func (r *TCPRunner) get_run() *IndividualTestKeys { + if r.tk.Runs == nil { + r.tk.Runs = make(map[string]*IndividualTestKeys) + } + key := r.run_key() + val, exists := r.tk.Runs[key] + if exists { + return val + } else { + r.tk.Runs[key] = &IndividualTestKeys{} + return r.tk.Runs[key] + } +} + +func (r *TCPRunner) conn(addr string, port string) (*TCPSession, bool) { + r.addr = addr + run := r.get_run() s := new(TCPSession) - if !s.conn(addr, port, r, r.tk.Errors[key]) { + if !s.conn(addr, port, r, run) { return nil, false } return s, true } -func (r TCPRunner) dial(addr string, port string) (net.Conn, error) { +func (r *TCPRunner) dial(addr string, port string) (net.Conn, error) { dialer := r.trace.NewDialerWithoutResolver(r.logger) conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port)) - r.tk.TCPConnect = append(r.tk.TCPConnect, r.trace.TCPConnects()...) + run := r.get_run() + run.TCPConnect = append(run.TCPConnect, r.trace.TCPConnects()...) return conn, err } -func (s TCPSession) conn(addr string, port string, runner TCPRunner, errors []*string) bool { +func (s *TCPSession) conn(addr string, port string, runner *TCPRunner, tk *IndividualTestKeys) bool { // Initialize addr field and corresponding errors in TestKeys s.addr = addr s.port = port s.tls = false - s.runner = &runner - s.errors = errors + s.runner = runner + s.tk = tk conn, err := runner.dial(addr, port) if err != nil { s.error(err) return false } - s.raw_conn = &conn + s.raw_conn = &conn return true } -func (s TCPSession) error(err error) { - s.errors = append(s.errors, tracex.NewFailure(err)) +func (s *TCPSession) error(err error) { + s.runner.tk.Failed = true + s.tk.Failure = tracex.NewFailure(err) + //s. = append(s.errors, tracex.NewFailure(err)) } -func (r TCPRunner) resolve(host string) ([]string, bool) { +func (r *TCPRunner) resolve(host string) ([]string, bool) { r.logger.Infof("Resolving DNS for %s", host) resolver := r.trace.NewStdlibResolver(r.logger) addrs, err := resolver.LookupHost(r.ctx, host) @@ -216,7 +245,7 @@ func (r TCPRunner) resolve(host string) ([]string, bool) { return addrs, true } -func (s TCPSession) handshake() bool { +func (s *TCPSession) handshake() bool { if s.tls { // TLS already initialized... return true @@ -224,7 +253,7 @@ func (s TCPSession) handshake() bool { s.runner.logger.Infof("Starting TLS handshake with %s:%s", s.addr, s.port) thx := s.runner.trace.NewTLSHandshakerStdlib(s.runner.logger) tconn, _, err := thx.Handshake(s.runner.ctx, *s.raw_conn, s.runner.tlsconfig) - s.runner.tk.TLSHandshakes = append(s.runner.tk.TLSHandshakes, s.runner.trace.FirstTLSHandshakeOrNil()) + s.tk.TLSHandshakes = append(s.tk.TLSHandshakes, s.runner.trace.FirstTLSHandshakeOrNil()) if err != nil { s.error(err) return false @@ -236,7 +265,7 @@ func (s TCPSession) handshake() bool { return true } -func (s TCPSession) starttls(message string) bool { +func (s *TCPSession) starttls(message string) bool { if s.tls { // TLS already initialized... return true @@ -248,7 +277,7 @@ func (s TCPSession) starttls(message string) bool { return s.handshake() } -func (s TCPSession) smtp(ehlo string, noop uint8) bool { +func (s *TCPSession) smtp(ehlo string, noop uint8) bool { // Auto-choose plaintext/TCP session client, err := smtp.NewClient(s.current_conn(), ehlo) if err != nil { @@ -264,10 +293,10 @@ func (s TCPSession) smtp(ehlo string, noop uint8) bool { if noop > 0 { s.runner.logger.Infof("Trying to generate more no-op traffic") // TODO: noop counter per IP address - s.runner.tk.NoOpCounter = 0 - for s.runner.tk.NoOpCounter < noop { - s.runner.tk.NoOpCounter += 1 - s.runner.logger.Infof("NoOp Iteration %d", s.runner.tk.NoOpCounter) + s.tk.NoOpCounter = 0 + for s.tk.NoOpCounter < noop { + s.tk.NoOpCounter += 1 + s.runner.logger.Infof("NoOp Iteration %d", s.tk.NoOpCounter) err = client.Noop() if err != nil { s.error(err) @@ -275,11 +304,11 @@ func (s TCPSession) smtp(ehlo string, noop uint8) bool { } } - if s.runner.tk.NoOpCounter == noop { + if s.tk.NoOpCounter == noop { s.runner.logger.Infof("Successfully generated no-op traffic") return true } else { - s.runner.logger.Infof("Failed no-op traffic at iteration %d", s.runner.tk.NoOpCounter) + s.runner.logger.Infof("Failed no-op traffic at iteration %d", s.tk.NoOpCounter) return false } } @@ -313,7 +342,7 @@ func (m Measurer) Run( ServerName: config.host, } - runner := TCPRunner{ + runner := &TCPRunner{ trace: trace, logger: log, ctx: ctx, From c95fb894f0a6a36696285144dc55a6852df66bef Mon Sep 17 00:00:00 2001 From: ooninoob Date: Mon, 21 Nov 2022 14:13:41 +0100 Subject: [PATCH 07/11] Test suite expects static Config not depending on input --- internal/engine/experiment/smtp/smtp.go | 26 +++++++++---------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 97c183f..568db04 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -8,7 +8,6 @@ import ( "net" "net/smtp" "net/url" - "strconv" "time" "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" @@ -26,9 +25,6 @@ var ( // errInvalidScheme indicates that the scheme is invalid errInvalidScheme = errors.New("scheme must be smtp(s)") - - // errInvalidPort indicates that the port provided could not be parsed as an int - errInvalidPort = errors.New("Port number is not a valid integer") ) const ( @@ -37,14 +33,16 @@ const ( ) // Config contains the experiment config. -type Config struct { +type Config struct{} + +type RuntimeConfig struct { host string port string forced_tls bool noop_count uint8 } -func config(input model.MeasurementTarget) (*Config, error) { +func config(input model.MeasurementTarget) (*RuntimeConfig, error) { if input == "" { // TODO: static input data (eg. gmail/riseup..) return nil, errNoInputProvided @@ -68,16 +66,11 @@ func config(input model.MeasurementTarget) (*Config, error) { port = "465" } } else { - // Check that requested port is a valid integer - _, err := strconv.Atoi(parsed.Port()) - if err != nil { - return nil, errInvalidPort - } else { - port = parsed.Port() - } + // Valid port is checked by URL parsing + port = parsed.Port() } - valid_config := Config{ + valid_config := RuntimeConfig{ host: parsed.Hostname(), forced_tls: parsed.Scheme == "smtps", port: port, @@ -321,7 +314,6 @@ func (m Measurer) Run( ctx context.Context, sess model.ExperimentSession, measurement *model.Measurement, callbacks model.ExperimentCallbacks, ) error { - log := sess.Logger() trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved) @@ -372,7 +364,7 @@ func (m Measurer) Run( } // Try EHLO + NoOps - if !tcp_session.smtp("localhost", 10) { + if !tcp_session.smtp("localhost", config.noop_count) { continue } } else { @@ -386,7 +378,7 @@ func (m Measurer) Run( continue } - if !tcp_session.smtp("localhost", 10) { + if !tcp_session.smtp("localhost", config.noop_count) { continue } } From b4d5eebb60860171fcb0fa07d70ddce5d25c3fec Mon Sep 17 00:00:00 2001 From: ooninoob Date: Mon, 21 Nov 2022 14:14:22 +0100 Subject: [PATCH 08/11] First iteration for tests --- internal/engine/experiment/smtp/smtp_test.go | 185 +++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 internal/engine/experiment/smtp/smtp_test.go diff --git a/internal/engine/experiment/smtp/smtp_test.go b/internal/engine/experiment/smtp/smtp_test.go new file mode 100644 index 0000000..e49af52 --- /dev/null +++ b/internal/engine/experiment/smtp/smtp_test.go @@ -0,0 +1,185 @@ +package smtp + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/mockable" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func plaintextListener() net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { + panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err)) + } + } + return l +} + +func tlsListener(l net.Listener) net.Listener { + return tls.NewListener(l, &tls.Config{}) +} + +func listener_addr(l net.Listener) string { + return l.Addr().String() +} + +func ValidSMTPServer(conn net.Conn) { + for { + command, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + return + } + + if command == "" { + } else if command == "NOOP" { + conn.Write([]byte("250 2.0.0 Ok\n")) + } else if command == "STARTTLS" { + conn.Write([]byte("220 2.0.0 Ready to start TLS\n")) + // TODO: conn.Close does not actually close connection? or does client not detect it? + conn.Close() + return + } else if strings.HasPrefix(command, "EHLO") { + conn.Write([]byte("250 mock.example.com\n")) + } + conn.Write([]byte("\n")) + } +} + +func TCPServer(l net.Listener) { + for { + conn, err := l.Accept() + if err != nil { + continue + } + defer conn.Close() + conn.Write([]byte("220 mock.example.com ESMTP (spam is not appreciated)\n")) + ValidSMTPServer(conn) + } +} + +func TestMeasurer_run(t *testing.T) { + // runHelper is an helper function to run this set of tests. + runHelper := func(input string) (*model.Measurement, model.ExperimentMeasurer, error) { + m := NewExperimentMeasurer(Config{}) + if m.ExperimentName() != "smtp" { + t.Fatal("invalid experiment name") + } + if m.ExperimentVersion() != "0.0.1" { + t.Fatal("invalid experiment version") + } + ctx := context.Background() + meas := &model.Measurement{ + Input: model.MeasurementTarget(input), + } + sess := &mockable.Session{ + MockableLogger: model.DiscardLogger, + } + callbacks := model.NewPrinterCallbacks(model.DiscardLogger) + err := m.Run(ctx, sess, meas, callbacks) + return meas, m, err + } + + t.Run("with empty input", func(t *testing.T) { + _, _, err := runHelper("") + if !errors.Is(err, errNoInputProvided) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid URL", func(t *testing.T) { + _, _, err := runHelper("\t") + if !errors.Is(err, errInputIsNotAnURL) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid scheme", func(t *testing.T) { + _, _, err := runHelper("https://8.8.8.8:443/") + if !errors.Is(err, errInvalidScheme) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with broken TLS", func(t *testing.T) { + p := plaintextListener() + defer p.Close() + + l := tlsListener(p) + defer l.Close() + addr := listener_addr(l) + go TCPServer(l) + + meas, m, err := runHelper("smtps://" + addr) + if err != nil { + t.Fatal(err) + } + + tk := meas.TestKeys.(*TestKeys) + + for _, run := range tk.Runs { + for _, handshake := range run.TLSHandshakes { + if *handshake.Failure != "unknown_failure: remote error: tls: unrecognized name" { + t.Fatal("expected unrecognized_name in TLS handshake") + } + } + + if run.NoOpCounter != 0 { + t.Fatalf("expected to not have any noops, not %d noops", run.NoOpCounter) + } + } + + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + }) + + t.Run("with broken starttls", func(t *testing.T) { + l := plaintextListener() + defer l.Close() + addr := listener_addr(l) + + go TCPServer(l) + + meas, m, err := runHelper("smtp://" + addr) + if err != nil { + t.Fatal(err) + } + + tk := meas.TestKeys.(*TestKeys) + + for _, run := range tk.Runs { + for _, handshake := range run.TLSHandshakes { + if *handshake.Failure != "generic_timeout_error" { + t.Fatal("expected timeout in TLS handshake") + } + } + + if run.NoOpCounter != 0 { + t.Fatalf("expected to not have any noops, not %d noops", run.NoOpCounter) + } + } + + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + }) +} From 4c96b2f0f4f356ad3f0daf7062e85e4a4de80052 Mon Sep 17 00:00:00 2001 From: ooninoob Date: Sun, 20 Nov 2022 21:04:57 +0100 Subject: [PATCH 09/11] Rebase on experiment --- internal/engine/experiment/smtp/smtp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index 568db04..d97446c 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -230,7 +230,7 @@ func (r *TCPRunner) resolve(host string) ([]string, bool) { addrs, err := resolver.LookupHost(r.ctx, host) r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...) if err != nil { - r.tk.Failure = *tracex.NewFailure(err) + r.global_error(err) return []string{}, false } r.logger.Infof("Finished DNS for %s: %v", host, addrs) From 95b245cda46992020f6ee6f4c8624471104b1fbb Mon Sep 17 00:00:00 2001 From: ooninoob Date: Mon, 21 Nov 2022 14:21:06 +0100 Subject: [PATCH 10/11] missed that in rebase --- internal/engine/experiment/smtp/smtp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/engine/experiment/smtp/smtp.go b/internal/engine/experiment/smtp/smtp.go index d97446c..568db04 100644 --- a/internal/engine/experiment/smtp/smtp.go +++ b/internal/engine/experiment/smtp/smtp.go @@ -230,7 +230,7 @@ func (r *TCPRunner) resolve(host string) ([]string, bool) { addrs, err := resolver.LookupHost(r.ctx, host) r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...) if err != nil { - r.global_error(err) + r.tk.Failure = *tracex.NewFailure(err) return []string{}, false } r.logger.Infof("Finished DNS for %s: %v", host, addrs) From fce6eb779b2576e2b7e14daf7300ed01ad172a2d Mon Sep 17 00:00:00 2001 From: ooninoob Date: Mon, 21 Nov 2022 19:25:23 +0100 Subject: [PATCH 11/11] Start work on IMAP probe --- internal/engine/experiment/imap/.smtp.go.swp | Bin 0 -> 24576 bytes .../engine/experiment/imap/.smtp_test.go.swp | Bin 0 -> 28672 bytes internal/engine/experiment/imap/imap.go | 416 ++++++++++++++++++ internal/engine/experiment/imap/imap_test.go | 186 ++++++++ 4 files changed, 602 insertions(+) create mode 100644 internal/engine/experiment/imap/.smtp.go.swp create mode 100644 internal/engine/experiment/imap/.smtp_test.go.swp create mode 100644 internal/engine/experiment/imap/imap.go create mode 100644 internal/engine/experiment/imap/imap_test.go diff --git a/internal/engine/experiment/imap/.smtp.go.swp b/internal/engine/experiment/imap/.smtp.go.swp new file mode 100644 index 0000000000000000000000000000000000000000..c4f8bb682bb7263202a642c7b577a5ee662f82a3 GIT binary patch literal 24576 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 literal 0 HcmV?d00001 diff --git a/internal/engine/experiment/imap/.smtp_test.go.swp b/internal/engine/experiment/imap/.smtp_test.go.swp new file mode 100644 index 0000000000000000000000000000000000000000..5a742bedb807ab21ba6fcf0ea4c3a34e2c981d9b GIT binary patch literal 28672 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 P{vN<;N`7eyYQFymc 0 { + s.runner.logger.Infof("Trying to generate no-op traffic") + s.tk.NoOpCounter = 0 + for s.tk.NoOpCounter < noop { + s.tk.NoOpCounter += 1 + s.runner.logger.Infof("NoOp Iteration %d", s.tk.NoOpCounter) + + conn.Write([]byte("A1 NOOP\n")) + command, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + s.error(err) + break + } + if !strings.Contains(command, "OK NOOP") { + s.error(errors.New("Unexpected IMAP reply: " + command)) + break + } + } + + if s.tk.NoOpCounter == noop { + s.runner.logger.Infof("Successfully generated no-op traffic") + return true + } else { + s.runner.logger.Infof("Failed no-op traffic at iteration %d", s.tk.NoOpCounter) + return false + } + } + + return true +} + +// Run implements ExperimentMeasurer.Run +func (m Measurer) Run( + ctx context.Context, sess model.ExperimentSession, + measurement *model.Measurement, callbacks model.ExperimentCallbacks, +) error { + log := sess.Logger() + trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved) + + config, err := config(measurement.Input) + if err != nil { + // Invalid input data, we don't even generate report + return err + } + + tk := new(TestKeys) + measurement.TestKeys = tk + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + tlsconfig := tls.Config{ + InsecureSkipVerify: false, + ServerName: config.host, + } + + runner := &TCPRunner{ + trace: trace, + logger: log, + ctx: ctx, + tk: tk, + tlsconfig: &tlsconfig, + host: config.host, + port: config.port, + } + + // First resolve DNS + addrs, success := runner.resolve(config.host) + if !success { + return nil + } + + for _, addr := range addrs { + tcp_session, success := runner.conn(addr, config.port) + if !success { + continue + } + defer tcp_session.Close() + + if config.forced_tls { + // Direct TLS connection + if !tcp_session.handshake() { + continue + } + + // Try EHLO + NoOps + if !tcp_session.imap(config.noop_count) { + continue + } + } else { + // StartTLS... + if !tcp_session.starttls("A1 STARTTLS\n") { + continue + } + + if !tcp_session.imap(config.noop_count) { + continue + } + } + } + + return nil +} + +// NewExperimentMeasurer creates a new ExperimentMeasurer. +func NewExperimentMeasurer(config Config) model.ExperimentMeasurer { + return Measurer{Config: config} +} + +// SummaryKeys contains summary keys for this experiment. +// +// Note that this structure is part of the ABI contract with ooniprobe +// therefore we should be careful when changing it. +type SummaryKeys struct { + //DNSBlocking bool `json:"facebook_dns_blocking"` + //TCPBlocking bool `json:"facebook_tcp_blocking"` + IsAnomaly bool `json:"-"` +} + +// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys. +func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) { + sk := SummaryKeys{IsAnomaly: false} + _, ok := measurement.TestKeys.(*TestKeys) + if !ok { + return sk, errors.New("invalid test keys type") + } + return sk, nil +} diff --git a/internal/engine/experiment/imap/imap_test.go b/internal/engine/experiment/imap/imap_test.go new file mode 100644 index 0000000..1fbfc15 --- /dev/null +++ b/internal/engine/experiment/imap/imap_test.go @@ -0,0 +1,186 @@ +package imap + +import ( + "bufio" + "context" + "crypto/tls" + //"encoding/json" + "errors" + "fmt" + "net" + "strings" + "testing" + + "github.com/ooni/probe-cli/v3/internal/engine/mockable" + "github.com/ooni/probe-cli/v3/internal/model" +) + +func plaintextListener() net.Listener { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + if l, err = net.Listen("tcp6", "[::1]:0"); err != nil { + panic(fmt.Sprintf("httptest: failed to listen on a port: %v", err)) + } + } + return l +} + +func tlsListener(l net.Listener) net.Listener { + return tls.NewListener(l, &tls.Config{}) +} + +func listener_addr(l net.Listener) string { + return l.Addr().String() +} + +func ValidIMAPServer(conn net.Conn) { + for { + command, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + return + } + + if strings.Contains(command, "NOOP") { + conn.Write([]byte("A1 OK NOOP completed.\n")) + } else if command == "STARTTLS" { + conn.Write([]byte("A1 OK Begin TLS negotiation now.\n")) + // TODO: conn.Close does not actually close connection? or does client not detect it? + conn.Close() + return + } + conn.Write([]byte("\n")) + } +} + +func TCPServer(l net.Listener) { + for { + conn, err := l.Accept() + if err != nil { + continue + } + defer conn.Close() + conn.Write([]byte("* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS LOGINDISABLED] howdy, ready.\n")) + ValidIMAPServer(conn) + } +} + +func TestMeasurer_run(t *testing.T) { + // runHelper is an helper function to run this set of tests. + runHelper := func(input string) (*model.Measurement, model.ExperimentMeasurer, error) { + m := NewExperimentMeasurer(Config{}) + if m.ExperimentName() != "imap" { + t.Fatal("invalid experiment name") + } + if m.ExperimentVersion() != "0.0.1" { + t.Fatal("invalid experiment version") + } + ctx := context.Background() + meas := &model.Measurement{ + Input: model.MeasurementTarget(input), + } + sess := &mockable.Session{ + MockableLogger: model.DiscardLogger, + } + callbacks := model.NewPrinterCallbacks(model.DiscardLogger) + err := m.Run(ctx, sess, meas, callbacks) + return meas, m, err + } + + t.Run("with empty input", func(t *testing.T) { + _, _, err := runHelper("") + if !errors.Is(err, errNoInputProvided) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid URL", func(t *testing.T) { + _, _, err := runHelper("\t") + if !errors.Is(err, errInputIsNotAnURL) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with invalid scheme", func(t *testing.T) { + _, _, err := runHelper("https://8.8.8.8:443/") + if !errors.Is(err, errInvalidScheme) { + t.Fatal("unexpected error", err) + } + }) + + t.Run("with broken TLS", func(t *testing.T) { + p := plaintextListener() + defer p.Close() + + l := tlsListener(p) + defer l.Close() + addr := listener_addr(l) + go TCPServer(l) + + meas, m, err := runHelper("imaps://" + addr) + if err != nil { + t.Fatal(err) + } + + tk := meas.TestKeys.(*TestKeys) + + for _, run := range tk.Runs { + for _, handshake := range run.TLSHandshakes { + if *handshake.Failure != "unknown_failure: remote error: tls: unrecognized name" { + t.Fatal("expected unrecognized_name in TLS handshake") + } + } + + if run.NoOpCounter != 0 { + t.Fatalf("expected to not have any noops, not %d noops", run.NoOpCounter) + } + } + + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + }) + + t.Run("with broken starttls", func(t *testing.T) { + l := plaintextListener() + defer l.Close() + addr := listener_addr(l) + + go TCPServer(l) + + meas, m, err := runHelper("imap://" + addr) + if err != nil { + t.Fatal(err) + } + + tk := meas.TestKeys.(*TestKeys) + //bs, _ := json.Marshal(tk) + //fmt.Println(string(bs)) + + for _, run := range tk.Runs { + for _, handshake := range run.TLSHandshakes { + if *handshake.Failure != "unknown_failure: tls: first record does not look like a TLS handshake" { + + t.Fatal("expected broken handshake") + } + } + + if run.NoOpCounter != 0 { + t.Fatalf("expected to not have any noops, not %d noops", run.NoOpCounter) + } + } + + ask, err := m.GetSummaryKeys(meas) + if err != nil { + t.Fatal("cannot obtain summary") + } + summary := ask.(SummaryKeys) + if summary.IsAnomaly { + t.Fatal("expected no anomaly") + } + }) +}