ooni-probe-cli/internal/engine/experiment/smtp/smtp.go

334 lines
8.4 KiB
Go

package smtp
import (
"context"
"crypto/tls"
"fmt"
"github.com/pkg/errors"
"net/smtp"
"net/url"
"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/tcprunner"
"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)")
)
const (
testName = "smtp"
testVersion = "0.0.1"
)
// Config contains the experiment config.
type Config struct{}
type runtimeConfig struct {
host string
port string
forcedTLS bool
noopCount uint8
}
func config(input model.MeasurementTarget) (*runtimeConfig, 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 {
// Valid port is checked by URL parsing
port = parsed.Port()
}
validConfig := runtimeConfig{
host: parsed.Hostname(),
forcedTLS: parsed.Scheme == "smtps",
port: port,
noopCount: 10,
}
return &validConfig, nil
}
// TestKeys contains the experiment results for an entire domain host
type TestKeys struct {
Host string `json:"hostname"`
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
// Individual IP/port results
Runs []*IndividualTestKeys `json:"runs"`
// Used for global failure (DNS resolution)
Failure string `json:"failure"`
}
func newTestKeys(host string) *TestKeys {
tk := new(TestKeys)
tk.Host = host
return tk
}
// Hostname TCPRunnerModel
func (tk *TestKeys) Hostname(host string) {
tk.Host = host
}
// DNSResults TCPRunnerModel
func (tk *TestKeys) DNSResults(res []*model.ArchivalDNSLookupResult) {
// TODO: not sure if we are passed the overall trace results and should overwrite key, or just append
tk.Queries = append(tk.Queries, res...)
}
// Failed TCPRunnerModel
func (tk *TestKeys) Failed(msg string) {
tk.Failure = msg
}
// NewRun TCPRunnerModel
func (tk *TestKeys) NewRun(addr string, port string) tcprunner.TCPSessionModel {
itk := newIndividualTestKeys(addr, port)
tk.Runs = append(tk.Runs, itk)
return itk
}
// IndividualTestKeys contains the experiment results for a single IP/port combo
type IndividualTestKeys struct {
TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"`
TLSHandshake *model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"`
Failure string `json:"failure"`
FailureStep string `json:"failed_step"`
IP string `json:"ip"`
Port string `json:"port"`
noopCounter uint8
}
func newIndividualTestKeys(addr string, port string) *IndividualTestKeys {
itk := new(IndividualTestKeys)
itk.IP = addr
itk.Port = port
return itk
}
// IPPort TCPSessionModel
func (itk *IndividualTestKeys) IPPort(ip string, port string) {
itk.IP = ip
itk.Port = port
}
// ConnectResults TCPSessionModel
func (itk *IndividualTestKeys) ConnectResults(res []*model.ArchivalTCPConnectResult) {
itk.TCPConnect = append(itk.TCPConnect, res...)
}
// HandshakeResult TCPSessionModel
func (itk *IndividualTestKeys) HandshakeResult(res *model.ArchivalTLSOrQUICHandshakeResult) {
itk.TLSHandshake = res
}
// FailedStep TCPSessionModel
func (itk *IndividualTestKeys) FailedStep(failure string, step string) {
itk.Failure = failure
itk.FailureStep = step
}
// Measurer performs the measurement.
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
}
// Run implements ExperimentMeasurer.Run
func (m Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error {
sess := args.Session
measurement := args.Measurement
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, 30*time.Second)
defer cancel()
tlsconfig := tls.Config{
InsecureSkipVerify: false,
ServerName: config.host,
}
runner := &tcprunner.TCPRunner{
Tk: tk,
Trace: trace,
Logger: log,
Ctx: ctx,
Tlsconfig: &tlsconfig,
}
// First resolve DNS
addrs, success := runner.Resolve(config.host)
if !success {
return nil
}
for _, addr := range addrs {
tcpSession, success := runner.Conn(addr, config.port)
if !success {
continue
}
defer tcpSession.Close()
if config.forcedTLS {
log.Infof("Running direct TLS mode to %s:%s", addr, config.port)
if !tcpSession.Handshake() {
continue
}
// Try EHLO + NoOps
if !testSMTP(tcpSession, "localhost", config.noopCount) {
continue
}
} else {
log.Infof("Running StartTLS mode to %s:%s", addr, config.port)
if !testSMTP(tcpSession, "localhost", 0) {
continue
}
// Upgrade via StartTLS and try EHLO + NoOps
if !tcpSession.StartTLS("STARTTLS\n", "TLS") {
continue
}
if !testSMTP(tcpSession, "localhost", config.noopCount) {
continue
}
}
}
return nil
}
func testSMTP(s *tcprunner.TCPSession, ehlo string, noop uint8) bool {
// Auto-choose plaintext/TCP session
// TODO: move to Debugf
s.Runner.Logger.Infof("Retrieving existing connection")
conn := s.CurrentConn()
s.Runner.Logger.Infof("Initializing SMTP client")
client, err := smtp.NewClient(conn, ehlo)
if err != nil {
s.FailedStep(*tracex.NewFailure(err), "smtp_init")
return false
}
s.Runner.Logger.Infof("Starting SMTP EHLO")
err = client.Hello(ehlo)
if err != nil {
if s.TLS {
s.FailedStep(*tracex.NewFailure(err), "smtp_tls_ehlo")
} else {
s.FailedStep(*tracex.NewFailure(err), "smtp_plaintext_ehlo")
}
return false
}
s.Runner.Logger.Infof("Finished SMTP EHLO")
if noop > 0 {
// Downcast TCPSession's itk into typed IndividualTestKeys to access noopCounter field
concreteITK := s.Itk.(*IndividualTestKeys)
s.Runner.Logger.Infof("Trying to generate more no-op traffic")
concreteITK.noopCounter = 0
for concreteITK.noopCounter < noop {
concreteITK.noopCounter++
s.Runner.Logger.Infof("NoOp Iteration %d", concreteITK.noopCounter)
err = client.Noop()
if err != nil {
s.FailedStep(*tracex.NewFailure(err), fmt.Sprintf("smtp_noop_%d", concreteITK.noopCounter))
break
}
}
if concreteITK.noopCounter == noop {
s.Runner.Logger.Infof("Successfully generated no-op traffic")
return true
}
s.Runner.Logger.Warnf("Failed no-op traffic at iteration %d", concreteITK.noopCounter)
return false
}
return true
}
// 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
}