334 lines
8.4 KiB
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
|
|
}
|