ooni-probe-cli/pkg/oonimkall/session.go

378 lines
12 KiB
Go
Raw Normal View History

package oonimkall
import (
"context"
"encoding/json"
"errors"
"fmt"
"runtime"
"sync"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices"
"github.com/ooni/probe-cli/v3/internal/engine/runtimex"
)
// AtomicInt64 allows us to export atomicx.Int64 variables to
// mobile libraries so we can use them in testing.
type AtomicInt64 struct {
*atomicx.Int64
}
// The following two variables contain metrics pertaining to the number
// of Sessions and Contexts that are currently being used.
var (
ActiveSessions = &AtomicInt64{atomicx.NewInt64()}
ActiveContexts = &AtomicInt64{atomicx.NewInt64()}
)
// Logger is the logger used by a Session. You should implement a class
// compatible with this interface in Java/ObjC and then save a reference
// to this instance in the SessionConfig object. All log messages that
// the Session will generate will be routed to this Logger.
type Logger interface {
Debug(msg string)
Info(msg string)
Warn(msg string)
}
// SessionConfig contains configuration for a Session. You should
// fill all the mandatory fields and could also optionally fill some of
// the optional fields. Then pass this struct to NewSession.
type SessionConfig struct {
// AssetsDir is the mandatory directory where to store assets
// required by a Session, e.g. MaxMind DB files.
AssetsDir string
// Logger is the optional logger that will receive all the
// log messages generated by a Session. If this field is nil
// then the session will not emit any log message.
Logger Logger
// ProbeServicesURL allows you to optionally force the
// usage of an alternative probe service instance. This setting
// should only be used for implementing integration tests.
ProbeServicesURL string
// SoftwareName is the mandatory name of the application
// that will be using the new Session.
SoftwareName string
// SoftwareVersion is the mandatory version of the application
// that will be using the new Session.
SoftwareVersion string
// StateDir is the mandatory directory where to store state
// information required by a Session.
StateDir string
// TempDir is the mandatory directory where the Session shall
// store temporary files. Among other tasks, Session.Close will
// remove any temporary file created within this Session.
TempDir string
// Verbose is optional. If there is a non-null Logger and this
// field is true, then the Logger will also receive Debug messages,
// otherwise it will not receive such messages.
Verbose bool
}
// Session contains shared state for running experiments and/or other
// OONI related task (e.g. geolocation). Note that the Session isn't
// mean to be a long living object. The workflow is to create a Session,
// do the operations you need to do with it now, then make sure it is
// not referenced by other variables, so the Go GC can finalize it.
//
// Future directions
//
// We will eventually rewrite the code for running new experiments such
// that a Task will be created from a Session, such that experiments
// could share the same Session and save geolookups, etc. For now, we
// are in the suboptimal situations where Tasks create, use, and close
// their own session, thus running more lookups than needed.
type Session struct {
cl []context.CancelFunc
mtx sync.Mutex
submitter *probeservices.Submitter
sessp *engine.Session
// Hooks for testing (should not appear in Java/ObjC)
TestingCheckInBeforeNewProbeServicesClient func(ctx *Context)
TestingCheckInBeforeCheckIn func(ctx *Context)
}
// NewSession creates a new session. You should use a session for running
// a set of operations in a relatively short time frame. You SHOULD NOT create
// a single session and keep it all alive for the whole app lifecyle, since
// the Session code is not specifically designed for this use case.
func NewSession(config *SessionConfig) (*Session, error) {
kvstore, err := engine.NewFileSystemKVStore(config.StateDir)
if err != nil {
return nil, err
}
var availableps []model.Service
if config.ProbeServicesURL != "" {
availableps = append(availableps, model.Service{
Address: config.ProbeServicesURL,
Type: "https",
})
}
engineConfig := engine.SessionConfig{
AssetsDir: config.AssetsDir,
AvailableProbeServices: availableps,
KVStore: kvstore,
Logger: newLogger(config.Logger, config.Verbose),
SoftwareName: config.SoftwareName,
SoftwareVersion: config.SoftwareVersion,
TempDir: config.TempDir,
}
sessp, err := engine.NewSession(engineConfig)
if err != nil {
return nil, err
}
sess := &Session{sessp: sessp}
runtime.SetFinalizer(sess, sessionFinalizer)
ActiveSessions.Add(1)
return sess, nil
}
// sessionFinalizer finalizes a Session. While in general in Go code using a
// finalizer is probably unclean, it seems that using a finalizer when binding
// with Java/ObjC code is actually useful to simplify the apps.
func sessionFinalizer(sess *Session) {
for _, fn := range sess.cl {
fn()
}
sess.sessp.Close() // ignore return value
ActiveSessions.Add(-1)
}
// Context is the context of an operation. You use this context
// to cancel a long running operation by calling Cancel(). Because
// you create a Context from a Session and because the Session is
// keeping track of the Context instances it owns, you do don't
// need to call the Cancel method when you're done.
type Context struct {
cancel context.CancelFunc
ctx context.Context
}
// Cancel cancels pending operations using this context.
func (ctx *Context) Cancel() {
ctx.cancel()
}
// NewContext creates an new interruptible Context.
func (sess *Session) NewContext() *Context {
return sess.NewContextWithTimeout(-1)
}
// NewContextWithTimeout creates an new interruptible Context that will automatically
// cancel itself after the given timeout. Setting a zero or negative timeout implies
// there is no actual timeout configured for the Context.
func (sess *Session) NewContextWithTimeout(timeout int64) *Context {
sess.mtx.Lock()
defer sess.mtx.Unlock()
ctx, origcancel := newContext(timeout)
ActiveContexts.Add(1)
var once sync.Once
cancel := func() {
once.Do(func() {
ActiveContexts.Add(-1)
origcancel()
})
}
sess.cl = append(sess.cl, cancel)
return &Context{cancel: cancel, ctx: ctx}
}
// GeolocateResults contains the GeolocateTask results.
type GeolocateResults struct {
// ASN is the autonomous system number.
ASN string
// Country is the country code.
Country string
// IP is the IP address.
IP string
// Org is the commercial name of the ASN.
Org string
}
// MaybeUpdateResources ensures that resources are up to date.
func (sess *Session) MaybeUpdateResources(ctx *Context) error {
sess.mtx.Lock()
defer sess.mtx.Unlock()
return sess.sessp.MaybeUpdateResources(ctx.ctx)
}
// Geolocate performs a geolocate operation and returns the results. This method
// is (in Java terminology) synchronized with the session instance.
func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
info, err := sess.sessp.LookupLocationContext(ctx.ctx)
if err != nil {
return nil, err
}
return &GeolocateResults{
ASN: fmt.Sprintf("AS%d", info.ASN),
Country: info.CountryCode,
IP: info.ProbeIP,
Org: info.NetworkName,
}, nil
}
// SubmitMeasurementResults contains the results of a single measurement submission
// to the OONI backends using the OONI collector API.
type SubmitMeasurementResults struct {
UpdatedMeasurement string
UpdatedReportID string
}
// Submit submits the given measurement and returns the results. This method is (in
// Java terminology) synchronized with the Session instance.
func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
if sess.submitter == nil {
psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
if err != nil {
return nil, err
}
sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger())
}
var mm model.Measurement
if err := json.Unmarshal([]byte(measurement), &mm); err != nil {
return nil, err
}
if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil {
return nil, err
}
data, err := json.Marshal(mm)
runtimex.PanicOnError(err, "json.Marshal should not fail here")
return &SubmitMeasurementResults{
UpdatedMeasurement: string(data),
UpdatedReportID: mm.ReportID,
}, nil
}
// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test
type CheckInConfigWebConnectivity struct {
CategoryCodes []string // CategoryCodes is an array of category codes
}
// Add a category code to the array in CheckInConfigWebConnectivity
func (ckw *CheckInConfigWebConnectivity) Add(cat string) {
ckw.CategoryCodes = append(ckw.CategoryCodes, cat)
}
func (ckw *CheckInConfigWebConnectivity) toModel() model.CheckInConfigWebConnectivity {
return model.CheckInConfigWebConnectivity{
CategoryCodes: ckw.CategoryCodes,
}
}
// CheckInConfig contains configuration for calling the checkin API.
type CheckInConfig struct {
Charging bool // Charging indicate if the phone is actually charging
OnWiFi bool // OnWiFi indicate if the phone is actually connected to a WiFi network
Platform string // Platform of the probe
RunType string // RunType
SoftwareName string // SoftwareName of the probe
SoftwareVersion string // SoftwareVersion of the probe
WebConnectivity *CheckInConfigWebConnectivity // WebConnectivity class contain an array of categories
}
// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API
type CheckInInfoWebConnectivity struct {
ReportID string
URLs []model.URLInfo
}
// URLInfo contains info on a test lists URL
type URLInfo struct {
CategoryCode string
CountryCode string
URL string
}
// Size returns the number of URLs.
func (ckw *CheckInInfoWebConnectivity) Size() int64 {
return int64(len(ckw.URLs))
}
// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs
func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo {
if idx < 0 || int(idx) >= len(ckw.URLs) {
return nil
}
w := ckw.URLs[idx]
return &URLInfo{
CategoryCode: w.CategoryCode,
CountryCode: w.CountryCode,
URL: w.URL,
}
}
func newCheckInInfoWebConnectivity(ckw *model.CheckInInfoWebConnectivity) *CheckInInfoWebConnectivity {
if ckw == nil {
return nil
}
out := new(CheckInInfoWebConnectivity)
out.ReportID = ckw.ReportID
out.URLs = ckw.URLs
return out
}
// CheckInInfo contains the return test objects from the checkin API
type CheckInInfo struct {
WebConnectivity *CheckInInfoWebConnectivity
}
// CheckIn function is called by probes asking if there are tests to be run
// The config argument contains the mandatory settings.
// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure.
func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
if config.WebConnectivity == nil {
return nil, errors.New("oonimkall: missing webconnectivity config")
}
info, err := sess.sessp.LookupLocationContext(ctx.ctx)
if err != nil {
return nil, err
}
if sess.TestingCheckInBeforeNewProbeServicesClient != nil {
sess.TestingCheckInBeforeNewProbeServicesClient(ctx)
}
psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
if err != nil {
return nil, err
}
if sess.TestingCheckInBeforeCheckIn != nil {
sess.TestingCheckInBeforeCheckIn(ctx)
}
cfg := model.CheckInConfig{
Charging: config.Charging,
OnWiFi: config.OnWiFi,
Platform: config.Platform,
ProbeASN: info.ASNString(),
ProbeCC: info.CountryCode,
RunType: config.RunType,
SoftwareVersion: config.SoftwareVersion,
WebConnectivity: config.WebConnectivity.toModel(),
}
result, err := psc.CheckIn(ctx.ctx, cfg)
if err != nil {
return nil, err
}
return &CheckInInfo{
WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity),
}, nil
}