Compare commits

..

1 Commits

Author SHA1 Message Date
fad69bdef1 Store results/errors from individual ip/port combos (runs) 2022-11-20 21:04:57 +01:00
2 changed files with 113 additions and 329 deletions

View File

@ -8,6 +8,7 @@ import (
"net" "net"
"net/smtp" "net/smtp"
"net/url" "net/url"
"strconv"
"time" "time"
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter" "github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
@ -25,6 +26,9 @@ var (
// errInvalidScheme indicates that the scheme is invalid // errInvalidScheme indicates that the scheme is invalid
errInvalidScheme = errors.New("scheme must be smtp(s)") 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 ( const (
@ -33,16 +37,14 @@ const (
) )
// Config contains the experiment config. // Config contains the experiment config.
type Config struct{} type Config struct {
type RuntimeConfig struct {
host string host string
port string port string
forced_tls bool forced_tls bool
noop_count uint8 noop_count uint8
} }
func config(input model.MeasurementTarget) (*RuntimeConfig, error) { func config(input model.MeasurementTarget) (*Config, error) {
if input == "" { if input == "" {
// TODO: static input data (eg. gmail/riseup..) // TODO: static input data (eg. gmail/riseup..)
return nil, errNoInputProvided return nil, errNoInputProvided
@ -66,11 +68,16 @@ func config(input model.MeasurementTarget) (*RuntimeConfig, error) {
port = "465" port = "465"
} }
} else { } else {
// Valid port is checked by URL parsing // Check that requested port is a valid integer
port = parsed.Port() _, err := strconv.Atoi(parsed.Port())
if err != nil {
return nil, errInvalidPort
} else {
port = parsed.Port()
}
} }
valid_config := RuntimeConfig{ valid_config := Config{
host: parsed.Hostname(), host: parsed.Hostname(),
forced_tls: parsed.Scheme == "smtps", forced_tls: parsed.Scheme == "smtps",
port: port, port: port,
@ -81,13 +88,12 @@ func config(input model.MeasurementTarget) (*RuntimeConfig, error) {
} }
// TestKeys contains the experiment results // TestKeys contains the experiment results
type TestKeys struct { type TestKeys struct {
Queries []*model.ArchivalDNSLookupResult `json:"queries"` Queries []*model.ArchivalDNSLookupResult `json:"queries"`
Runs map[string]*IndividualTestKeys `json:"runs"` Runs map[string]IndividualTestKeys `json:"runs"`
// Used for global failure (DNS resolution) // Used for global failure (DNS resolution)
Failure string `json:"failure"` Failure string `json:"failure"`
// Indicates global failure or individual test failure // Used to indicate global failure state
Failed bool `json:"failed"` Failed bool `json:"failed"`
} }
@ -120,9 +126,9 @@ func (m Measurer) ExperimentVersion() string {
return testVersion return testVersion
} }
// Manages sequential TCP sessions to the same hostname (over different IPs) // Manages sequential SMTP sessions to the same hostname (over different IPs)
// don't use in parallel! // don't use in parallel because addr changed dynamically
type TCPRunner struct { type SMTPRunner struct {
trace *measurexlite.Trace trace *measurexlite.Trace
logger model.Logger logger model.Logger
ctx context.Context ctx context.Context
@ -130,107 +136,35 @@ type TCPRunner struct {
tlsconfig *tls.Config tlsconfig *tls.Config
host string host string
port string port string
// addr is changed everytime TCPRunner.conn(addr) is called // addr is changed everytime SMTPRunner.conn(addr) is called
addr string addr string
} }
type TCPSession struct { func (r *SMTPRunner) run_key() string {
addr string
port string
runner *TCPRunner
tk *IndividualTestKeys
tls bool
raw_conn *net.Conn
tls_conn *net.Conn
}
func (s *TCPSession) Close() {
if s.tls {
var conn = *s.tls_conn
conn.Close()
} else {
var conn = *s.raw_conn
conn.Close()
}
}
func (s *TCPSession) current_conn() net.Conn {
if s.tls {
return *s.tls_conn
} else {
return *s.raw_conn
}
}
func (r *TCPRunner) run_key() string {
return net.JoinHostPort(r.addr, r.port) return net.JoinHostPort(r.addr, r.port)
} }
func (r *TCPRunner) get_run() *IndividualTestKeys { func (r *SMTPRunner) run_error(err error) {
if r.tk.Runs == nil { r.tk.Failed = true
r.tk.Runs = make(map[string]*IndividualTestKeys)
}
key := r.run_key() key := r.run_key()
val, exists := r.tk.Runs[key] // Key is initialized in conn() no need to check here
if exists { entry, _ := r.tk.Runs[key]
return val entry.Failure = tracex.NewFailure(err)
} else { r.tk.Runs[key] = entry
r.tk.Runs[key] = &IndividualTestKeys{}
return r.tk.Runs[key]
}
} }
func (r *TCPRunner) conn(addr string, port string) (*TCPSession, bool) { func (r *SMTPRunner) global_error(err error) {
r.addr = addr r.tk.Failed = true
run := r.get_run() r.tk.Failure = *tracex.NewFailure(err)
s := new(TCPSession)
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 *SMTPRunner) resolve(host string) ([]string, bool) {
dialer := r.trace.NewDialerWithoutResolver(r.logger)
conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, port))
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, tk *IndividualTestKeys) bool {
// Initialize addr field and corresponding errors in TestKeys
s.addr = addr
s.port = port
s.tls = false
s.runner = runner
s.tk = tk
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.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) {
r.logger.Infof("Resolving DNS for %s", host) r.logger.Infof("Resolving DNS for %s", host)
resolver := r.trace.NewStdlibResolver(r.logger) resolver := r.trace.NewStdlibResolver(r.logger)
addrs, err := resolver.LookupHost(r.ctx, host) addrs, err := resolver.LookupHost(r.ctx, host)
r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...) r.tk.Queries = append(r.tk.Queries, r.trace.DNSLookupsFromRoundTrip()...)
if err != nil { if err != nil {
r.tk.Failure = *tracex.NewFailure(err) r.global_error(err)
return []string{}, false return []string{}, false
} }
r.logger.Infof("Finished DNS for %s: %v", host, addrs) r.logger.Infof("Finished DNS for %s: %v", host, addrs)
@ -238,70 +172,99 @@ func (r *TCPRunner) resolve(host string) ([]string, bool) {
return addrs, true return addrs, true
} }
func (s *TCPSession) handshake() bool { func (r *SMTPRunner) get_run() IndividualTestKeys {
if s.tls { if r.tk.Runs == nil {
// TLS already initialized... r.tk.Runs = make(map[string]IndividualTestKeys)
return true
} }
s.runner.logger.Infof("Starting TLS handshake with %s:%s", s.addr, s.port) key := r.run_key()
thx := s.runner.trace.NewTLSHandshakerStdlib(s.runner.logger) val, exists := r.tk.Runs[key]
tconn, _, err := thx.Handshake(s.runner.ctx, *s.raw_conn, s.runner.tlsconfig) if exists {
s.tk.TLSHandshakes = append(s.tk.TLSHandshakes, s.runner.trace.FirstTLSHandshakeOrNil()) return val
if err != nil { } else {
s.error(err) return IndividualTestKeys{}
return false
} }
s.tls = true
s.tls_conn = &tconn
s.runner.logger.Infof("Handshake succeeded")
return true
} }
func (s *TCPSession) starttls(message string) bool { func (r *SMTPRunner) save_run(itk IndividualTestKeys) {
if s.tls { key := r.run_key()
// TLS already initialized... r.tk.Runs[key] = itk
return true }
func (r *SMTPRunner) conn(addr string) (net.Conn, bool) {
// Initialize addr field and corresponding errors in TestKeys
r.logger.Infof("Establishing TCP to %s", addr)
r.addr = addr
run := r.get_run()
dialer := r.trace.NewDialerWithoutResolver(r.logger)
conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, r.port))
run.TCPConnect = append(run.TCPConnect, r.trace.TCPConnects()...)
if err != nil {
r.run_error(err)
return nil, false
} }
r.save_run(run)
return conn, 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)
run := r.get_run()
thx := r.trace.NewTLSHandshakerStdlib(r.logger)
tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig)
run.TLSHandshakes = append(run.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil())
if err != nil {
r.run_error(err)
return nil, false
}
r.save_run(run)
r.logger.Infof("Handshake succeeded")
return tconn, true
}
func (r *SMTPRunner) starttls(conn net.Conn, message string) (net.Conn, bool) {
if message != "" { if message != "" {
s.runner.logger.Infof("Asking for StartTLS upgrade") r.logger.Infof("Asking for StartTLS upgrade")
s.current_conn().Write([]byte(message)) conn.Write([]byte(message))
} }
return s.handshake() tconn, success := r.handshake(conn)
return tconn, success
} }
func (s *TCPSession) smtp(ehlo string, noop uint8) bool { func (r *SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool {
// Auto-choose plaintext/TCP session client, err := smtp.NewClient(conn, ehlo)
client, err := smtp.NewClient(s.current_conn(), ehlo)
if err != nil { if err != nil {
s.error(err) r.run_error(err)
return false return false
} }
err = client.Hello(ehlo) err = client.Hello(ehlo)
if err != nil { if err != nil {
s.error(err) r.run_error(err)
return false return false
} }
if noop > 0 { if noop > 0 {
s.runner.logger.Infof("Trying to generate more no-op traffic") run := r.get_run()
r.logger.Infof("Trying to generate more no-op traffic")
// TODO: noop counter per IP address // TODO: noop counter per IP address
s.tk.NoOpCounter = 0 run.NoOpCounter = 0
for s.tk.NoOpCounter < noop { for run.NoOpCounter < noop {
s.tk.NoOpCounter += 1 run.NoOpCounter += 1
s.runner.logger.Infof("NoOp Iteration %d", s.tk.NoOpCounter) r.logger.Infof("NoOp Iteration %d", run.NoOpCounter)
err = client.Noop() err = client.Noop()
if err != nil { if err != nil {
s.error(err) r.run_error(err)
break break
} }
} }
if s.tk.NoOpCounter == noop { r.save_run(run)
s.runner.logger.Infof("Successfully generated no-op traffic")
if run.NoOpCounter == noop {
r.logger.Infof("Successfully generated no-op traffic")
return true return true
} else { } else {
s.runner.logger.Infof("Failed no-op traffic at iteration %d", s.tk.NoOpCounter) r.logger.Infof("Failed no-op traffic at iteration %d", run.NoOpCounter)
return false return false
} }
} }
@ -314,6 +277,7 @@ func (m Measurer) Run(
ctx context.Context, sess model.ExperimentSession, ctx context.Context, sess model.ExperimentSession,
measurement *model.Measurement, callbacks model.ExperimentCallbacks, measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error { ) error {
log := sess.Logger() log := sess.Logger()
trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved) trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved)
@ -334,7 +298,7 @@ func (m Measurer) Run(
ServerName: config.host, ServerName: config.host,
} }
runner := &TCPRunner{ runner := &SMTPRunner{
trace: trace, trace: trace,
logger: log, logger: log,
ctx: ctx, ctx: ctx,
@ -342,6 +306,7 @@ func (m Measurer) Run(
tlsconfig: &tlsconfig, tlsconfig: &tlsconfig,
host: config.host, host: config.host,
port: config.port, port: config.port,
addr: "",
} }
// First resolve DNS // First resolve DNS
@ -351,34 +316,38 @@ func (m Measurer) Run(
} }
for _, addr := range addrs { for _, addr := range addrs {
tcp_session, success := runner.conn(addr, config.port) conn, success := runner.conn(addr)
if !success { if !success {
continue return nil
} }
defer tcp_session.Close() defer conn.Close()
if config.forced_tls { if config.forced_tls {
// Direct TLS connection // Direct TLS connection
if !tcp_session.handshake() { tconn, success := runner.handshake(conn)
if !success {
continue continue
} }
defer tconn.Close()
// Try EHLO + NoOps // Try EHLO + NoOps
if !tcp_session.smtp("localhost", config.noop_count) { if !runner.smtp(tconn, "localhost", 10) {
continue continue
} }
} else { } else {
// StartTLS... first try plaintext EHLO // StartTLS... first try plaintext EHLO
if !tcp_session.smtp("localhost", 0) { if !runner.smtp(conn, "localhost", 0) {
continue continue
} }
// Upgrade via StartTLS and try EHLO + NoOps // Upgrade via StartTLS and try EHLO + NoOps
if !tcp_session.starttls("STARTTLS\n") { tconn, success := runner.starttls(conn, "STARTTLS\n")
if !success {
continue continue
} }
defer tconn.Close()
if !tcp_session.smtp("localhost", config.noop_count) { if !runner.smtp(tconn, "localhost", 10) {
continue continue
} }
} }

View File

@ -1,185 +0,0 @@
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")
}
})
}