Basic SMTP+StartTLS / SMTPS test

This commit is contained in:
ooni noob 2022-11-19 17:18:46 +01:00
parent 28aabe0947
commit 3cf6f976ae
2 changed files with 336 additions and 0 deletions

View File

@ -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
}

22
internal/registry/smtp.go Normal file
View File

@ -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,
}
}