2022-11-19 17:18:46 +01:00
|
|
|
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 {
|
2022-11-20 21:04:57 +01:00
|
|
|
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
|
|
|
|
Runs map[string]IndividualTestKeys `json:"runs"`
|
2022-11-19 17:18:46 +01:00
|
|
|
// Used for global failure (DNS resolution)
|
|
|
|
Failure string `json:"failure"`
|
2022-11-20 21:04:57 +01:00
|
|
|
// Used to indicate global failure state
|
|
|
|
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"`
|
2022-11-19 17:18:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2022-11-20 18:22:42 +01:00
|
|
|
// Manages sequential SMTP sessions to the same hostname (over different IPs)
|
2022-11-20 21:04:57 +01:00
|
|
|
// don't use in parallel because addr changed dynamically
|
2022-11-19 17:18:46 +01:00
|
|
|
type SMTPRunner struct {
|
|
|
|
trace *measurexlite.Trace
|
|
|
|
logger model.Logger
|
|
|
|
ctx context.Context
|
|
|
|
tk *TestKeys
|
|
|
|
tlsconfig *tls.Config
|
2022-11-20 18:22:42 +01:00
|
|
|
host string
|
|
|
|
port string
|
|
|
|
// addr is changed everytime SMTPRunner.conn(addr) is called
|
|
|
|
addr string
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) run_key() string {
|
|
|
|
return net.JoinHostPort(r.addr, r.port)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *SMTPRunner) run_error(err error) {
|
|
|
|
r.tk.Failed = true
|
|
|
|
key := r.run_key()
|
2022-11-20 18:30:15 +01:00
|
|
|
// Key is initialized in conn() no need to check here
|
2022-11-20 21:04:57 +01:00
|
|
|
entry, _ := r.tk.Runs[key]
|
|
|
|
entry.Failure = tracex.NewFailure(err)
|
|
|
|
r.tk.Runs[key] = entry
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *SMTPRunner) global_error(err error) {
|
|
|
|
r.tk.Failed = true
|
|
|
|
r.tk.Failure = *tracex.NewFailure(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) resolve(host string) ([]string, bool) {
|
2022-11-19 17:18:46 +01:00
|
|
|
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 {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.global_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
return []string{}, false
|
|
|
|
}
|
|
|
|
r.logger.Infof("Finished DNS for %s: %v", host, addrs)
|
|
|
|
|
|
|
|
return addrs, true
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) 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 {
|
|
|
|
return IndividualTestKeys{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *SMTPRunner) save_run(itk IndividualTestKeys) {
|
|
|
|
key := r.run_key()
|
|
|
|
r.tk.Runs[key] = itk
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *SMTPRunner) conn(addr string) (net.Conn, bool) {
|
2022-11-20 18:30:15 +01:00
|
|
|
// Initialize addr field and corresponding errors in TestKeys
|
2022-11-20 21:04:57 +01:00
|
|
|
r.logger.Infof("Establishing TCP to %s", addr)
|
2022-11-20 18:22:42 +01:00
|
|
|
r.addr = addr
|
2022-11-20 21:04:57 +01:00
|
|
|
run := r.get_run()
|
2022-11-20 18:30:15 +01:00
|
|
|
|
2022-11-19 17:18:46 +01:00
|
|
|
dialer := r.trace.NewDialerWithoutResolver(r.logger)
|
2022-11-20 21:04:57 +01:00
|
|
|
conn, err := dialer.DialContext(r.ctx, "tcp", net.JoinHostPort(addr, r.port))
|
|
|
|
run.TCPConnect = append(run.TCPConnect, r.trace.TCPConnects()...)
|
2022-11-19 17:18:46 +01:00
|
|
|
if err != nil {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.run_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
return nil, false
|
|
|
|
}
|
2022-11-20 21:04:57 +01:00
|
|
|
r.save_run(run)
|
2022-11-19 17:18:46 +01:00
|
|
|
return conn, true
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) handshake(conn net.Conn) (net.Conn, bool) {
|
2022-11-20 18:22:42 +01:00
|
|
|
r.logger.Infof("Starting TLS handshake with %s:%s (%s)", r.host, r.port, r.addr)
|
2022-11-20 21:04:57 +01:00
|
|
|
run := r.get_run()
|
2022-11-19 17:18:46 +01:00
|
|
|
thx := r.trace.NewTLSHandshakerStdlib(r.logger)
|
|
|
|
tconn, _, err := thx.Handshake(r.ctx, conn, r.tlsconfig)
|
2022-11-20 21:04:57 +01:00
|
|
|
run.TLSHandshakes = append(run.TLSHandshakes, r.trace.FirstTLSHandshakeOrNil())
|
2022-11-19 17:18:46 +01:00
|
|
|
if err != nil {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.run_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
return nil, false
|
|
|
|
}
|
2022-11-20 21:04:57 +01:00
|
|
|
r.save_run(run)
|
2022-11-19 17:18:46 +01:00
|
|
|
r.logger.Infof("Handshake succeeded")
|
|
|
|
return tconn, true
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) starttls(conn net.Conn, message string) (net.Conn, bool) {
|
2022-11-20 18:22:42 +01:00
|
|
|
if message != "" {
|
|
|
|
r.logger.Infof("Asking for StartTLS upgrade")
|
|
|
|
conn.Write([]byte(message))
|
|
|
|
}
|
|
|
|
tconn, success := r.handshake(conn)
|
2022-11-19 17:18:46 +01:00
|
|
|
return tconn, success
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
func (r *SMTPRunner) smtp(conn net.Conn, ehlo string, noop uint8) bool {
|
2022-11-19 17:18:46 +01:00
|
|
|
client, err := smtp.NewClient(conn, ehlo)
|
|
|
|
if err != nil {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.run_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
err = client.Hello(ehlo)
|
|
|
|
if err != nil {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.run_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
if noop > 0 {
|
2022-11-20 21:04:57 +01:00
|
|
|
run := r.get_run()
|
2022-11-19 17:18:46 +01:00
|
|
|
r.logger.Infof("Trying to generate more no-op traffic")
|
|
|
|
// TODO: noop counter per IP address
|
2022-11-20 21:04:57 +01:00
|
|
|
run.NoOpCounter = 0
|
|
|
|
for run.NoOpCounter < noop {
|
|
|
|
run.NoOpCounter += 1
|
|
|
|
r.logger.Infof("NoOp Iteration %d", run.NoOpCounter)
|
2022-11-19 17:18:46 +01:00
|
|
|
err = client.Noop()
|
|
|
|
if err != nil {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.run_error(err)
|
2022-11-19 17:18:46 +01:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
r.save_run(run)
|
|
|
|
|
|
|
|
if run.NoOpCounter == noop {
|
2022-11-19 17:18:46 +01:00
|
|
|
r.logger.Infof("Successfully generated no-op traffic")
|
|
|
|
return true
|
|
|
|
} else {
|
2022-11-20 21:04:57 +01:00
|
|
|
r.logger.Infof("Failed no-op traffic at iteration %d", run.NoOpCounter)
|
2022-11-19 17:18:46 +01:00
|
|
|
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,
|
|
|
|
}
|
|
|
|
|
2022-11-20 21:04:57 +01:00
|
|
|
runner := &SMTPRunner{
|
2022-11-19 17:18:46 +01:00
|
|
|
trace: trace,
|
|
|
|
logger: log,
|
|
|
|
ctx: ctx,
|
|
|
|
tk: tk,
|
|
|
|
tlsconfig: &tlsconfig,
|
2022-11-20 18:22:42 +01:00
|
|
|
host: config.host,
|
|
|
|
port: config.port,
|
|
|
|
addr: "",
|
2022-11-19 17:18:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// First resolve DNS
|
|
|
|
addrs, success := runner.resolve(config.host)
|
|
|
|
if !success {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, addr := range addrs {
|
2022-11-20 18:22:42 +01:00
|
|
|
conn, success := runner.conn(addr)
|
2022-11-19 17:18:46 +01:00
|
|
|
if !success {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
defer conn.Close()
|
|
|
|
|
|
|
|
if config.forced_tls {
|
|
|
|
// Direct TLS connection
|
2022-11-20 18:22:42 +01:00
|
|
|
tconn, success := runner.handshake(conn)
|
2022-11-19 17:18:46 +01:00
|
|
|
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
|
2022-11-20 18:22:42 +01:00
|
|
|
tconn, success := runner.starttls(conn, "STARTTLS\n")
|
2022-11-19 17:18:46 +01:00
|
|
|
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
|
|
|
|
}
|