// Command miniooni is a simple binary for research and QA purposes // with a CLI interface similar to MK and OONI Probe v2.x. package main import ( "context" "fmt" "os" "path" "runtime/debug" "strings" "time" "github.com/apex/log" "github.com/ooni/probe-cli/v3/internal/engine" "github.com/ooni/probe-cli/v3/internal/humanize" "github.com/ooni/probe-cli/v3/internal/legacy/assetsdir" "github.com/ooni/probe-cli/v3/internal/logx" "github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/registry" "github.com/ooni/probe-cli/v3/internal/runtimex" "github.com/ooni/probe-cli/v3/internal/version" "github.com/spf13/cobra" ) // Options contains the options you can set from the CLI. type Options struct { Annotations []string Emoji bool ExtraOptions []string HomeDir string Inputs []string InputFilePaths []string MaxRuntime int64 NoJSON bool NoCollector bool ProbeServicesURL string Proxy string Random bool RepeatEvery int64 ReportFile string SnowflakeRendezvous string TorArgs []string TorBinary string Tunnel string Verbose bool Yes bool } // main is the main function of miniooni. func main() { var globalOptions Options rootCmd := &cobra.Command{ Use: "miniooni", Short: "miniooni is OONI's research client", Args: cobra.NoArgs, Version: version.Version, } rootCmd.SetVersionTemplate("{{ .Version }}\n") flags := rootCmd.PersistentFlags() flags.StringSliceVarP( &globalOptions.Annotations, "annotation", "A", []string{}, "add KEY=VALUE annotation to the report (can be repeated multiple times)", ) flags.BoolVar( &globalOptions.Emoji, "emoji", false, "whether to use emojis when logging", ) flags.StringVar( &globalOptions.HomeDir, "home", "", "force specific home directory", ) flags.BoolVarP( &globalOptions.NoJSON, "no-json", "N", false, "disable writing to disk", ) flags.BoolVarP( &globalOptions.NoCollector, "no-collector", "n", false, "do not submit measurements to the OONI collector", ) flags.StringVar( &globalOptions.ProbeServicesURL, "probe-services", "", "URL of the OONI backend instance you want to use", ) flags.StringVar( &globalOptions.Proxy, "proxy", "", "set proxy URL to communicate with the OONI backend (mutually exclusive with --tunnel)", ) flags.Int64Var( &globalOptions.RepeatEvery, "repeat-every", 0, "wait the given number of seconds and then repeat the same measurement", ) flags.StringVarP( &globalOptions.ReportFile, "reportfile", "o", "", "set the output report file path (default: \"report.jsonl\")", ) flags.StringVar( &globalOptions.SnowflakeRendezvous, "snowflake-rendezvous", "domain_fronting", "rendezvous method for --tunnel=torsf (one of: \"domain_fronting\" and \"amp\")", ) flags.StringSliceVar( &globalOptions.TorArgs, "tor-args", []string{}, "extra arguments for the tor binary (may be specified multiple times)", ) flags.StringVar( &globalOptions.TorBinary, "tor-binary", "", "execute a specific tor binary", ) flags.StringVar( &globalOptions.Tunnel, "tunnel", "", "tunnel to use to communicate with the OONI backend (one of: psiphon, tor, torsf)", ) flags.BoolVarP( &globalOptions.Verbose, "verbose", "v", false, "increase verbosity level", ) flags.BoolVarP( &globalOptions.Yes, "yes", "y", false, "assume yes as the answer to all questions", ) rootCmd.MarkFlagsMutuallyExclusive("proxy", "tunnel") registerAllExperiments(rootCmd, &globalOptions) registerOONIRun(rootCmd, &globalOptions) if err := rootCmd.Execute(); err != nil { os.Exit(1) } } // TODO(bassosimone): the current implementation is basically a cobra application // where we hammered the previous miniooni code to make it work. We should // obviously strive for more correctness. For example, it's a bit disgusting // that MainWithConfiguration is invoked for both oonirun and random experiments. // registerOONIRun registers the oonirun subcommand func registerOONIRun(rootCmd *cobra.Command, globalOptions *Options) { subCmd := &cobra.Command{ Use: "oonirun", Short: "Runs a given OONI Run v2 link", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { MainWithConfiguration(cmd.Use, globalOptions) }, } rootCmd.AddCommand(subCmd) flags := subCmd.Flags() flags.StringSliceVarP( &globalOptions.Inputs, "input", "i", []string{}, "URL of the OONI Run v2 descriptor to run (may be specified multiple times)", ) flags.StringSliceVarP( &globalOptions.InputFilePaths, "input-file", "f", []string{}, "Path to the OONI Run v2 descriptor to run (may be specified multiple times)", ) } // registerAllExperiments registers a subcommand for each experiment func registerAllExperiments(rootCmd *cobra.Command, globalOptions *Options) { for name, factory := range registry.AllExperiments { subCmd := &cobra.Command{ Use: name, Short: fmt.Sprintf("Runs the %s experiment", name), Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { MainWithConfiguration(cmd.Use, globalOptions) }, } rootCmd.AddCommand(subCmd) flags := subCmd.Flags() switch factory.InputPolicy() { case model.InputOrQueryBackend, model.InputStrictlyRequired, model.InputOptional, model.InputOrStaticDefault: flags.StringSliceVarP( &globalOptions.InputFilePaths, "input-file", "f", []string{}, "path to file to supply test dependent input (may be specified multiple times)", ) flags.StringSliceVarP( &globalOptions.Inputs, "input", "i", []string{}, "add test-dependent input (may be specified multiple times)", ) flags.Int64Var( &globalOptions.MaxRuntime, "max-runtime", 0, "maximum runtime in seconds for the experiment (zero means infinite)", ) flags.BoolVar( &globalOptions.Random, "random", false, "randomize the inputs list", ) default: // nothing } if doc := documentationForOptions(name, factory); doc != "" { flags.StringSliceVarP( &globalOptions.ExtraOptions, "option", "O", []string{}, doc, ) } } } // MainWithConfiguration is the miniooni main with a specific configuration // represented by the experiment name and the current options. // // This function will panic in case of a fatal error. It is up to you that // integrate this function to either handle the panic of ignore it. func MainWithConfiguration(experimentName string, currentOptions *Options) { runtimex.PanicOnError(engine.CheckEmbeddedPsiphonConfig(), "Invalid embedded psiphon config") if currentOptions.Tunnel != "" { currentOptions.Proxy = fmt.Sprintf("%s:///", currentOptions.Tunnel) } logHandler := logx.NewHandlerWithDefaultSettings() logHandler.Emoji = currentOptions.Emoji logger := &log.Logger{Level: log.InfoLevel, Handler: logHandler} if currentOptions.Verbose { logger.Level = log.DebugLevel } if currentOptions.ReportFile == "" { currentOptions.ReportFile = "report.jsonl" } log.Log = logger for { mainSingleIteration(logger, experimentName, currentOptions) if currentOptions.RepeatEvery <= 0 { break } log.Infof("waiting %ds before repeating the measurement", currentOptions.RepeatEvery) log.Info("use Ctrl-C to interrupt miniooni") time.Sleep(time.Duration(currentOptions.RepeatEvery) * time.Second) } } // mainSingleIteration runs a single iteration. There may be multiple iterations // when the user specifies the --repeat-every command line flag. func mainSingleIteration(logger model.Logger, experimentName string, currentOptions *Options) { // We allow the inner code to fail but we stop propagating the panic here // such that --repeat-every works as intended anyway if currentOptions.RepeatEvery > 0 { defer func() { if r := recover(); r != nil { log.Warnf("recovered from panic: %+v\n%s\n", r, debug.Stack()) } }() } extraOptions := mustMakeMapStringAny(currentOptions.ExtraOptions) annotations := mustMakeMapStringString(currentOptions.Annotations) ctx := context.Background() //Mon Jan 2 15:04:05 -0700 MST 2006 log.Infof("Current time: %s", time.Now().Format("2006-01-02 15:04:05 MST")) homeDir := gethomedir(currentOptions.HomeDir) runtimex.Assert(homeDir != "", "home directory is empty") miniooniDir := path.Join(homeDir, ".miniooni") err := os.MkdirAll(miniooniDir, 0700) runtimex.PanicOnError(err, "cannot create $HOME/.miniooni directory") // We cleanup the assets files used by versions of ooniprobe // older than v3.9.0, where we started embedding the assets // into the binary and use that directly. This cleanup doesn't // remove the whole directory but only known files inside it // and then the directory itself, if empty. We explicitly discard // the return value as it does not matter to us here. assetsDir := path.Join(miniooniDir, "assets") _, _ = assetsdir.Cleanup(assetsDir) log.Debugf("miniooni state directory: %s", miniooniDir) log.Info("miniooni home directory: $HOME/.miniooni") acquireUserConsent(miniooniDir, currentOptions) sess := newSessionOrPanic(ctx, currentOptions, miniooniDir, logger) defer func() { sess.Close() log.Infof("whole session: recv %s, sent %s", humanize.SI(sess.KibiBytesReceived()*1024, "byte"), humanize.SI(sess.KibiBytesSent()*1024, "byte"), ) }() lookupBackendsOrPanic(ctx, sess) lookupLocationOrPanic(ctx, sess) // We handle the oonirun experiment name specially. The user must specify // `miniooni -i {OONIRunURL} oonirun` to run a OONI Run URL (v1 or v2). if experimentName == "oonirun" { ooniRunMain(ctx, sess, currentOptions, annotations) return } // Otherwise just run OONI experiments as we normally do. runx(ctx, sess, experimentName, annotations, extraOptions, currentOptions) } func documentationForOptions(name string, factory *registry.Factory) string { var sb strings.Builder options, err := factory.Options() if err != nil || len(options) < 1 { return "" } fmt.Fprint(&sb, "Pass KEY=VALUE options to the experiment. Available options:\n") for name, info := range options { if info.Doc == "" { continue } fmt.Fprintf(&sb, "\n") fmt.Fprintf(&sb, " -O, --option %s=<%s>\n", name, info.Type) fmt.Fprintf(&sb, " %s\n", info.Doc) } return sb.String() }