// Copyright (c) 2024 Proton AG // // This file is part of Proton Mail Bridge. // // Proton Mail Bridge is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Proton Mail Bridge is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Proton Mail Bridge. If not, see . package app import ( "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "os" "path/filepath" "runtime" "time" "github.com/Masterminds/semver/v3" "github.com/ProtonMail/gluon/async" "github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/constants" "github.com/ProtonMail/proton-bridge/v3/internal/cookies" "github.com/ProtonMail/proton-bridge/v3/internal/crash" "github.com/ProtonMail/proton-bridge/v3/internal/events" "github.com/ProtonMail/proton-bridge/v3/internal/focus" "github.com/ProtonMail/proton-bridge/v3/internal/frontend/theme" "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/sentry" "github.com/ProtonMail/proton-bridge/v3/internal/useragent" "github.com/ProtonMail/proton-bridge/v3/internal/vault" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/ProtonMail/proton-bridge/v3/pkg/restarter" "github.com/elastic/go-sysinfo" "github.com/pkg/profile" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) // Visible flags. const ( flagCPUProfile = "cpu-prof" flagCPUProfileShort = "p" flagTraceProfile = "trace-prof" flagTraceProfileShort = "t" flagMemProfile = "mem-prof" flagMemProfileShort = "m" flagLogLevel = "log-level" flagLogLevelShort = "l" flagGRPC = "grpc" flagGRPCShort = "g" flagCLI = "cli" flagCLIShort = "c" flagNonInteractive = "noninteractive" flagNonInteractiveShort = "n" flagLogIMAP = "log-imap" flagLogSMTP = "log-smtp" ) // Hidden flags. const ( flagLauncher = "launcher" flagNoWindow = "no-window" flagParentPID = "parent-pid" flagSoftwareRenderer = "software-renderer" flagSessionID = "session-id" ) const ( appUsage = "Proton Mail IMAP and SMTP Bridge" appShortName = "bridge" ) func New() *cli.App { app := cli.NewApp() app.Name = constants.FullAppName app.Usage = appUsage app.Flags = []cli.Flag{ &cli.BoolFlag{ Name: flagCPUProfile, Aliases: []string{flagCPUProfileShort}, Usage: "Generate CPU profile", }, &cli.BoolFlag{ Name: flagTraceProfile, Aliases: []string{flagTraceProfileShort}, Usage: "Generate Trace profile", }, &cli.BoolFlag{ Name: flagMemProfile, Aliases: []string{flagMemProfileShort}, Usage: "Generate memory profile", }, &cli.StringFlag{ Name: flagLogLevel, Aliases: []string{flagLogLevelShort}, Usage: "Set the log level (one of panic, fatal, error, warn, info, debug)", }, &cli.BoolFlag{ Name: flagGRPC, Aliases: []string{flagGRPCShort}, Usage: "Start the gRPC service", }, &cli.BoolFlag{ Name: flagCLI, Aliases: []string{flagCLIShort}, Usage: "Start the command line interface", }, &cli.BoolFlag{ Name: flagNonInteractive, Aliases: []string{flagNonInteractiveShort}, Usage: "Start the app in non-interactive mode", }, &cli.StringFlag{ Name: flagLogIMAP, Usage: "Enable logging of IMAP communications (all|client|server) (may contain decrypted data!)", }, &cli.BoolFlag{ Name: flagLogSMTP, Usage: "Enable logging of SMTP communications (may contain decrypted data!)", }, // Hidden flags &cli.BoolFlag{ Name: flagNoWindow, Usage: "Don't show window after start", Hidden: true, }, &cli.StringFlag{ Name: flagLauncher, Usage: "The launcher used to start the app", Hidden: true, }, &cli.IntFlag{ Name: flagParentPID, Usage: "Process ID of the parent", Hidden: true, Value: -1, }, &cli.BoolFlag{ Name: flagSoftwareRenderer, // This flag is ignored by bridge, but should be passed to launcher in case of restart, so it need to be accepted by the CLI parser. Usage: "GUI is using software renderer", Hidden: true, Value: false, }, &cli.StringFlag{ Name: flagSessionID, Hidden: true, }, } app.Action = run return app } func run(c *cli.Context) error { // Get the current bridge version. version, err := semver.NewVersion(constants.Version) if err != nil { return fmt.Errorf("could not create version: %w", err) } // Create a user agent that will be used for all requests. identifier := useragent.New() // Create a new Sentry client that will be used to report crashes etc. reporter := sentry.NewReporter(constants.FullAppName, identifier) // Determine the exe that should be used to restart/autostart the app. // By default, this is the launcher, if used. Otherwise, we try to get // the current exe, and fall back to os.Args[0] if that fails. var exe string if launcher := c.String(flagLauncher); launcher != "" { exe = launcher } else if executable, err := os.Executable(); err == nil { exe = executable } else { exe = os.Args[0] } var logCloser io.Closer defer func() { _ = logging.Close(logCloser) }() // Restart the app if requested. err = withRestarter(exe, func(restarter *restarter.Restarter) error { // Handle crashes with various actions. return withCrashHandler(restarter, reporter, func(crashHandler *crash.Handler, quitCh <-chan struct{}) error { migrationErr := migrateOldVersions() // Run with profiling if requested. return withProfiler(c, func() error { // Load the locations where we store our files. return WithLocations(func(locations *locations.Locations) error { // Migrate the keychain helper. if err := migrateKeychainHelper(locations); err != nil { logrus.WithError(err).Error("Failed to migrate keychain helper") } // Initialize logging. return withLogging(c, crashHandler, locations, func(closer io.Closer) error { logCloser = closer // If there was an error during migration, log it now. if migrationErr != nil { logrus.WithError(migrationErr).Error("Failed to migrate old app data") } // Ensure we are the only instance running. settings, err := locations.ProvideSettingsPath() if err != nil { logrus.WithError(err).Error("Failed to get settings path") } return withSingleInstance(settings, locations.GetLockFile(), version, func() error { // Look for available keychains return WithKeychainList(crashHandler, func(keychains *keychain.List) error { // Unlock the encrypted vault. return WithVault(locations, keychains, crashHandler, func(v *vault.Vault, insecure, corrupt bool) error { if !v.Migrated() { // Migrate old settings into the vault. if err := migrateOldSettings(v); err != nil { logrus.WithError(err).Error("Failed to migrate old settings") } // Migrate old accounts into the vault. if err := migrateOldAccounts(locations, keychains, v); err != nil { logrus.WithError(err).Error("Failed to migrate old accounts") } // The vault has been migrated. if err := v.SetMigrated(); err != nil { logrus.WithError(err).Error("Failed to mark vault as migrated") } } logrus.WithFields(logrus.Fields{ "lastVersion": v.GetLastVersion().String(), "showAllMail": v.GetShowAllMail(), "updateCh": v.GetUpdateChannel(), "autoUpdate": v.GetAutoUpdate(), "rollout": v.GetUpdateRollout(), "DoH": v.GetProxyAllowed(), }).Info("Vault loaded") // Load the cookies from the vault. return withCookieJar(v, func(cookieJar http.CookieJar) error { // Create a new bridge instance. return withBridge(c, exe, locations, version, identifier, crashHandler, reporter, v, cookieJar, keychains, func(b *bridge.Bridge, eventCh <-chan events.Event) error { if insecure { logrus.Warn("The vault key could not be retrieved; the vault will not be encrypted") b.PushError(bridge.ErrVaultInsecure) } if corrupt { logrus.Warn("The vault is corrupt and has been wiped") b.PushError(bridge.ErrVaultCorrupt) } // Remove old updates files b.RemoveOldUpdates() // Run the frontend. return runFrontend(c, crashHandler, restarter, locations, b, eventCh, quitCh, c.Int(flagParentPID)) }) }) }) }) }) }) }) }) }) }) // if an error occurs, it must be logged now because we're about to close the log file. if err != nil { logrus.Fatal(err) } return err } // If there's another instance already running, try to raise it and exit. func withSingleInstance(settingPath, lockFile string, version *semver.Version, fn func() error) error { logrus.Debug("Checking for other instances") defer logrus.Debug("Single instance stopped") lock, err := checkSingleInstance(settingPath, lockFile, version) if err != nil { logrus.Info("Another instance is already running; raising it") if ok := focus.TryRaise(settingPath); !ok { return fmt.Errorf("another instance is already running but it could not be raised") } logrus.Info("The other instance has been raised") return nil } defer func() { if err := lock.Close(); err != nil { logrus.WithError(err).Error("Failed to close lock file") } }() return fn() } // Initialize our logging system. func withLogging(c *cli.Context, crashHandler *crash.Handler, locations *locations.Locations, fn func(closer io.Closer) error) error { logrus.Debug("Initializing logging") defer logrus.Debug("Logging stopped") // Get a place to keep our logs. logsPath, err := locations.ProvideLogsPath() if err != nil { return fmt.Errorf("could not provide logs path: %w", err) } logrus.WithField("path", logsPath).Debug("Received logs path") // Initialize logging. sessionID := logging.NewSessionIDFromString(c.String(flagSessionID)) var closer io.Closer if closer, err = logging.Init( logsPath, sessionID, logging.BridgeShortAppName, logging.DefaultMaxLogFileSize, logging.DefaultPruningSize, c.String(flagLogLevel), ); err != nil { return fmt.Errorf("could not initialize logging: %w", err) } // Ensure we dump a stack trace if we crash. crashHandler.AddRecoveryAction(logging.DumpStackTrace(logsPath, sessionID, appShortName)) logrus. WithField("appName", constants.FullAppName). WithField("version", constants.Version). WithField("revision", constants.Revision). WithField("tag", constants.Tag). WithField("build", constants.BuildTime). WithField("runtime", runtime.GOOS). WithField("args", os.Args). WithField("SentryID", sentry.GetProtectedHostname()). Info("Run app") now := time.Now() logrus. WithField("timeZone", now.Format("MST")). WithField("offset", now.Format("-07:00:00")). Info("Time zone info") host, err := sysinfo.Host() if err != nil { logrus.WithError(err).Error("Could not retrieve operating system info") } else { osInfo := host.Info().OS logrus. WithField("name", osInfo.Name). WithField("version", osInfo.Version). WithField("build", osInfo.Build). Info("Operating system info") } return fn(closer) } // WithLocations provides access to locations where we store our files. func WithLocations(fn func(*locations.Locations) error) error { logrus.Debug("Creating locations") defer logrus.Debug("Locations stopped") // Create a locations provider to determine where to store our files. provider, err := locations.NewDefaultProvider(filepath.Join(constants.VendorName, constants.ConfigName)) if err != nil { return fmt.Errorf("could not create locations provider: %w", err) } // Create a new locations object that will be used to provide paths to store files. return fn(locations.New(provider, constants.ConfigName)) } // Start profiling if requested. func withProfiler(c *cli.Context, fn func() error) error { defer logrus.Debug("Profiler stopped") if c.Bool(flagCPUProfile) { logrus.Debug("Running with CPU profiling") defer profile.Start(profile.CPUProfile, profile.ProfilePath(".")).Stop() } if c.Bool(flagTraceProfile) { logrus.Debug("Running with Trace profiling") defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop() } if c.Bool(flagMemProfile) { logrus.Debug("Running with memory profiling") defer profile.Start(profile.MemProfile, profile.MemProfileAllocs, profile.ProfilePath(".")).Stop() } return fn() } // Restart the app if necessary. func withRestarter(exe string, fn func(*restarter.Restarter) error) error { logrus.Debug("Creating restarter") defer logrus.Debug("Restarter stopped") restarter := restarter.New(exe) defer restarter.Restart() return fn(restarter) } // Handle crashes if they occur. func withCrashHandler(restarter *restarter.Restarter, reporter *sentry.Reporter, fn func(*crash.Handler, <-chan struct{}) error) error { logrus.Debug("Creating crash handler") defer logrus.Debug("Crash handler stopped") crashHandler := crash.NewHandler(crash.ShowErrorNotification(constants.FullAppName)) defer async.HandlePanic(crashHandler) // On crash, send crash report to Sentry. crashHandler.AddRecoveryAction(reporter.ReportException) // On crash, notify the user and restart the app. crashHandler.AddRecoveryAction(crash.ShowErrorNotification(constants.FullAppName)) // On crash, restart the app. crashHandler.AddRecoveryAction(func(any) error { restarter.Set(true, true); return nil }) // quitCh is closed when the app is quitting. quitCh := make(chan struct{}) // On crash, quit the app. crashHandler.AddRecoveryAction(func(any) error { close(quitCh); return nil }) return fn(crashHandler, quitCh) } // Use a custom cookie jar to persist values across runs. func withCookieJar(vault *vault.Vault, fn func(http.CookieJar) error) error { logrus.Debug("Creating cookie jar") defer logrus.Debug("Cookie jar stopped") // Create the underlying cookie jar. jar, err := cookiejar.New(nil) if err != nil { return fmt.Errorf("could not create cookie jar: %w", err) } // Create the cookie jar which persists to the vault. persister, err := cookies.NewCookieJar(jar, vault) if err != nil { return fmt.Errorf("could not create cookie jar: %w", err) } if err := setDeviceCookies(persister); err != nil { return fmt.Errorf("could not set device cookies: %w", err) } // Persist the cookies to the vault when we close. defer func() { logrus.Debug("Persisting cookies") if err := persister.PersistCookies(); err != nil { logrus.WithError(err).Error("Failed to persist cookies") } }() return fn(persister) } // WithKeychainList init the list of usable keychains. func WithKeychainList(panicHandler async.PanicHandler, fn func(*keychain.List) error) error { logrus.Debug("Creating keychain list") defer logrus.Debug("Keychain list stop") defer async.HandlePanic(panicHandler) return fn(keychain.NewList()) } func setDeviceCookies(jar *cookies.Jar) error { url, err := url.Parse(constants.APIHost) if err != nil { return err } for name, value := range map[string]string{ "hhn": sentry.GetProtectedHostname(), "tz": sentry.GetTimeZone(), "lng": sentry.GetSystemLang(), "clr": string(theme.DefaultTheme()), } { jar.SetCookies(url, []*http.Cookie{{Name: name, Value: value, Secure: true}}) } return nil }