Compare commits

...

36 Commits

Author SHA1 Message Date
Cimbali 927b20d4ff
Merge b49ec833b6 into 6cbe51138a 2024-05-05 10:59:56 +01:00
Xavier Michelon 6cbe51138a chore: merge Alcantara to master 2024-04-29 12:31:37 +00:00
Xavier Michelon 82607efe1c chore: Alcantara Bridge 3.11.0 changelog. 2024-04-23 17:07:24 +02:00
Xavier Michelon 961dc9435f fix(BRIDGE-15): Apple Mail profile install page was not properly reset before showing. 2024-04-23 15:58:22 +02:00
Xavier Michelon b574ccb6ea chore: Alcantara Bridge 3.11.0 changelog. 2024-04-22 10:37:47 +02:00
Xavier Michelon 2569e83e51 chore: Alcantara Bridge 3.11.0 changelog. 2024-04-22 09:27:43 +02:00
Jakub Cuth d9fdbb35bc fix(GODT-3185): logic mistake. 2024-04-22 07:26:18 +00:00
Jakub Cuth 5769fb9466 ci: windows build missing revision 2024-04-19 10:39:47 +02:00
Xavier Michelon a4020cebd4 chore: do not use C++ 20 std::ranges. 2024-04-19 08:03:18 +02:00
Atanas Janeshliev 7a8760e2ef fix(BRIDGE-19): warning instead of error on logs for checksum validation... 2024-04-17 12:59:36 +00:00
Atanas Janeshliev 9552e72ba8 feat(BRIDGE-14): HV3 implementation - GUI & CLI; ownership verification & CAPTCHA are supported 2024-04-12 13:07:22 +00:00
Xavier Michelon c692c21b87 fix(BRIDGE-8): more robust command-line args parser in bridge-gui.
fix(BRIDGE-8): add command-line invocation to log.
2024-04-12 11:16:59 +00:00
Xavier Michelon bb15efa711 fix(BRIDGE-8): launcher replace session-id if provided instead of adding another one. 2024-04-12 11:16:59 +00:00
Atanas Janeshliev e94d3be12d chore: bump testing context bridge version 2024-04-12 11:55:53 +02:00
Xavier Michelon 66569f71a0 fix(BRIDGE-7): add timestamp to test credentials for keychain on macOS. 2024-04-09 10:43:31 +02:00
Xavier Michelon 9bfa79455e fix(BRIDGE-7): modify keychain test on macOS. 2024-04-08 14:35:36 +02:00
Xavier Michelon 67e802e3a0 feat(BRIDGE-15): fix a stack layout index in comment. 2024-04-08 11:27:28 +02:00
Xavier Michelon 8a5e2007f6 feat(BRIDGE-15): certificate install is now also done during Outlook setup on macOS. 2024-04-04 08:57:30 +02:00
Xavier Michelon 5b92945626 chore: disable GO-2024-2687 in govulncheck 2024-04-04 07:58:16 +02:00
Atanas Janeshliev 4a8a7ef093 fix(BRIDGE-4): logs not being created when invalid flag is passed 2024-03-21 16:32:12 +00:00
Xavier Michelon 2cfda14b1a fix(BRIDGE-5): add tooltip to tray icon. 2024-03-20 14:55:40 +01:00
Jakub Cuth 312993e08e feat(GODT-3253): windows cache and paths. 2024-03-15 11:28:52 +01:00
Jakub b1110b04c9 feat(GODT-3253): make paths. 2024-03-15 11:27:33 +01:00
Jakub d2bc60d9cb ci: debug 2024-03-15 11:27:29 +01:00
Jakub 1d8f6c75c8 feat(GODT-3253): use new virtual machine for windows jobs. bump vcpkg to 2024.02.14 2024-03-15 11:23:46 +01:00
Jakub 06daaf8d9f feat(GODT-3146): don't need to wait for IMAP in tests. 2024-03-14 11:57:55 +01:00
Jakub cb436fff63 feat(GODT-3146): remove unused 2024-03-13 14:31:53 +01:00
Jakub 921a44f1a3 feat(GODT-3146): keep imap/smtp server always on. 2024-03-13 14:22:23 +01:00
Xavier Michelon d35af6b686 chore: added bridge-rollout to CI. 2024-03-13 09:25:40 +00:00
Xavier Michelon 4cb938c57f chore: added bridge-rollout cli tool. 2024-03-13 09:25:40 +00:00
Jakub 232e98d812 chore: Zaehringen Bridge 3.10.0 changelog. 2024-03-13 10:21:52 +01:00
Jakub Cuth 6fadbde4a6 feat(GODT-3185): report cases which leads to wrong address key used 2024-03-13 07:49:25 +00:00
Jakub d2fbbc3e25 fix(GODT-3163): filter MBOX format delimiter. 2024-03-07 12:30:33 +00:00
Jakub 1c7c342e19 ci(GODT-3304): ignore go vulncheck until go version bumped. 2024-03-07 13:00:16 +01:00
Cimbali b49ec833b6 Do not require a functional vcpkg during CMake process
Allows to simplify vendoring for offline build. Toolchain configuration
set from build scripts where vcpkg is already used.
2023-11-23 19:51:04 +00:00
Cimbali c1c68eca36 Allow to look for installed googletest framework
Requires renaming to proper dependency name and adding FIND_PACKAGE_ARGS
argument

This allows to avoid downloading on offline build systems.
Also don’t include googletest framework in install bundle (!)
2023-11-23 19:50:48 +00:00
91 changed files with 3289 additions and 1875 deletions

View File

@ -3,6 +3,25 @@
Changelog [format](http://keepachangelog.com/en/1.0.0/)
## Alcantara Bridge 3.11.0
### Added
* GODT-3185: Report cases which leads to wrong address key used.
### Changed
* BRIDGE-14: HV3 implementation.
* BRIDGE-15: Certificate install is now also done during Outlook setup on macOS.
* GODT-3146: Start servers on startup, keep running even when no users are active.
* BRIDGE-19: Update checksum validation use warning instead of error on non-existing files.
### Fixed
* BRIDGE-8: Fix bridge double sessionID issue in logs.
* BRIDGE-7: Modify keychain test on macOS.
* BRIDGE-4: Logs not being created when invalid flag is passed.
* BRIDGE-5: Add tooltip to tray icon.
* GODT-3163: Filter MBOX format delimiter.
## Zaehringen Bridge 3.10.0
### Added

View File

@ -1,17 +1,18 @@
export GO111MODULE=on
export CGO_ENABLED=1
# By default, the target OS is the same as the host OS,
# but this can be overridden by setting TARGET_OS to "windows"/"darwin"/"linux".
GOOS:=$(shell go env GOOS)
TARGET_CMD?=Desktop-Bridge
TARGET_OS?=${GOOS}
ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
ROOT_DIR:=$(realpath .)
## Build
.PHONY: build build-gui build-nogui build-launcher versioner hasher
# Keep version hardcoded so app build works also without Git repository.
BRIDGE_APP_VERSION?=3.10.0+git
BRIDGE_APP_VERSION?=3.11.0+git
APP_VERSION:=${BRIDGE_APP_VERSION}
APP_FULL_NAME:=Proton Mail Bridge
APP_VENDOR:=Proton AG
@ -19,8 +20,8 @@ SRC_ICO:=bridge.ico
SRC_ICNS:=Bridge.icns
SRC_SVG:=bridge.svg
EXE_NAME:=proton-bridge
REVISION:=$(shell ./utils/get_revision.sh)
TAG:=$(shell ./utils/get_revision.sh tag)
REVISION:=$(shell "${ROOT_DIR}/utils/get_revision.sh" rev)
TAG:=$(shell "${ROOT_DIR}/utils/get_revision.sh" tag)
BUILD_TIME:=$(shell date +%FT%T%z)
MACOS_MIN_VERSION_ARM64=11.0
MACOS_MIN_VERSION_AMD64=10.15
@ -101,9 +102,9 @@ endif
ifeq "${GOOS}" "windows"
go-build-finalize= \
$(if $(4),powershell Copy-Item ${ROOT_DIR}/${RESOURCE_FILE} ${4} &&,) \
$(if $(4),cp "${ROOT_DIR}/${RESOURCE_FILE}" ${4} &&,) \
$(call go-build,$(1),$(2),$(3)) \
$(if $(4), && powershell Remove-Item ${4} -Force,)
$(if $(4), && rm -f ${4},)
endif
${EXE_NAME}: gofiles ${RESOURCE_FILE}
@ -117,7 +118,10 @@ versioner:
go build ${BUILD_FLAGS} -o versioner utils/versioner/main.go
vault-editor:
$(call go-build-finalize,"-tags=debug","vault-editor","./utils/vault-editor/main.go")
$(call go-build-finalize,-tags=debug,"vault-editor","./utils/vault-editor/main.go")
bridge-rollout:
$(call go-build-finalize,, "bridge-rollout","./utils/bridge-rollout/bridge-rollout.go")
hasher:
go build -o hasher utils/hasher/main.go
@ -164,7 +168,7 @@ ${EXE_TARGET}: check-build-essentials ${EXE_NAME}
BRIDGE_BUILD_TIME=${BUILD_TIME} \
BRIDGE_GUI_BUILD_CONFIG=Release \
BRIDGE_BUILD_ENV=${BUILD_ENV} \
BRIDGE_INSTALL_PATH=${ROOT_DIR}/${DEPLOY_DIR}/${GOOS} \
BRIDGE_INSTALL_PATH="${ROOT_DIR}/${DEPLOY_DIR}/${GOOS}" \
./build.sh install
mv "${ROOT_DIR}/${BRIDGE_EXE}" "$(ROOT_DIR)/${EXE_TARGET}"

View File

@ -1,4 +1,3 @@
---
.script-build:
@ -7,9 +6,14 @@
extends:
- .rules-branch-and-MR-manual
script:
- which go && go version
- which gcc && gcc --version
- which qmake && qmake --version
- git rev-parse --short=10 HEAD
- make build
- git diff && git diff-index --quiet HEAD
- make vault-editor
- make bridge-rollout
artifacts:
expire_in: 1 day
when: always
@ -17,7 +21,7 @@
paths:
- bridge_*.tgz
- vault-editor
- bridge-rollout
build-linux:
extends:
- .script-build
@ -66,4 +70,3 @@ trigger-qa-installer:
trigger:
project: "jcuth/bridge-release"
branch: master

View File

@ -2,27 +2,35 @@
---
.env-windows:
extends:
- .image-windows-virt-build
before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1
- !reference [.before-script-windows-aws-build, before_script]
- !reference [.before-script-windows-virt-build, before_script]
- !reference [.before-script-git-config, before_script]
- git config --global safe.directory '*'
- git status --porcelain
cache: {}
tags:
- windows-bridge
- mkdir -p .cache/bin
- export PATH=$(pwd)/.cache/bin:$PATH
- export GOPATH="$CI_PROJECT_DIR/.cache"
variables:
GOARCH: amd64
BRIDGE_SYNC_FORCE_MINIMUM_SPEC: 1
VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache
cache:
key: windows-vcpkg-go-0
paths:
- .cache
when: 'always'
.env-darwin:
extends:
- .image-darwin-build
before_script:
- export BRIDGE_SYNC_FORCE_MINIMUM_SPEC=1
- !reference [.before-script-darwin-tart-build, before_script]
- !reference [.before-script-git-config, before_script]
- mkdir -p .cache/bin
- export PATH=$(pwd)/.cache/bin:$PATH
- export GOPATH="$CI_PROJECT_DIR/.cache"
variables:
BRIDGE_SYNC_FORCE_MINIMUM_SPEC: 1
VCPKG_DEFAULT_BINARY_CACHE: ${CI_PROJECT_DIR}/.cache
cache:
key: darwin-go-and-vcpkg

View File

@ -26,11 +26,15 @@ lint-bug-report-preview:
extends:
- .rules-branch-manual-MR-and-devel-always
script:
- which go && go version
- which gcc && gcc --version
- make test
artifacts:
paths:
- coverage/**
test-linux:
extends:
- .image-linux-test
@ -70,6 +74,9 @@ test-integration:
- test-linux
script:
- make test-integration | tee -a integration-job.log
after_script:
- |
grep "Error: " integration-job.log
artifacts:
when: always
paths:
@ -95,6 +102,9 @@ test-integration-nightly:
- test-integration
script:
- make test-integration-nightly | tee -a nightly-job.log
after_script:
- |
grep "Error: " nightly-job.log
artifacts:
when: always
paths:

View File

@ -19,8 +19,15 @@ package main
import (
"os"
"runtime"
"strings"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"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/sirupsen/logrus"
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/bradenaw/juniper/xslices"
)
@ -43,5 +50,72 @@ import (
*/
func main() {
_ = app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") }))
appErr := app.New().Run(xslices.Filter(os.Args, func(arg string) bool { return !strings.Contains(arg, "-psn_") }))
if appErr != nil {
_ = app.WithLocations(func(l *locations.Locations) error {
logsPath, err := l.ProvideLogsPath()
if err != nil {
return err
}
// Get the session ID if its specified
var sessionID logging.SessionID
if flagVal, found := getFlagValue(os.Args, app.FlagSessionID); found {
sessionID = logging.SessionID(flagVal)
} else {
sessionID = logging.NewSessionID()
}
closer, err := logging.Init(
logsPath,
sessionID,
logging.BridgeShortAppName,
logging.DefaultMaxLogFileSize,
logging.DefaultPruningSize,
"",
)
if err != nil {
return err
}
defer func() {
_ = logging.Close(closer)
}()
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()).WithError(appErr).Error("Failed to initialize bridge")
return nil
})
}
}
// getFlagValue - obtains the value of a specified tag
// The flag can be of the following form `-flag value`, `--flag value`, `-flag=value` or `--flags=value`.
func getFlagValue(argList []string, flag string) (string, bool) {
eqPrefix1 := "-" + flag + "="
eqPrefix2 := "--" + flag + "="
for i := 0; i < len(argList); i++ {
arg := argList[i]
if strings.HasPrefix(arg, eqPrefix1) {
val := strings.TrimPrefix(arg, eqPrefix1)
return val, len(val) > 0
}
if strings.HasPrefix(arg, eqPrefix2) {
val := strings.TrimPrefix(arg, eqPrefix2)
return val, len(val) > 0
}
if (arg == "-"+flag || arg == "--"+flag) && i+1 < len(argList) {
return argList[i+1], true
}
}
return "", false
}

View File

@ -0,0 +1,47 @@
// 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 <https://www.gnu.org/licenses/>.
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetFlagValue(t *testing.T) {
tests := []struct {
args []string
flag string
expected string
}{
{[]string{"session-id", ""}, "session-id", ""},
{[]string{"-session-id", ""}, "session-id", ""},
{[]string{"--session-id", ""}, "session-id", ""},
{[]string{"session-id", "test"}, "session-id", ""},
{[]string{"-session-id", "test"}, "session-id", "test"},
{[]string{"--session-id", "test"}, "session-id", "test"},
{[]string{"session-id=test"}, "session-id", ""},
{[]string{"-session-id=test"}, "session-id", "test"},
{[]string{"--session-id=test"}, "session-id", "test"},
}
for _, tt := range tests {
val, _ := getFlagValue(tt.args, tt.flag)
require.Equal(t, val, tt.expected)
}
}

View File

@ -40,6 +40,7 @@ import (
"github.com/elastic/go-sysinfo/types"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"golang.org/x/sys/execabs"
)
@ -53,9 +54,12 @@ const (
FlagCLIShort = "c"
FlagNonInteractive = "noninteractive"
FlagNonInteractiveShort = "n"
FlagLauncher = "--launcher"
FlagWait = "--wait"
FlagSessionID = "--session-id"
FlagLauncher = "launcher"
FlagWait = "wait"
FlagSessionID = "session-id"
HyphenatedFlagLauncher = "--" + FlagLauncher
HyphenatedFlagWait = "--" + FlagWait
HyphenatedFlagSessionID = "--" + FlagSessionID
)
func main() { //nolint:funlen
@ -151,7 +155,7 @@ func main() { //nolint:funlen
}
}
cmd := execabs.Command(exe, appendLauncherPath(launcher, append(args, FlagSessionID, string(sessionID)))...) //nolint:gosec
cmd := execabs.Command(exe, appendLauncherPath(launcher, appendOrModifySessionID(args, string(sessionID)))...) //nolint:gosec
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@ -173,19 +177,14 @@ func main() { //nolint:funlen
// appendLauncherPath add launcher path if missing.
func appendLauncherPath(path string, args []string) []string {
if !sliceContains(args, FlagLauncher) {
if !slices.Contains(args, HyphenatedFlagLauncher) {
res := append([]string{}, args...)
res = append(res, FlagLauncher, path)
res = append(res, HyphenatedFlagLauncher, path)
return res
}
return args
}
// sliceContains checks if a value is present in a list.
func sliceContains[T comparable](list []T, s T) bool {
return xslices.Any(list, func(arg T) bool { return arg == s })
}
// inCLIMode detect if CLI mode is asked.
func inCLIMode(args []string) bool {
return hasFlag(args, FlagCLI) || hasFlag(args, FlagCLIShort) || hasFlag(args, FlagNonInteractive) || hasFlag(args, FlagNonInteractiveShort)
@ -193,7 +192,12 @@ func inCLIMode(args []string) bool {
// hasFlag checks if a flag is present in a list.
func hasFlag(args []string, flag string) bool {
return xslices.Any(args, func(arg string) bool { return (arg == "-"+flag) || (arg == "--"+flag) })
return flagIndex(args, flag) >= 0
}
// flagIndex returns the position of the first occurrence of a flag int args, or -1 if the flag is not present.
func flagIndex(args []string, flag string) int {
return slices.IndexFunc(args, func(arg string) bool { return (arg == "-"+flag) || (arg == "--"+flag) })
}
// findAndStrip check if a value is present in s list and remove all occurrences of the value from this list.
@ -211,7 +215,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
hasFlag := false
values := make([]string, 0)
for k, v := range res {
if v != FlagWait {
if v != HyphenatedFlagWait {
continue
}
if k+1 >= len(res) {
@ -222,7 +226,7 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
}
if hasFlag {
res, _ = findAndStrip(res, FlagWait)
res, _ = findAndStrip(res, HyphenatedFlagWait)
for _, v := range values {
res, _ = findAndStrip(res, v)
}
@ -230,6 +234,23 @@ func findAndStripWait(args []string) ([]string, bool, []string) {
return res, hasFlag, values
}
// return args with the sessionID flag and value added or modified. The original slice is not modified.
func appendOrModifySessionID(args []string, sessionID string) []string {
index := flagIndex(args, FlagSessionID)
if index < 0 {
return append(args, HyphenatedFlagSessionID, sessionID)
}
if index == len(args)-1 {
return append(args, sessionID)
}
res := slices.Clone(args)
res[index+1] = sessionID
return res
}
func getPathToUpdatedExecutable(
name string,
ver *versioner.Versioner,

View File

@ -20,61 +20,62 @@ package main
import (
"testing"
"github.com/bradenaw/juniper/xslices"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/stretchr/testify/assert"
)
func TestSliceContains(t *testing.T) {
assert.True(t, sliceContains([]string{"a", "b", "c"}, "a"))
assert.True(t, sliceContains([]int{1, 2, 3}, 2))
assert.False(t, sliceContains([]string{"a", "b", "c"}, "A"))
assert.False(t, sliceContains([]int{1, 2, 3}, 4))
assert.False(t, sliceContains([]string{}, "a"))
assert.True(t, sliceContains([]string{"a", "a"}, "a"))
}
func TestFindAndStrip(t *testing.T) {
list := []string{"a", "b", "c", "c", "b", "c"}
result, found := findAndStrip(list, "a")
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"b", "c", "c", "b", "c"}))
assert.Equal(t, result, []string{"b", "c", "c", "b", "c"})
result, found = findAndStrip(list, "c")
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a", "b", "b"}))
assert.Equal(t, result, []string{"a", "b", "b"})
result, found = findAndStrip([]string{"c", "c", "c"}, "c")
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{}))
assert.Equal(t, result, []string{})
result, found = findAndStrip(list, "A")
assert.False(t, found)
assert.True(t, xslices.Equal(result, list))
assert.Equal(t, result, list)
result, found = findAndStrip([]string{}, "a")
assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{}))
assert.Equal(t, result, []string{})
}
func TestFindAndStripWait(t *testing.T) {
result, found, values := findAndStripWait([]string{"a", "b", "c"})
assert.False(t, found)
assert.True(t, xslices.Equal(result, []string{"a", "b", "c"}))
assert.True(t, xslices.Equal(values, []string{}))
assert.Equal(t, result, []string{"a", "b", "c"})
assert.Equal(t, values, []string{})
result, found, values = findAndStripWait([]string{"a", "--wait", "b"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b"}))
assert.Equal(t, result, []string{"a"})
assert.Equal(t, values, []string{"b"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b", "c"}))
assert.Equal(t, result, []string{"a"})
assert.Equal(t, values, []string{"b", "c"})
result, found, values = findAndStripWait([]string{"a", "--wait", "b", "--wait", "c", "--wait", "d"})
assert.True(t, found)
assert.True(t, xslices.Equal(result, []string{"a"}))
assert.True(t, xslices.Equal(values, []string{"b", "c", "d"}))
assert.Equal(t, result, []string{"a"})
assert.Equal(t, values, []string{"b", "c", "d"})
}
func TestAppendOrModifySessionID(t *testing.T) {
sessionID := string(logging.NewSessionID())
assert.Equal(t, appendOrModifySessionID(nil, sessionID), []string{"--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{}, sessionID), []string{"--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli", "--session-id"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--cli", "--session-id"}, sessionID), []string{"--cli", "--session-id", sessionID})
assert.Equal(t, appendOrModifySessionID([]string{"--session-id", "<oldID>", "--cli"}, sessionID), []string{"--session-id", sessionID, "--cli"})
}

2
extern/vcpkg vendored

@ -1 +1 @@
Subproject commit d4d39d71b3e6dd7536592c36ab2f7e84a8a64942
Subproject commit fba75d09065fcc76a25dcf386b1d00d33f5175af

2
go.mod
View File

@ -7,7 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible

2
go.sum
View File

@ -40,6 +40,8 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9 h1:tcQpGQljNsZmfuA6L4hAzio8/AIx5OXcU2JUdyX/qxw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436 h1:ej+W9+UQlb2owkT5arCegmUFkicwesMyFHgBp/wwNg8=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

@ -83,7 +83,7 @@ const (
flagNoWindow = "no-window"
flagParentPID = "parent-pid"
flagSoftwareRenderer = "software-renderer"
flagSessionID = "session-id"
FlagSessionID = "session-id"
)
const (
@ -165,7 +165,7 @@ func New() *cli.App {
Value: false,
},
&cli.StringFlag{
Name: flagSessionID,
Name: FlagSessionID,
Hidden: true,
},
}
@ -346,7 +346,7 @@ func withLogging(c *cli.Context, crashHandler *crash.Handler, locations *locatio
logrus.WithField("path", logsPath).Debug("Received logs path")
// Initialize logging.
sessionID := logging.NewSessionIDFromString(c.String(flagSessionID))
sessionID := logging.NewSessionIDFromString(c.String(FlagSessionID))
var closer io.Closer
if closer, err = logging.Init(
logsPath,

View File

@ -43,7 +43,7 @@ import (
// nolint:gosec
func migrateKeychainHelper(locations *locations.Locations) error {
logrus.Info("Migrating keychain helper")
logrus.Trace("Checking if keychain helper needs to be migrated")
settings, err := locations.ProvideSettingsPath()
if err != nil {
@ -75,7 +75,11 @@ func migrateKeychainHelper(locations *locations.Locations) error {
return fmt.Errorf("failed to unmarshal old prefs file: %w", err)
}
return vault.SetHelper(settings, prefs.Helper)
err = vault.SetHelper(settings, prefs.Helper)
if err == nil {
logrus.Info("Keychain helper has been migrated")
}
return err
}
// nolint:gosec

View File

@ -184,20 +184,11 @@ func TestBridge_UserAgent_Persistence(t *testing.T) {
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil)))
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -235,21 +226,12 @@ func TestBridge_UserAgentFromUnknownClient(t *testing.T) {
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -274,21 +256,12 @@ func TestBridge_UserAgentFromSMTPClient(t *testing.T) {
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
currentUserAgent := b.GetCurrentUserAgent()
require.Contains(t, currentUserAgent, useragent.DefaultUserAgent)
userID, err := b.LoginFull(context.Background(), username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(b.GetSMTPPort())))
require.NoError(t, err)
defer client.Close() //nolint:errcheck
@ -333,17 +306,8 @@ func TestBridge_UserAgentFromIMAPID(t *testing.T) {
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
require.NoError(t, getErr(b.LoginFull(ctx, otherUser, otherPassword, nil, nil)))
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
defer func() { _ = imapClient.Logout() }()
@ -715,21 +679,12 @@ func TestBridge_InitGluonDirectory(t *testing.T) {
func TestBridge_LoginFailed(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, vaultKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, vaultKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
failCh, done := chToType[events.Event, events.IMAPLoginFailed](bridge.GetEvents(events.IMAPLoginFailed{}))
defer done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetIMAPPort())))
require.NoError(t, err)
@ -757,12 +712,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
configDir, err := b.GetGluonDataDir()
require.NoError(t, err)
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(b)
defer smtpWaiter.Done()
// Login the user.
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
@ -796,9 +745,6 @@ func TestBridge_ChangeCacheDirectory(t *testing.T) {
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
smtpWaiter.Wait()
client, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, client.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -64,9 +64,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
// The initial user should be fully synced.
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, _ *bridge.Mocks) {
waiter := waitForIMAPServerReady(b)
defer waiter.Done()
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
@ -74,7 +71,6 @@ func TestBridge_HandleDraftsSendFromOtherClient(t *testing.T) {
require.NoError(t, err)
require.Equal(t, userID, (<-syncCh).UserID)
waiter.Wait()
info, err := b.GetUserInfo(userID)
require.NoError(t, err)

View File

@ -46,17 +46,12 @@ func TestBridge_Send(t *testing.T) {
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
recipientUserID, err := bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err)
smtpWaiter.Wait()
senderInfo, err := bridge.GetUserInfo(senderUserID)
require.NoError(t, err)
@ -409,9 +404,6 @@ SGVsbG8gd29ybGQK
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
@ -431,8 +423,6 @@ SGVsbG8gd29ybGQK
messageMultipartWithoutTextWithTextAttachment,
}
smtpWaiter.Wait()
for _, m := range messages {
// Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
@ -617,9 +607,6 @@ Hello world
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
@ -639,8 +626,6 @@ Hello world
messageInlineImageFollowedByText,
}
smtpWaiter.Wait()
for _, m := range messages {
// Dial the server.
client, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
@ -714,17 +699,12 @@ func TestBridge_SendAddressDisabled(t *testing.T) {
require.NoError(t, s.ChangeAddressAllowSend(senderUserID, addrID, false))
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, _ *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
senderUserID, err := bridge.LoginFull(ctx, "sender", password, nil, nil)
require.NoError(t, err)
_, err = bridge.LoginFull(ctx, "recipient", password, nil, nil)
require.NoError(t, err)
smtpWaiter.Wait()
recipientInfo, err := bridge.GetUserInfo(recipientUserID)
require.NoError(t, err)
@ -750,7 +730,7 @@ func TestBridge_SendAddressDisabled(t *testing.T) {
strings.NewReader("Subject: Test 1\r\n\r\nHello world!"),
)
smtpErr := smtpservice.NewErrCanNotSendOnAddress(senderInfo.Addresses[0])
smtpErr := smtpservice.NewErrCannotSendFromAddress(senderInfo.Addresses[0])
require.Equal(t, fmt.Sprintf("Error: %v", smtpErr.Error()), err.Error())
})
})

View File

@ -36,9 +36,6 @@ import (
func TestBridge_Report(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(b *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(b)
defer imapWaiter.Done()
syncCh, done := chToType[events.Event, events.SyncFinished](b.GetEvents(events.SyncFinished{}))
defer done()
@ -54,8 +51,6 @@ func TestBridge_Report(t *testing.T) {
require.NoError(t, err)
require.True(t, info.State == bridge.Connected)
imapWaiter.Wait()
// Dial the IMAP port.
conn, err := net.Dial("tcp", fmt.Sprintf("%v:%v", constants.Host, b.GetIMAPPort()))
require.NoError(t, err)

View File

@ -20,6 +20,7 @@ package bridge_test
import (
"context"
"fmt"
"net"
"testing"
"github.com/ProtonMail/go-proton-api"
@ -27,57 +28,39 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/emersion/go-smtp"
"github.com/stretchr/testify/require"
)
func TestServerManager_NoLoadedUsersNoServers(t *testing.T) {
func TestServerManager_ServersStartWithBridge(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
_, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.Error(t, err)
})
})
}
func TestServerManager_ServersStartAfterFirstConnectedUser(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
imapWaiter.Wait()
smtpWaiter.Wait()
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
})
})
}
func TestServerManager_ServersStopsAfterUserLogsOut(t *testing.T) {
func TestServerManager_ServersKeepsRunningfterUserLogsOut(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userID, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapWaiterStopped := waitForIMAPServerStopped(bridge)
defer imapWaiterStopped.Done()
require.NoError(t, bridge.LogoutUser(ctx, userID))
imapWaiterStopped.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
})
})
}
@ -90,21 +73,12 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
userIDOther, err := bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
evtCh, cancel := bridge.GetEvents(events.UserDeauth{})
defer cancel()
@ -115,31 +89,10 @@ func TestServerManager_ServersDoNotStopWhenThereIsStillOneActiveUser(t *testing.
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
})
})
}
func TestServerManager_ServersStartIfAtLeastOneUserIsLoggedIn(t *testing.T) {
otherPassword := []byte("bar")
otherUser := "foo"
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
userIDOther, _, err := s.CreateUser(otherUser, otherPassword)
require.NoError(t, err)
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
_, err = bridge.LoginFull(ctx, otherUser, otherPassword, nil, nil)
require.NoError(t, err)
})
require.NoError(t, s.RevokeUser(userIDOther))
withBridgeWaitForServers(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
smtpClient.Close() //nolint:errcheck
})
})
}
@ -162,8 +115,13 @@ func TestServerManager_NetworkLossStopsServers(t *testing.T) {
_, err := bridge.LoginFull(ctx, username, password, nil, nil)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
imapClient, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, imapClient.Logout())
smtpClient, err := smtp.Dial(net.JoinHostPort(constants.Host, fmt.Sprint(bridge.GetSMTPPort())))
require.NoError(t, err)
smtpClient.Close() //nolint:errcheck
netCtl.Disable()

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
@ -123,15 +124,20 @@ func (bridge *Bridge) QueryUserInfo(query string) (UserInfo, error) {
}
// LoginAuth begins the login process. It returns an authorized client that might need 2FA.
func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte) (*proton.Client, proton.Auth, error) {
func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte, hvDetails *proton.APIHVDetails) (*proton.Client, proton.Auth, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Authorizing user for login")
if username == "crash@bandicoot" {
panic("Your wish is my command.. I crash!")
}
client, auth, err := bridge.api.NewClientWithLogin(ctx, username, password)
client, auth, err := bridge.api.NewClientWithLoginWithHVToken(ctx, username, password, hvDetails)
if err != nil {
if hv.IsHvRequest(err) {
logUser.WithFields(logrus.Fields{"username": logging.Sensitive(username),
"loginError": err.Error()}).Info("Human Verification requested for login")
return nil, proton.Auth{}, err
}
return nil, proton.Auth{}, fmt.Errorf("failed to create new API client: %w", err)
}
@ -154,12 +160,13 @@ func (bridge *Bridge) LoginUser(
client *proton.Client,
auth proton.Auth,
keyPass []byte,
hvDetails *proton.APIHVDetails,
) (string, error) {
logUser.WithField("userID", auth.UserID).Info("Logging in authorized user")
userID, err := try.CatchVal(
func() (string, error) {
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass)
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass, hvDetails)
},
)
@ -192,7 +199,8 @@ func (bridge *Bridge) LoginFull(
) (string, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Performing full user login")
client, auth, err := bridge.LoginAuth(ctx, username, password)
// (atanas) the following may need to be modified once HV is merged (its used only for testing; and depends on whether we will test HV related logic)
client, auth, err := bridge.LoginAuth(ctx, username, password, nil)
if err != nil {
return "", fmt.Errorf("failed to begin login process: %w", err)
}
@ -225,7 +233,7 @@ func (bridge *Bridge) LoginFull(
keyPass = password
}
userID, err := bridge.LoginUser(ctx, client, auth, keyPass)
userID, err := bridge.LoginUser(ctx, client, auth, keyPass, nil)
if err != nil {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logUser.WithError(err).Error("Failed to delete auth")
@ -374,8 +382,8 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
}, bridge.usersLock)
}
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte) (string, error) {
apiUser, err := client.GetUser(ctx)
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte, hvDetails *proton.APIHVDetails) (string, error) {
apiUser, err := client.GetUserWithHV(ctx, hvDetails)
if err != nil {
return "", fmt.Errorf("failed to get API user: %w", err)
}

View File

@ -139,9 +139,6 @@ func test_badMessage_badEvent(userFeedback func(t *testing.T, ctx context.Contex
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string
@ -177,8 +174,6 @@ func test_badMessage_badEvent(userFeedback func(t *testing.T, ctx context.Contex
userFeedback(t, ctx, bridge, badUserID)
smtpWaiter.Wait()
userContinueEventProcess(ctx, t, s, bridge)
})
})
@ -197,9 +192,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
})
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
userLoginAndSync(ctx, t, bridge, "user", password)
var messageIDs []string
@ -223,7 +215,6 @@ func TestBridge_User_BadMessage_NoBadEvent(t *testing.T) {
require.NoError(t, c.DeleteMessage(ctx, messageIDs...))
})
smtpWaiter.Wait()
userContinueEventProcess(ctx, t, s, bridge)
})
})
@ -776,20 +767,11 @@ func TestBridge_User_CreateDisabledAddress(t *testing.T) {
func TestBridge_User_HandleParentLabelRename(t *testing.T) {
withEnv(t, func(ctx context.Context, s *server.Server, netCtl *proton.NetCtl, locator bridge.Locator, storeKey []byte) {
withBridge(ctx, t, s.GetHostURL(), netCtl, locator, storeKey, func(bridge *bridge.Bridge, mocks *bridge.Mocks) {
imapWaiter := waitForIMAPServerReady(bridge)
defer imapWaiter.Done()
smtpWaiter := waitForSMTPServerReady(bridge)
defer smtpWaiter.Done()
require.NoError(t, getErr(bridge.LoginFull(ctx, username, password, nil, nil)))
info, err := bridge.QueryUserInfo(username)
require.NoError(t, err)
imapWaiter.Wait()
smtpWaiter.Wait()
cli, err := eventuallyDial(fmt.Sprintf("%v:%v", constants.Host, bridge.GetIMAPPort()))
require.NoError(t, err)
require.NoError(t, cli.Login(info.Addresses[0], string(info.BridgePass)))

View File

@ -53,14 +53,6 @@ endif()
set(VCPKG_ROOT "${BRIDGE_REPO_ROOT}/extern/vcpkg")
message(STATUS "VCPKG_ROOT is ${VCPKG_ROOT}")
if (WIN32)
find_program(VCPKG_EXE "${VCPKG_ROOT}/vcpkg.exe")
else()
find_program(VCPKG_EXE "${VCPKG_ROOT}/vcpkg")
endif()
if (NOT VCPKG_EXE)
message(FATAL_ERROR "vcpkg is not installed. Run build.sh (macOS/Linux) or build.ps1 (Windows) first.")
endif()
# For now we support only a single architecture for macOS (ARM64 or x86_64). We need to investigate how to build universal binaries with vcpkg.
if (APPLE)
@ -86,5 +78,3 @@ if (WIN32)
message(STATUS "Building for Intel x64 Windows computers")
set(VCPKG_TARGET_TRIPLET x64-windows)
endif()
set(CMAKE_TOOLCHAIN_FILE "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" CACHE STRING "toolchain")

View File

@ -29,13 +29,11 @@ using namespace bridgepp;
namespace {
QString const defaultKeychain = "defaultKeychain"; ///< The default keychain.
QString const HV_ERROR_TEMPLATE = "failed to create new API client: 422 POST https://mail-api.proton.me/auth/v4: CAPTCHA validation failed (Code=12087, Status=422)";
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
@ -349,6 +347,7 @@ Status GRPCService::ForceLauncher(ServerContext *, StringValue const *request, E
/// \return The status for the call.
//****************************************************************************************************************************************************
Status GRPCService::SetMainExecutable(ServerContext *, StringValue const *request, Empty *) {
resetHv();
app().log().debug(__FUNCTION__);
app().log().info(QString("SetMainExecutable: %1").arg(QString::fromStdString(request->value())));
return Status::OK;
@ -418,7 +417,19 @@ Status GRPCService::Login(ServerContext *, LoginRequest const *request, Empty *)
return Status::OK;
}
if (usersTab.nextUserHvRequired() && !hvWasRequested_ && previousHvUsername_ != QString::fromStdString(request->username())) {
hvWasRequested_ = true;
previousHvUsername_ = QString::fromStdString(request->username());
qtProxy_.sendDelayedEvent(newLoginHvRequestedEvent());
return Status::OK;
} else {
hvWasRequested_ = false;
previousHvUsername_ = "";
}
if (usersTab.nextUserHvError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::HV_ERROR, HV_ERROR_TEMPLATE));
return Status::OK;
}
if (usersTab.nextUserUsernamePasswordError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage()));
return Status::OK;
@ -495,6 +506,7 @@ Status GRPCService::Login2Passwords(ServerContext *, LoginRequest const *request
//****************************************************************************************************************************************************
Status GRPCService::LoginAbort(ServerContext *, LoginAbortRequest const *request, Empty *) {
app().log().debug(__FUNCTION__);
this->resetHv();
loginUsername_ = QString();
return Status::OK;
}
@ -953,3 +965,11 @@ void GRPCService::finishLogin() {
qtProxy_.sendDelayedEvent(newLoginFinishedEvent(user->id(), alreadyExist));
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void GRPCService::resetHv() {
hvWasRequested_ = false;
previousHvUsername_ = "";
}

View File

@ -106,6 +106,7 @@ public: // member functions.
private: // member functions
void finishLogin(); ///< finish the login procedure once the credentials have been validated.
void resetHv(); ///< Resets the human verification state.
private: // data member
mutable QMutex eventStreamMutex_; ///< Mutex used to access eventQueue_, isStreaming_ and shouldStopStreaming_;
@ -113,6 +114,8 @@ private: // data member
bool isStreaming_; ///< Is the gRPC stream running. Access protected by eventStreamMutex_;
bool eventStreamShouldStop_; ///< Should the stream be stopped? Access protected by eventStreamMutex
QString loginUsername_; ///< The username used for the current login procedure.
QString previousHvUsername_; ///< The previous username used for HV.
bool hvWasRequested_ {false}; ///< Was human verification requested.
GRPCQtProxy qtProxy_; ///< Qt Proxy used to send signals, as this class is not a QObject.
};

View File

@ -277,6 +277,22 @@ bridgepp::SPUser UsersTab::userWithUsernameOrEmail(QString const &username) {
}
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification request
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvRequired() const {
return ui_.checkHV3Required->isChecked();
}
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification error
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvError() const {
return ui_.checkHV3Error->isChecked();
}
//****************************************************************************************************************************************************
/// \return true iff the next login attempt should trigger a username/password error.
//****************************************************************************************************************************************************

View File

@ -39,6 +39,8 @@ public: // member functions.
UserTable &userTable(); ///< Returns a reference to the user table.
bridgepp::SPUser userWithID(QString const &userID); ///< Get the user with the given ID.
bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Get the user with the given username.
bool nextUserHvRequired() const; ///< Check if next user login should trigger HV
bool nextUserHvError() const; ///< Check if next user login should trigger HV error
bool nextUserUsernamePasswordError() const; ///< Check if next user login should trigger a username/password error.
bool nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error.
bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA.

View File

@ -290,6 +290,20 @@
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Required">
<property name="text">
<string>HV3 required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Error">
<property name="text">
<string>HV3 error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkFreeUserError">
<property name="text">

View File

@ -140,7 +140,7 @@ if (WIN32) # on Windows, we add a (non-Qt) resource file that contains the appli
endif()
target_precompile_headers(bridge-gui PRIVATE Pch.h)
target_include_directories(bridge-gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${SENTRY_CONFIG_GENERATED_FILE_DIR})
target_include_directories(bridge-gui PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" ${SENTRY_CONFIG_GENERATED_FILE_DIR})
target_link_libraries(bridge-gui
Qt6::Widgets
Qt6::Core

View File

@ -15,113 +15,85 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "Pch.h"
#include "CommandLine.h"
#include "Settings.h"
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h>
using namespace bridgepp;
namespace {
QString const launcherFlag = "--launcher"; ///< launcher flag parameter used for bridge.
QString const noWindowFlag = "--no-window"; ///< The no-window command-line flag.
QString const softwareRendererFlag = "--software-renderer"; ///< The 'software-renderer' command-line flag. enable software rendering for a single execution
QString const setSoftwareRendererFlag = "--set-software-renderer"; ///< The 'set-software-renderer' command-line flag. Software rendering will be used for all subsequent executions of the application.
QString const setHardwareRendererFlag = "--set-hardware-renderer"; ///< The 'set-hardware-renderer' command-line flag. Hardware rendering will be used for all subsequent executions of the application.
//****************************************************************************************************************************************************
/// \brief parse a command-line string argument as expected by go's CLI package.
/// \param[in] argc The number of arguments passed to the application.
/// \param[in] argv The list of arguments passed to the application.
/// \param[in] paramNames the list of names for the parameter
//****************************************************************************************************************************************************
QString parseGoCLIStringArgument(int argc, char *argv[], QStringList paramNames) {
// go cli package is pretty permissive when it comes to parsing arguments. For each name 'param', all the following seems to be accepted:
// -param value
// --param value
// -param=value
// --param=value
for (QString const &paramName: paramNames) {
for (qsizetype i = 1; i < argc; ++i) {
QString const arg(QString::fromLocal8Bit(argv[i]));
if ((i < argc - 1) && ((arg == "-" + paramName) || (arg == "--" + paramName))) {
return QString(argv[i + 1]);
}
QRegularExpressionMatch match = QRegularExpression(QString("^-{1,2}%1=(.+)$").arg(paramName)).match(arg);
if (match.hasMatch()) {
return match.captured(1);
}
}
}
return QString();
}
QString const hyphenatedLauncherFlag = "--launcher"; ///< launcher flag parameter used for bridge.
QString const hyphenatedWindowFlag = "--no-window"; ///< The no-window command-line flag.
QString const hyphenatedSoftwareRendererFlag = "--software-renderer"; ///< The 'software-renderer' command-line flag. enable software rendering for a single execution
QString const hyphenatedSetSoftwareRendererFlag = "--set-software-renderer"; ///< The 'set-software-renderer' command-line flag. Software rendering will be used for all subsequent executions of the application.
QString const hyphenatedSetHardwareRendererFlag = "--set-hardware-renderer"; ///< The 'set-hardware-renderer' command-line flag. Hardware rendering will be used for all subsequent executions of the application.
//****************************************************************************************************************************************************
/// \brief Parse the log level from the command-line arguments.
///
/// \param[in] argc The number of arguments passed to the application.
/// \param[in] argv The list of arguments passed to the application.
/// \param[in] args The command-line arguments.
/// \return The log level. if not specified on the command-line, the default log level is returned.
//****************************************************************************************************************************************************
Log::Level parseLogLevel(int argc, char *argv[]) {
QString levelStr = parseGoCLIStringArgument(argc, argv, { "l", "log-level" });
Log::Level parseLogLevel(QStringList const &args) {
QStringList levelStr = parseGoCLIStringArgument(args, {"l", "log-level"});
if (levelStr.isEmpty()) {
return Log::defaultLevel;
}
Log::Level level = Log::defaultLevel;
Log::stringToLevel(levelStr, level);
Log::stringToLevel(levelStr.back(), level);
return level;
}
} // anonymous namespace
//****************************************************************************************************************************************************
/// \param[in] argc number of arguments passed to the application.
/// \param[in] argv list of arguments passed to the application.
/// \param[in] argv list of arguments passed to the application, including the exe name/path at index 0.
/// \return The parsed options.
//****************************************************************************************************************************************************
CommandLineOptions parseCommandLine(int argc, char *argv[]) {
CommandLineOptions parseCommandLine(QStringList const &argv) {
CommandLineOptions options;
bool flagFound = false;
options.launcher = QString::fromLocal8Bit(argv[0]);
bool launcherFlagFound = false;
options.launcher = argv[0];
// for unknown reasons, on Windows QCoreApplication::arguments() frequently returns an empty list, which is incorrect, so we rebuild the argument
// list from the original argc and argv values.
for (int i = 1; i < argc; i++) {
QString const &arg = QString::fromLocal8Bit(argv[i]);
for (int i = 1; i < argv.count(); i++) {
QString const &arg = argv[i];
// we can't use QCommandLineParser here since it will fail on unknown options.
// we skip session-id for now we'll process it later, with a special treatment for duplicates
if (arg == hyphenatedSessionIDFlag) {
i++; // we skip the next param, which if the flag's value.
continue;
}
if (arg.startsWith(hyphenatedSessionIDFlag + "=")) {
continue;
}
// Arguments may contain some bridge flags.
if (arg == softwareRendererFlag) {
if (arg == hyphenatedSoftwareRendererFlag) {
options.bridgeGuiArgs.append(arg);
options.useSoftwareRenderer = true;
}
if (arg == setSoftwareRendererFlag) {
if (arg == hyphenatedSetSoftwareRendererFlag) {
app().settings().setUseSoftwareRenderer(true);
continue; // setting is permanent. no need to keep/pass it to bridge for restart.
}
if (arg == setHardwareRendererFlag) {
if (arg == hyphenatedSetHardwareRendererFlag) {
app().settings().setUseSoftwareRenderer(false);
continue; // setting is permanent. no need to keep/pass it to bridge for restart.
}
if (arg == noWindowFlag) {
if (arg == hyphenatedWindowFlag) {
options.noWindow = true;
}
if (arg == launcherFlag) {
if (arg == hyphenatedLauncherFlag) {
options.bridgeArgs.append(arg);
options.launcher = QString::fromLocal8Bit(argv[++i]);
options.launcher = argv[++i];
options.bridgeArgs.append(options.launcher);
flagFound = true;
launcherFlagFound = true;
}
#ifdef QT_DEBUG
else if (arg == "--attach" || arg == "-a") {
@ -135,22 +107,24 @@ CommandLineOptions parseCommandLine(int argc, char *argv[]) {
options.bridgeGuiArgs.append(arg);
}
}
if (!flagFound) {
if (!launcherFlagFound) {
// add bridge-gui as launcher
options.bridgeArgs.append(launcherFlag);
options.bridgeArgs.append(hyphenatedLauncherFlag);
options.bridgeArgs.append(options.launcher);
}
options.logLevel = parseLogLevel(argc, argv);
QString sessionID = parseGoCLIStringArgument(argc, argv, { "session-id" });
if (sessionID.isEmpty()) {
// The session ID was not passed to us on the command-line -> create one and add to the command-line for bridge
sessionID = newSessionID();
options.bridgeArgs.append("--session-id");
options.bridgeArgs.append(sessionID);
QStringList args;
if (!argv.isEmpty()) {
args = argv.last(argv.count() - 1);
}
options.logLevel = parseLogLevel(args);
QString const sessionID = mostRecentSessionID(args);
options.bridgeArgs.append(hyphenatedSessionIDFlag);
options.bridgeArgs.append(sessionID);
app().setSessionID(sessionID);
return options;
}

View File

@ -37,7 +37,7 @@ struct CommandLineOptions {
};
CommandLineOptions parseCommandLine(int argc, char *argv[]); ///< Parse the command-line arguments
CommandLineOptions parseCommandLine(QStringList const &argv); ///< Parse the command-line arguments
#endif //BRIDGE_GUI_COMMAND_LINE_H

View File

@ -31,7 +31,7 @@ macro( AppendLib LIB_NAME HINT_PATH)
if( ${PATH_${UP_NAME}} STREQUAL "PATH_${UP_NAME}-NOTFOUND")
message(SEND_ERROR "${LIB_NAME} was not found in ${HINT_PATH}")
else()
list(APPEND DEPLOY_LIBS ${PATH_${UP_NAME}})
list(APPEND DEPLOY_LIBS "${PATH_${UP_NAME}}")
endif()
endmacro()

View File

@ -810,6 +810,18 @@ void QMLBackend::login(QString const &username, QString const &password) const {
)
}
void QMLBackend::loginHv(QString const &username, QString const &password) const {
HANDLE_EXCEPTION(
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) {
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot",
"This error exists for test purposes and should be ignored.", __func__, tailOfLatestBridgeLog(app().sessionID()));
}
app().grpc().loginHv(username, password);
)
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
@ -1334,6 +1346,8 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::login2PasswordErrorAbort, this, &QMLBackend::login2PasswordErrorAbort);
connect(client, &GRPCClient::loginFinished, this, &QMLBackend::onLoginFinished);
connect(client, &GRPCClient::loginAlreadyLoggedIn, this, &QMLBackend::onLoginAlreadyLoggedIn);
connect(client, &GRPCClient::loginHvRequested, this, &QMLBackend::loginHvRequested);
connect(client, &GRPCClient::loginHvError, this, &QMLBackend::loginHvError);
// update events
connect(client, &GRPCClient::updateManualError, this, &QMLBackend::updateManualError);

View File

@ -183,6 +183,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void changeColorScheme(QString const &scheme); ///< Slot for the change of the theme.
void setDiskCachePath(QUrl const &path) const; ///< Slot for the change of the disk cache path.
void login(QString const &username, QString const &password) const; ///< Slot for the login button (initial login).
void loginHv(QString const &username, QString const &password) const; ///< Slot for the login button (after HV challenge completed).
void login2FA(QString const &username, QString const &code) const; ///< Slot for the login button (2FA login).
void login2Password(QString const &username, QString const &password) const; ///< Slot for the login button (mailbox password login).
void loginAbort(QString const &username) const; ///< Slot for the login abort procedure.
@ -238,6 +239,8 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void login2PasswordErrorAbort(QString const &errorMsg); ///< Signal for the 'login2PasswordErrorAbort' gRPC stream event.
void loginFinished(int index, bool wasSignedOut); ///< Signal for the 'loginFinished' gRPC stream event.
void loginAlreadyLoggedIn(int index); ///< Signal for the 'loginAlreadyLoggedIn' gRPC stream event.
void loginHvRequested(QString const &hvUrl); ///< Signal for the 'loginHvRequested' gRPC stream event.
void loginHvError(QString const &errorMsg); ///< Signal for the 'loginHvError' gRPC stream event.
void updateManualReady(QString const &version); ///< Signal for the 'updateManualReady' gRPC stream event.
void updateManualRestartNeeded(); ///< Signal for the 'updateManualRestartNeeded' gRPC stream event.
void updateManualError(); ///< Signal for the 'updateManualError' gRPC stream event.

View File

@ -117,6 +117,7 @@
<file>qml/Resources/Help/WhyProfileWarning.html</file>
<file>qml/SettingsItem.qml</file>
<file>qml/SettingsView.qml</file>
<file>qml/SetupWizard/ClientConfigCertInstall.qml</file>
<file>qml/SetupWizard/ClientListItem.qml</file>
<file>qml/SetupWizard/LeftPane.qml</file>
<file>qml/SetupWizard/ClientConfigAppleMail.qml</file>

View File

@ -182,6 +182,7 @@ TrayIcon::TrayIcon()
, notificationErrorIcon_(loadIconFromSVG(":/qml/icons/ic-alert.svg")) {
this->generateDotIcons();
this->setContextMenu(menu_.get());
this->setToolTip(PROJECT_FULL_NAME);
connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow);
connect(this, &TrayIcon::selectUser, &app().backend(), [](QString const& userID, bool forceShowWindow) {

View File

@ -63,6 +63,7 @@ $buildDir=(Join-Path $scriptDir "cmake-build-$buildConfig".ToLower())
$vcpkgRoot = (Join-Path $bridgeRepoRootDir "extern/vcpkg" -Resolve)
$vcpkgExe = (Join-Path $vcpkgRoot "vcpkg.exe")
$vcpkgBootstrap = (Join-Path $vcpkgRoot "bootstrap-vcpkg.bat")
$vcpkgToolchain = (Join-Path $vcpkgRoot "scripts/buildsystems/vcpkg.cmake")
function check_exit() {
if ($? -ne $True)
@ -91,6 +92,7 @@ git submodule update --init --recursive $vcpkgRoot
. $vcpkgExe install sentry-native:x64-windows grpc:x64-windows --clean-after-build
. $vcpkgExe upgrade --no-dry-run
. $cmakeExe -G "Visual Studio 17 2022" -DCMAKE_BUILD_TYPE="$buildConfig" `
-DCMAKE_TOOLCHAIN_FILE="$vcpkgToolchain" `
-DBRIDGE_APP_FULL_NAME="$bridgeFullName" `
-DBRIDGE_VENDOR="$bridgeVendor" `
-DBRIDGE_REVISION="$REVISION_HASH" `
@ -102,6 +104,7 @@ git submodule update --init --recursive $vcpkgRoot
-S . -B $buildDir
check_exit "CMake failed"
. $cmakeExe --build $buildDir --config "$buildConfig"
check_exit "Build failed"
@ -109,7 +112,7 @@ if ($($args.count) -gt 0 )
{
if ($args[0] = "install")
{
. $cmakeExe --install $buildDir
. $cmakeExe --install "$buildDir" -v
check_exit "Install failed"
}
}

View File

@ -95,6 +95,7 @@ fi
cmake \
-DCMAKE_BUILD_TYPE="${BUILD_CONFIG}" \
-DCMAKE_TOOLCHAIN_FILE="${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" \
-DBRIDGE_APP_FULL_NAME="${BRIDGE_APP_FULL_NAME}" \
-DBRIDGE_VENDOR="${BRIDGE_VENDOR}" \
-DBRIDGE_REVISION="${BRIDGE_REVISION}" \

View File

@ -15,7 +15,6 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "BridgeApp.h"
#include "BuildConfig.h"
#include "CommandLine.h"
@ -30,13 +29,12 @@
#include <bridgepp/Log/LogUtils.h>
#include <bridgepp/ProcessMonitor.h>
#include "bridgepp/CLI/CLIUtils.h"
#ifdef Q_OS_MACOS
#include "MacOS/SecondInstance.h"
#endif
using namespace bridgepp;
@ -50,17 +48,14 @@ QString const exeSuffix = ".exe";
QString const exeSuffix;
#endif
QString const bridgeLock = "bridge-v3.lock"; ///< The file name used for the bridge-gui lock file.
QString const bridgeGUILock = "bridge-v3-gui.lock"; ///< The file name used for the bridge-gui lock file.
QString const exeName = "bridge" + exeSuffix; ///< The bridge executable file name.*
qint64 const grpcServiceConfigWaitDelayMs = 180000; ///< The wait delay for the gRPC config file in milliseconds.
qint64 constexpr grpcServiceConfigWaitDelayMs = 180000; ///< The wait delay for the gRPC config file in milliseconds.
QString const waitFlag = "--wait"; ///< The wait command-line flag.
} // anonymous namespace
//****************************************************************************************************************************************************
/// \return The path of the bridge executable.
/// \return A null string if the executable could not be located.
@ -70,7 +65,6 @@ QString locateBridgeExe() {
return (fileInfo.exists() && fileInfo.isFile() && fileInfo.isExecutable()) ? fileInfo.absoluteFilePath() : QString();
}
//****************************************************************************************************************************************************
/// // initialize the Qt application.
//****************************************************************************************************************************************************
@ -97,8 +91,6 @@ void initQtApplication() {
#endif // #ifdef Q_OS_MACOS
}
//****************************************************************************************************************************************************
/// \param[in] engine The QML component.
//****************************************************************************************************************************************************
@ -118,13 +110,12 @@ QQmlComponent *createRootQmlComponent(QQmlApplicationEngine &engine) {
rootComponent->loadUrl(QUrl(qrcQmlDir + "/Bridge.qml"));
if (rootComponent->status() != QQmlComponent::Status::Ready) {
QString const &err = rootComponent->errorString();
app().log().error(err);
app().log().error(err);
throw Exception("Could not load QML component", err);
}
return rootComponent;
}
//****************************************************************************************************************************************************
/// \param[in] lock The lock file to be checked.
/// \return True if the lock can be taken, false otherwise.
@ -155,7 +146,6 @@ bool checkSingleInstance(QLockFile &lock) {
return true;
}
//****************************************************************************************************************************************************
/// \return QUrl to reach the bridge API.
//****************************************************************************************************************************************************
@ -184,7 +174,6 @@ QUrl getApiUrl() {
return url;
}
//****************************************************************************************************************************************************
/// \brief Check if bridge is running.
///
@ -199,7 +188,6 @@ bool isBridgeRunning() {
return (!lockFile.tryLock()) && (lockFile.error() == QLockFile::LockFailedError);
}
//****************************************************************************************************************************************************
/// \brief Use api to bring focus on existing bridge instance.
//****************************************************************************************************************************************************
@ -213,8 +201,7 @@ void focusOtherInstance() {
if (!sc.load(path)) {
throw Exception("The gRPC focus service configuration file is invalid.");
}
}
else {
} else {
throw Exception("Server did not provide gRPC Focus service configuration.");
}
@ -225,20 +212,18 @@ void focusOtherInstance() {
if (!client.raise("focusOtherInstance").ok()) {
throw Exception(QString("The raise call to the bridge focus service failed."));
}
}
catch (Exception const &e) {
} catch (Exception const &e) {
app().log().error(e.qwhat());
auto uuid = reportSentryException("Exception occurred during focusOtherInstance()", e);
app().log().fatal(QString("reportID: %1 Captured exception: %2").arg(QByteArray(uuid.bytes, 16).toHex(), e.qwhat()));
}
}
//****************************************************************************************************************************************************
/// \param [in] args list of arguments to pass to bridge.
/// \return bridge executable path
//****************************************************************************************************************************************************
const QString launchBridge(QStringList const &args) {
QString launchBridge(QStringList const &args) {
UPOverseer &overseer = app().bridgeOverseer();
overseer.reset();
@ -251,26 +236,38 @@ const QString launchBridge(QStringList const &args) {
}
qint64 const pid = qApp->applicationPid();
QStringList const params = QStringList { "--grpc", "--parent-pid", QString::number(pid) } + args;
QStringList const params = QStringList{"--grpc", "--parent-pid", QString::number(pid)} + args;
app().log().info(QString("Launching bridge process with command \"%1\" %2").arg(bridgeExePath, params.join(" ")));
overseer = std::make_unique<Overseer>(new ProcessMonitor(bridgeExePath, params, nullptr), nullptr);
overseer->startWorker(true);
return bridgeExePath;
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void closeBridgeApp() {
app().grpc().quit(); // this will cause the grpc service and the bridge app to close.
UPOverseer &overseer = app().bridgeOverseer();
if (overseer) { // A null overseer means the app was run in 'attach' mode. We're not monitoring it.
UPOverseer const &overseer = app().bridgeOverseer();
if (overseer) {
// A null overseer means the app was run in 'attach' mode. We're not monitoring it.
// ReSharper disable once CppExpressionWithoutSideEffects
overseer->wait(Overseer::maxTerminationWaitTimeMs);
}
}
//****************************************************************************************************************************************************
/// \param[in] argv The command-line argments, including the application name at index 0.
//****************************************************************************************************************************************************
void logCommandLineInvocation(QStringList argv) {
Log &log = app().log();
if (argv.isEmpty()) {
log.error("The command line is empty");
}
log.info("bridge-gui executable: " + argv.front());
log.info("Command-line invocation: " + (argv.size() > 1 ? argv.last(argv.size() - 1).join(" ") : "<none>"));
}
//****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments.
@ -289,12 +286,11 @@ int main(int argc, char *argv[]) {
auto sentryCloser = qScopeGuard([] { sentry_close(); });
try {
QString const& configDir = bridgepp::userConfigDir();
QString const &configDir = bridgepp::userConfigDir();
initQtApplication();
CommandLineOptions const cliOptions = parseCommandLine(argc, argv);
QStringList const argvList = cliArgsToStringList(argc, argv);
CommandLineOptions const cliOptions = parseCommandLine(argvList);
Log &log = initLog();
log.setLevel(cliOptions.logLevel);
@ -309,6 +305,8 @@ int main(int argc, char *argv[]) {
setDockIconVisibleState(!cliOptions.noWindow);
#endif
logCommandLineInvocation(argvList);
// In attached mode, we do not intercept stderr and stdout of bridge, as we did not launch it ourselves, so we output the log to the console.
// When not in attached mode, log entries are forwarded to bridge, which output it on stdout/stderr. bridge-gui's process monitor intercept
// these outputs and output them on the command-line.
@ -348,7 +346,6 @@ int main(int argc, char *argv[]) {
QQuickWindow::setSceneGraphBackend((app().settings().useSoftwareRenderer() || cliOptions.useSoftwareRenderer) ? "software" : "rhi");
log.info(QString("Qt Quick renderer: %1").arg(QQuickWindow::sceneGraphBackend()));
QQmlApplicationEngine engine;
std::unique_ptr<QQmlComponent> rootComponent(createRootQmlComponent(engine));
std::unique_ptr<QObject> rootObject(rootComponent->create(engine.rootContext()));
@ -374,7 +371,7 @@ int main(int argc, char *argv[]) {
app().log().debug(QString("Monitoring Bridge PID : %1").arg(status.pid));
connection = QObject::connect(bridgeMonitor, &ProcessMonitor::processExited, [&](int returnCode) {
bridgeExited = true;// clazy:exclude=lambda-in-connect
bridgeExited = true; // clazy:exclude=lambda-in-connect
qGuiApp->exit(returnCode);
});
}
@ -383,7 +380,7 @@ int main(int argc, char *argv[]) {
int result = 0;
if (!startError) {
// we succeeded in launching bridge, so we can be set as mainExecutable.
QString mainexec = QString::fromLocal8Bit(argv[0]);
QString const mainexec = argvList[0];
app().grpc().setMainExecutable(mainexec);
QStringList args = cliOptions.bridgeGuiArgs;
args.append(waitFlag);
@ -412,8 +409,7 @@ int main(int argc, char *argv[]) {
// release the lock file
lock.unlock();
return result;
}
catch (Exception const &e) {
} catch (Exception const &e) {
sentry_uuid_s const uuid = reportSentryException("Exception occurred during main", e);
QString message = e.qwhat();
if (e.showSupportLink()) {

View File

@ -60,7 +60,7 @@ QtObject {
target: Backend
}
}
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion]
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent]
property Notification alreadyLoggedIn: Notification {
brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.")
@ -1130,6 +1130,27 @@ QtObject {
target: Backend
}
}
property Notification hvErrorEvent: Notification {
group: Notifications.Group.Configuration
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
action: Action {
text: qsTr("OK")
onTriggered: {
root.hvErrorEvent.active = false;
}
}
Connections {
function onLoginHvError(errorMsg) {
root.hvErrorEvent.active = true;
root.hvErrorEvent.description = errorMsg;
}
target: Backend
}
}
signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user)

View File

@ -17,256 +17,77 @@ import QtQuick.Controls
Item {
id: root
enum Screen {
CertificateInstall,
ProfileInstall
}
property var wizard
signal appleMailAutoconfigCertificateInstallPageShown
signal appleMailAutoconfigProfileInstallPageShow
property bool profilePaneLaunched: false
function showAutoconfig() {
if (Backend.isTLSCertificateInstalled()) {
showProfileInstall();
} else {
showCertificateInstall();
}
}
function showCertificateInstall() {
certificateInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.CertificateInstall;
appleMailAutoconfigCertificateInstallPageShown();
}
function showProfileInstall() {
profileInstall.reset();
stack.currentIndex = ClientConfigAppleMail.Screen.ProfileInstall;
appleMailAutoconfigProfileInstallPageShow();
function reset() {
profilePaneLaunched = false;
}
StackLayout {
id: stack
anchors.fill: parent
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
// stack index 0
Item {
id: certificateInstall
property string errorString: ""
property bool showBugReportLink: false
property bool waitingForCert: false
function clearError() {
errorString = "";
showBugReportLink = false;
}
function reset() {
waitingForCert = false;
clearError();
}
Layout.fillHeight: true
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Connections {
function onCertificateInstallCanceled() {
certificateInstall.waitingForCert = false;
certificateInstall.errorString = qsTr("Apple Mail cannot be configured if you do not install the certificate. Please retry.");
certificateInstall.showBugReportLink = false;
}
function onCertificateInstallFailed() {
certificateInstall.waitingForCert = false;
certificateInstall.errorString = qsTr("An error occurred while installing the certificate.");
certificateInstall.showBugReportLink = true;
}
function onCertificateInstallSuccess() {
certificateInstall.reset();
root.showAutoconfig();
}
target: Backend
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the bridge certificate")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("After clicking on the button below, a system pop-up will ask you for your credentials, please enter your macOS user credentials (not your Proton accounts) and validate.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 182
opacity: certificateInstall.waitingForCert ? 0.3 : 1.0
source: "/qml/icons/img-macos-cert-screenshot.png"
width: 140
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !certificateInstall.waitingForCert
loading: certificateInstall.waitingForCert
text: qsTr("Install the certificate")
onClicked: {
certificateInstall.clearError();
certificateInstall.waitingForCert = true;
Backend.installTLSCertificate();
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !certificateInstall.waitingForCert
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_small
RowLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_extra_small
ColorImage {
color: wizard.colorScheme.signal_danger
height: errorLabel.lineHeight
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: errorLabel.lineHeight
visible: certificateInstall.errorString.length > 0
}
Label {
id: errorLabel
Layout.fillWidth: true
color: wizard.colorScheme.signal_danger
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: certificateInstall.errorString
type: Label.LabelType.Body_semibold
wrapMode: Text.WordWrap
}
}
LinkLabel {
Layout.alignment: Qt.AlignHCenter
callback: wizard.showBugReport
colorScheme: wizard.colorScheme
link: "#"
text: qsTr("Report the problem")
visible: certificateInstall.showBugReportLink
}
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the profile")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("A system pop-up will appear. Double click on the entry with your email, and click Install in the dialog that appears.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
// stack index 1
Item {
id: profileInstall
property bool profilePaneLaunched: false
function reset() {
profilePaneLaunched = false;
}
Layout.fillHeight: true
Image {
Layout.alignment: Qt.AlignHCenter
height: 102
source: "/qml/icons/img-macos-profile-screenshot.png"
width: 364
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: profilePaneLaunched ? qsTr("I have installed the profile") : qsTr("Install the profile")
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the profile")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("A system pop-up will appear. Double click on the entry with your email, and click Install in the dialog that appears.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
onClicked: {
if (profilePaneLaunched) {
wizard.showClientConfigEnd();
} else {
wizard.user.configureAppleMail(wizard.address);
profilePaneLaunched = true;
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 102
source: "/qml/icons/img-macos-profile-screenshot.png"
width: 364
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
text: qsTr("Cancel")
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: profileInstall.profilePaneLaunched ? qsTr("I have installed the profile") : qsTr("Install the profile")
onClicked: {
if (profileInstall.profilePaneLaunched) {
wizard.showClientConfigEnd();
} else {
wizard.user.configureAppleMail(wizard.address);
profileInstall.profilePaneLaunched = true;
}
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
onClicked: {
wizard.closeWizard();
}
}
}

View File

@ -0,0 +1,153 @@
// 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 <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
Item {
id: root
property string errorString: ""
property bool showBugReportLink: false
property bool waitingForCert: false
property var wizard
function clearError() {
errorString = "";
showBugReportLink = false;
}
function reset() {
waitingForCert = false;
clearError();
}
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_large
Connections {
function onCertificateInstallCanceled() {
root.waitingForCert = false;
root.errorString = qsTr("%1 cannot be configured if you do not install the certificate. Please retry.").arg(wizard.clientName());
root.showBugReportLink = false;
}
function onCertificateInstallFailed() {
root.waitingForCert = false;
root.errorString = qsTr("An error occurred while installing the certificate.");
root.showBugReportLink = true;
}
target: Backend
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Install the bridge certificate")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("After clicking on the button below, a system pop-up will ask you for your credentials, please enter your macOS user credentials (not your Proton accounts) and validate.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Image {
Layout.alignment: Qt.AlignHCenter
height: 182
opacity: root.waitingForCert ? 0.3 : 1.0
source: "/qml/icons/img-macos-cert-screenshot.png"
width: 140
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_medium
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !root.waitingForCert
loading: root.waitingForCert
text: qsTr("Install the certificate")
onClicked: {
root.clearError();
root.waitingForCert = true;
Backend.installTLSCertificate();
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
enabled: !root.waitingForCert
secondary: true
text: qsTr("Cancel")
onClicked: {
wizard.closeWizard();
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_small
RowLayout {
Layout.fillWidth: true
spacing: ProtonStyle.wizard_spacing_extra_small
ColorImage {
color: wizard.colorScheme.signal_danger
height: errorLabel.lineHeight
source: "/qml/icons/ic-exclamation-circle-filled.svg"
sourceSize.height: errorLabel.lineHeight
visible: root.errorString.length > 0
}
Label {
id: errorLabel
Layout.fillWidth: true
color: wizard.colorScheme.signal_danger
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.errorString
type: Label.LabelType.Body_semibold
wrapMode: Text.WordWrap
}
}
LinkLabel {
Layout.alignment: Qt.AlignHCenter
callback: wizard.showBugReport
colorScheme: wizard.colorScheme
link: "#"
text: qsTr("Report the problem")
visible: root.showBugReportLink
}
}
}
}
}

View File

@ -47,6 +47,10 @@ Item {
onClicked: {
wizard.client = SetupWizard.Client.AppleMail;
if (!Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showAppleMailAutoConfig();
}
}
@ -59,6 +63,10 @@ Item {
onClicked: {
wizard.client = SetupWizard.Client.MicrosoftOutlook;
if (root.onMacOS && !Backend.isTLSCertificateInstalled()) {
wizard.showCertInstall();
return
}
wizard.showClientParams();
}
}

View File

@ -34,13 +34,23 @@ Item {
signal startSetup()
function showAppleMailAutoconfigCertificateInstall() {
showAppleMailAutoconfigCommon();
descriptionLabel.text = qsTr("Apple Mail configuration is mostly automated, but in order to work, Bridge needs to install a certificate in your keychain.");
linkLabel1.setCallback(function() { Backend.openExternalLink("https://proton.me/support/apple-mail-certificate"); }, qsTr("Why is this certificate needed?"), true);
function showCertificateInstall() {
showClientConfigCommon();
if (wizard.client === SetupWizard.Client.AppleMail) {
descriptionLabel.text = qsTr("Apple Mail configuration is mostly automated, but in order to work, Bridge needs to install a certificate in your keychain.");
linkLabel1.setCallback(function () {
Backend.openExternalLink("https://proton.me/support/apple-mail-certificate");
}, qsTr("Why is this certificate needed?"), true);
} else {
descriptionLabel.text = qsTr("In order for Outlook to work, Bridge needs to install a certificate in your keychain.");
linkLabel1.setCallback(function () {
Backend.openExternalLink("https://proton.me/support/apple-mail-certificate");
}, qsTr("Why is this certificate needed?"), true);
}
linkLabel2.clear();
}
function showAppleMailAutoconfigCommon() {
function showClientConfigCommon() {
titleLabel.text = "";
linkLabel1.clear();
linkLabel2.clear();
@ -49,7 +59,7 @@ Item {
iconWidth = 80;
}
function showAppleMailAutoconfigProfileInstall() {
showAppleMailAutoconfigCommon();
showClientConfigCommon();
descriptionLabel.text = qsTr("The final step before you can start using Apple Mail is to install the Bridge server profile in the system preferences.\n\nAdding a server profile is necessary to ensure that your Mac can receive and send Proton Mails.");
linkLabel1.setCallback(function() { Backend.openExternalLink("https://proton.me/support/macos-certificate-warning"); }, qsTr("Why is there a yellow warning sign?"), true);
linkLabel2.setCallback(wizard.showClientParams, qsTr("Configure Apple Mail manually"), false);

View File

@ -20,12 +20,14 @@ FocusScope {
enum RootStack {
Login,
TOTP,
MailboxPassword
MailboxPassword,
HV
}
property alias currentIndex: stackLayout.currentIndex
property alias username: usernameTextField.text
property var wizard
property string hvLinkUrl: ""
signal loginAbort(string username, bool wasSignedOut)
@ -47,6 +49,14 @@ FocusScope {
passwordTextField.hidePassword();
secondPasswordTextField.hidePassword();
}
function resetViaHv() {
usernameTextField.enabled = false;
passwordTextField.enabled = false;
signInButton.loading = true;
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
totpLayout.reset();
}
StackLayout {
id: stackLayout
@ -124,6 +134,18 @@ FocusScope {
else
errorLabel.text = qsTr("Incorrect login credentials");
}
function onLoginHvRequested(hvUrl) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected loginHvRequested");
stackLayout.currentIndex = Login.RootStack.HV;
hvUsernameLabel.text = usernameTextField.text;
hvLinkUrl = hvUrl;
}
function onLoginHvError(_) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected onLoginHvInvalidTokenError");
stackLayout.currentIndex = Login.RootStack.Login;
root.resetViaHv();
root.reset()
}
target: Backend
}
@ -475,5 +497,112 @@ FocusScope {
}
}
}
Item {
id: hvLayout
ColumnLayout {
Layout.fillWidth: true
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_extra_large
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_small
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Human verification")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
id: hvUsernameLabel
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: wizard.colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
type: Label.LabelType.Body
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Please open the following link in your favourite web browser to verify you are human.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Label {
id: hvRequestedUrlText
type: Label.LabelType.Lead
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignLeft
text: "<a href='" + hvLinkUrl + "'>" + hvLinkUrl.replace("&", "&amp;")+ "</a>"
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.openUrlExternally(hvLinkUrl);
}
}
}
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
Button {
id: hVContinueButton
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: qsTr("Continue")
function checkAndSignInHv() {
console.assert(stackLayout.currentIndex === Login.RootStack.HV || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected checkInAndSignInHv")
stackLayout.currentIndex = Login.RootStack.Login
usernameTextField.validate();
passwordTextField.validate();
if (usernameTextField.error || passwordTextField.error) {
return;
}
root.resetViaHv();
Backend.loginHv(usernameTextField.text, Qt.btoa(passwordTextField.text));
}
onClicked: {
checkAndSignInHv()
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
secondaryIsOpaque: true
text: qsTr("Cancel")
onClicked: {
root.abort();
}
}
}
}
}
}
}

View File

@ -27,6 +27,7 @@ Item {
Onboarding,
Login,
ClientConfigSelector,
ClientConfigCertInstall,
ClientConfigAppleMail
}
enum RootStack {
@ -95,8 +96,9 @@ Item {
function showAppleMailAutoConfig() {
backAction = _showClientConfig;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
clientConfigAppleMail.reset()
rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigAppleMail;
clientConfigAppleMail.showAutoconfig(); // This will trigger signals that will display the appropriate left content.
leftContent.showAppleMailAutoconfigProfileInstall();
}
function showBugReport() {
closeWizard();
@ -118,6 +120,15 @@ Item {
backAction = _showClientConfig;
rootStackLayout.currentIndex = SetupWizard.RootStack.ClientConfigParameters;
}
function showCertInstall() {
backAction = _showClientConfig;
clientConfigCertInstall.reset();
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
leftContent.showCertificateInstall()
rightContent.currentIndex = SetupWizard.ContentStack.ClientConfigCertInstall;
}
function showLogin(username = "") {
backAction = null;
rootStackLayout.currentIndex = SetupWizard.RootStack.TwoPanesView;
@ -146,7 +157,13 @@ Item {
let address = user ? user.addresses[0] : "";
showClientConfig(user, address, true);
}
function onCertificateInstallSuccess() {
if (client === SetupWizard.Client.MicrosoftOutlook) {
showClientParams()
} else {
showAppleMailAutoConfig()
}
}
target: Backend
}
StackLayout {
@ -176,17 +193,6 @@ Item {
width: ProtonStyle.wizard_pane_width
wizard: root
Connections {
function onAppleMailAutoconfigCertificateInstallPageShown() {
leftContent.showAppleMailAutoconfigCertificateInstall();
}
function onAppleMailAutoconfigProfileInstallPageShow() {
leftContent.showAppleMailAutoconfigProfileInstall();
}
target: clientConfigAppleMail
}
Connections {
function onLogin2FARequested() {
leftContent.showLogin2FA();
@ -247,7 +253,14 @@ Item {
id: clientConfigSelector
wizard: root
}
// rightContent stack index 3
ClientConfigCertInstall {
id: clientConfigCertInstall
wizard: root
}
// rightContent stack index 4
ClientConfigAppleMail {
id: clientConfigAppleMail
wizard: root

View File

@ -174,14 +174,16 @@ endif ()
include(FetchContent)
FetchContent_Declare(
googletest
GTest
URL https://github.com/google/googletest/archive/b796f7d44681514f58a683a3a71ff17c94edb0c1.zip
FIND_PACKAGE_ARGS
)
# For Windows: Prevent overriding the parent project's compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
set(INSTALL_GTEST OFF)
FetchContent_MakeAvailable(googletest)
FetchContent_MakeAvailable(GTest)
enable_testing()

View File

@ -17,22 +17,22 @@
#include <bridgepp/CLI/CLIUtils.h>
#include <bridgepp/SessionID/SessionID.h>
#include <gtest/gtest.h>
using namespace bridgepp;
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
TEST(CLI, stripStringParameterFromCommandLine) {
struct Test {
struct TestData {
QStringList input;
QStringList expectedOutput;
};
QList<Test> const tests = {
QList<TestData> const tests = {
{{}, {}},
{{ "--a", "-b", "--C" }, { "--a", "-b", "--C" } },
{{ "--string", "value" }, {} },
@ -44,7 +44,45 @@ TEST(CLI, stripStringParameterFromCommandLine) {
{{ "--string", "--string", "value", "-b", "--string"}, { "value", "-b" } },
};
for (Test const& test: tests) {
for (TestData const& test: tests) {
EXPECT_EQ(stripStringParameterFromCommandLine("--string", test.input), test.expectedOutput);
}
}
TEST(CLI, parseGoCLIStringArgument) {
struct TestData {
QStringList args;
QStringList params;
QStringList expectedOutput;
};
QList<TestData> const tests = {
{ {}, {}, {} },
{ {"-param"}, {"param"}, {} },
{ {"--param", "1"}, {"param"}, { "1" } },
{ {"--param", "1","p", "-p", "2", "-flag", "-param=3", "--p=4"}, {"param", "p"}, { "1", "2", "3", "4" } },
{ {"--param", "--param", "1"}, {"param"}, { "--param" } },
};
for (TestData const& test: tests) {
EXPECT_EQ(parseGoCLIStringArgument(test.args, test.params), test.expectedOutput);
}
}
TEST(CLI, cliArgsToStringList) {
int constexpr argc = 3;
char *argv[] = { const_cast<char *>("1"), const_cast<char *>("2"), const_cast<char *>("3") };
QStringList const strList { "1", "2", "3" };
EXPECT_EQ(cliArgsToStringList(argc,argv), strList);
EXPECT_EQ(cliArgsToStringList(0, nullptr), QStringList {});
}
TEST(CLI, mostRecentSessionID) {
QStringList const sessionIDs { "20220411_155931148", "20230411_155931148", "20240411_155931148" };
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[0] }), sessionIDs[0]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[1], hyphenatedSessionIDFlag, sessionIDs[2] }), sessionIDs[2]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[2], hyphenatedSessionIDFlag, sessionIDs[1] }), sessionIDs[2]);
EXPECT_EQ(mostRecentSessionID({ hyphenatedSessionIDFlag, sessionIDs[1], hyphenatedSessionIDFlag, sessionIDs[2], hyphenatedSessionIDFlag,
sessionIDs[0] }), sessionIDs[2]);
}

View File

@ -16,7 +16,7 @@
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#include "CLIUtils.h"
#include "../SessionID/SessionID.h"
namespace bridgepp {
@ -42,4 +42,67 @@ QStringList stripStringParameterFromCommandLine(QString const &paramName, QStrin
}
//****************************************************************************************************************************************************
/// The flags may be present more than once in the args. All values are returned in order of appearance.
///
/// \param[in] args The arguments
/// \param[in] paramNames the list of names for the parameter, without any prefix hypen.
/// \return The values found for the flag.
//****************************************************************************************************************************************************
QStringList parseGoCLIStringArgument(QStringList const &args, QStringList const& paramNames) {
// go cli package is pretty permissive when it comes to parsing arguments. For each name 'param', all the following seems to be accepted:
// -param value
// --param value
// -param=value
// --param=value
QStringList result;
qsizetype const argCount = args.count();
for (qsizetype i = 0; i < args.size(); ++i) {
for (QString const &paramName: paramNames) {
if ((i < argCount - 1) && ((args[i] == "-" + paramName) || (args[i] == "--" + paramName))) {
result.append(args[i + 1]);
i += 1;
continue;
}
if (QRegularExpressionMatch match = QRegularExpression(QString("^-{1,2}%1=(.+)$").arg(paramName)).match(args[i]); match.hasMatch()) {
result.append(match.captured(1));
continue;
}
}
}
return result;
}
//****************************************************************************************************************************************************
/// \param[in] argc The number of command-line arguments.
/// \param[in] argv The list of command-line arguments.
/// \return A QStringList representing the arguments list.
//****************************************************************************************************************************************************
QStringList cliArgsToStringList(int argc, char **argv) {
QStringList result;
result.reserve(argc);
for (qsizetype i = 0; i < argc; ++i) {
result.append(QString::fromLocal8Bit(argv[i]));
}
return result;
}
//****************************************************************************************************************************************************
/// \param[in] args The command-line arguments.
/// \return The most recent sessionID in the list. If the list is empty, a new sessionID is created.
//****************************************************************************************************************************************************
QString mostRecentSessionID(QStringList const& args) {
QStringList const sessionIDs = parseGoCLIStringArgument(args, {sessionIDFlag});
if (sessionIDs.isEmpty()) {
return newSessionID();
}
return *std::max_element(sessionIDs.constBegin(), sessionIDs.constEnd(), [](QString const &lhs, QString const &rhs) -> bool {
return sessionIDToDateTime(lhs) < sessionIDToDateTime(rhs);
});
}
} // namespace bridgepp

View File

@ -15,18 +15,16 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
#ifndef BRIDGEPP_CLI_UTILS_H
#define BRIDGEPP_CLI_UTILS_H
namespace bridgepp {
QStringList stripStringParameterFromCommandLine(QString const &paramName, QStringList const &commandLineParams); ///< Remove a string parameter from a list of command-line parameters.
QStringList parseGoCLIStringArgument(QStringList const &args, QStringList const &paramNames); ///< Parse a command-line string argument as expected by go's CLI package.
QStringList cliArgsToStringList(int argc, char **argv); ///< Converts C-style command-line arguments to a string list.
QString mostRecentSessionID(QStringList const& args); ///< Returns the most recent sessionID parsed in command-line arguments.
}
#endif // BRIDGEPP_CLI_UTILS_H

View File

@ -302,6 +302,18 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username) {
}
//****************************************************************************************************************************************************
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newLoginHvRequestedEvent() {
auto event = new ::grpc::LoginHvRequestedEvent;
event->set_hvurl("https://verify.proton.me/?methods=captcha&token=SOME_RANDOM_TOKEN");
auto loginEvent = new grpc::LoginEvent;
loginEvent->set_allocated_hvrequested(event);
return wrapLoginEvent(loginEvent);
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \return The event.

View File

@ -48,6 +48,7 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username); ///< Create a
SPStreamEvent newLoginTwoPasswordsRequestedEvent(QString const &username); ///< Create a new LoginTwoPasswordsRequestedEvent event.
SPStreamEvent newLoginFinishedEvent(QString const &userID, bool wasSignedOut); ///< Create a new LoginFinishedEvent event.
SPStreamEvent newLoginAlreadyLoggedInEvent(QString const &userID); ///< Create a new LoginAlreadyLoggedInEvent event.
SPStreamEvent newLoginHvRequestedEvent(); ///< Create a new LoginHvRequestedEvent
// Update related events
SPStreamEvent newUpdateErrorEvent(grpc::UpdateErrorType errorType); ///< Create a new UpdateErrorEvent event.

View File

@ -23,7 +23,6 @@
#include "../ProcessMonitor.h"
#include "../Log/LogUtils.h"
using namespace google::protobuf;
using namespace grpc;
@ -607,6 +606,20 @@ grpc::Status GRPCClient::login(QString const &username, QString const &password)
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \param[in] password The password.
/// \return the status for the gRPC call.
//****************************************************************************************************************************************************
grpc::Status GRPCClient::loginHv(QString const &username, QString const &password) {
LoginRequest request;
request.set_username(username.toStdString());
request.set_password(password.toStdString());
request.set_usehvdetails(true);
return this->logGRPCCallStatus(stub_->Login(this->clientContext().get(), request, &empty), __FUNCTION__);
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \param[in] code The The 2FA code.
@ -1221,6 +1234,9 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
case TWO_PASSWORDS_ABORT:
emit login2PasswordErrorAbort(QString::fromStdString(error.message()));
break;
case HV_ERROR:
emit loginHvError(QString::fromStdString(error.message()));
break;
default:
this->logError("Unknown login error event received.");
break;
@ -1245,6 +1261,10 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
this->logTrace("Login event received: AlreadyLoggedIn.");
emit loginAlreadyLoggedIn(QString::fromStdString(event.finished().userid()));
break;
case LoginEvent::kHvRequested:
this->logTrace("Login event Received: HvRequested");
emit loginHvRequested(QString::fromStdString(event.hvrequested().hvurl()));
break;
default:
this->logError("Unknown Login event received.");
break;

View File

@ -155,6 +155,7 @@ public: // login related calls
grpc::Status login2FA(QString const &username, QString const &code); ///< Performs the 'login2FA' call.
grpc::Status login2Passwords(QString const &username, QString const &password); ///< Performs the 'login2Passwords' call.
grpc::Status loginAbort(QString const &username); ///< Performs the 'loginAbort' call.
grpc::Status loginHv(QString const &username, QString const &password); ///< Performs the 'login' call with additional useHv flag
signals:
void loginUsernamePasswordError(QString const &errMsg);
@ -168,6 +169,8 @@ signals:
void login2PasswordErrorAbort(QString const &errMsg);
void loginFinished(QString const &userID, bool wasSignedOut);
void loginAlreadyLoggedIn(QString const &userID);
void loginHvRequested(QString const &hvUrl);
void loginHvError(QString const &errMsg);
public: // Update related calls
grpc::Status checkUpdate();

View File

@ -32,6 +32,10 @@ QString const dateTimeFormat = "yyyyMMdd_hhmmsszzz"; ///< The format string for
namespace bridgepp {
QString const sessionIDFlag = "session-id";
QString const hyphenatedSessionIDFlag = "--" + sessionIDFlag;
//****************************************************************************************************************************************************
/// \return a new session ID based on the current local date/time
//****************************************************************************************************************************************************

View File

@ -23,6 +23,10 @@
namespace bridgepp {
extern QString const sessionIDFlag; ///< The sessionID command-line flag (without hyphens)
extern QString const hyphenatedSessionIDFlag; ///< The sessionID command-line flag (with two hyphens)
QString newSessionID(); ///< Create a new sessions
QDateTime sessionIDToDateTime(QString const &sessionID); ///< Parse the date/time from a sessionID.

View File

@ -19,12 +19,14 @@ package cli
import (
"context"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/abiosoft/ishell"
)
@ -116,6 +118,13 @@ func (f *frontendCLI) showAccountAddressInfo(user bridge.UserInfo, address strin
f.Println("")
}
func (f *frontendCLI) promptHvURL(details *proton.APIHVDetails) {
hvURL := hv.FormatHvURL(details)
fmt.Print("\nHuman Verification requested. Please open the URL below in a browser and press ENTER when the challenge has been completed.\n\n", hvURL+"\n")
f.ReadLine()
fmt.Println("Authenticating ...")
}
func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.ShowPrompt(false)
defer f.ShowPrompt(true)
@ -144,7 +153,19 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.Println("Authenticating ... ")
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password))
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), nil)
var hvDetails *proton.APIHVDetails
hvDetails, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil || hvDetails != nil {
if hvErr != nil {
f.printAndLogError("Cannot login", hvErr)
return
}
f.promptHvURL(hvDetails)
client, auth, err = f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
}
if err != nil {
f.printAndLogError("Cannot login: ", err)
return
@ -175,7 +196,55 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
keyPass = []byte(password)
}
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass)
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
hvDetails, hvErr = hv.VerifyAndExtractHvRequest(err)
if hvDetails != nil || hvErr != nil {
if hvErr != nil {
f.printAndLogError("Cannot login: ", hvErr)
return
}
f.loginAccountHv(c, loginName, password, keyPass, hvDetails)
return
}
if err != nil {
f.processAPIError(err)
return
}
user, err := f.bridge.GetUserInfo(userID)
if err != nil {
panic(err)
}
f.Printf("Account %s was added successfully.\n", bold(user.Username))
}
func (f *frontendCLI) loginAccountHv(c *ishell.Context, loginName string, password string, keyPass []byte, hvDetails *proton.APIHVDetails) {
f.promptHvURL(hvDetails)
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
if err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
if auth.TwoFA.Enabled&proton.HasTOTP != 0 {
code := f.readStringInAttempts("Two factor code", c.ReadLine, isNotEmpty)
if code == "" {
f.printAndLogError("Cannot login: need two factor code")
return
}
if err := client.Auth2FA(context.Background(), proton.Auth2FAReq{TwoFactorCode: code}); err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
}
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
if err != nil {
f.processAPIError(err)
return

File diff suppressed because it is too large Load Diff

View File

@ -168,6 +168,7 @@ message ReportBugRequest {
message LoginRequest {
string username = 1;
bytes password = 2;
optional bool useHvDetails = 3;
}
message LoginAbortRequest {
@ -308,6 +309,7 @@ message LoginEvent {
LoginTwoPasswordsRequestedEvent twoPasswordRequested = 3;
LoginFinishedEvent finished = 4;
LoginFinishedEvent alreadyLoggedIn = 5;
LoginHvRequestedEvent hvRequested = 6;
}
}
@ -319,6 +321,7 @@ enum LoginErrorType {
TFA_ABORT = 4;
TWO_PASSWORDS_ERROR = 5;
TWO_PASSWORDS_ABORT = 6;
HV_ERROR = 7;
}
message LoginErrorEvent {
@ -339,6 +342,10 @@ message LoginFinishedEvent {
bool wasSignedOut = 2;
}
message LoginHvRequestedEvent {
string hvUrl = 1;
}
//**********************************************************
// Update related events
//**********************************************************

View File

@ -100,6 +100,10 @@ func NewLoginAlreadyLoggedInEvent(userID string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_AlreadyLoggedIn{AlreadyLoggedIn: &LoginFinishedEvent{UserID: userID}}})
}
func NewLoginHvRequestedEvent(hvChallengeURL string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_HvRequested{HvRequested: &LoginHvRequestedEvent{HvUrl: hvChallengeURL}}})
}
func NewUpdateErrorEvent(errorType UpdateErrorType) *StreamEvent {
return updateEvent(&UpdateEvent{Event: &UpdateEvent_Error{Error: &UpdateErrorEvent{Type: errorType}}})
}

View File

@ -38,6 +38,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
@ -95,6 +96,9 @@ type Service struct { // nolint:structcheck
parentPID int
parentPIDDoneCh chan struct{}
showOnStartup bool
hvDetails *proton.APIHVDetails
useHvDetails bool
}
// NewService returns a new instance of the service.
@ -412,6 +416,7 @@ func (s *Service) loginClean() {
s.password[i] = '\x00'
}
s.password = s.password[0:0]
s.useHvDetails = false
}
func (s *Service) finishLogin() {
@ -424,6 +429,11 @@ func (s *Service) finishLogin() {
wasSignedOut := s.bridge.HasUser(s.auth.UserID)
var hvDetails *proton.APIHVDetails
if s.useHvDetails {
hvDetails = s.hvDetails
}
if len(s.password) == 0 || s.auth.UID == "" || s.authClient == nil {
s.log.
WithField("hasPass", len(s.password) != 0).
@ -439,8 +449,20 @@ func (s *Service) finishLogin() {
defer done()
ctx := context.Background()
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password)
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password, hvDetails)
if err != nil {
if hv.IsHvRequest(err) {
s.handleHvRequest(err)
performCleanup = false
return
}
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Code == proton.HumanValidationInvalidToken {
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
return
}
s.log.WithError(err).Errorf("Finish login failed")
s.twoPasswordAttemptCount++
errType := LoginErrorType_TWO_PASSWORDS_ABORT
@ -614,6 +636,18 @@ func (s *Service) monitorParentPID() {
}
}
func (s *Service) handleHvRequest(err error) {
hvDet, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil {
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, hvErr.Error()))
return
}
s.hvDetails = hvDet
hvChallengeURL := hv.FormatHvURL(hvDet)
_ = s.SendEvent(NewLoginHvRequestedEvent(hvChallengeURL))
}
// computeFileSocketPath Return an available path for a socket file in the temp folder.
func computeFileSocketPath() (string, error) {
tempPath := os.TempDir()

View File

@ -396,6 +396,14 @@ func (s *Service) RequestKnowledgeBaseSuggestions(_ context.Context, userInput *
func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty, error) {
s.log.WithField("username", login.Username).Debug("Login")
var hvDetails *proton.APIHVDetails
if login.UseHvDetails != nil && *login.UseHvDetails {
hvDetails = s.hvDetails
s.useHvDetails = true
} else {
s.useHvDetails = false
}
go func() {
defer async.HandlePanic(s.panicHandler)
@ -407,7 +415,7 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
return
}
client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password)
client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password, hvDetails)
if err != nil {
defer s.loginClean()
@ -421,6 +429,13 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
case proton.PaidPlanRequired:
_ = s.SendEvent(NewLoginError(LoginErrorType_FREE_USER, ""))
case proton.HumanVerificationRequired:
s.handleHvRequest(apiErr)
case proton.HumanValidationInvalidToken:
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
default:
_ = s.SendEvent(NewLoginError(LoginErrorType_USERNAME_PASSWORD_ERROR, err.Error()))
}
@ -522,7 +537,6 @@ func (s *Service) LoginAbort(_ context.Context, loginAbort *LoginAbortRequest) (
go func() {
defer async.HandlePanic(s.panicHandler)
s.loginAbort()
}()

62
internal/hv/hv.go Normal file
View File

@ -0,0 +1,62 @@
// 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 <https://www.gnu.org/licenses/>.
package hv
import (
"errors"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
)
// VerifyAndExtractHvRequest expects an error request as input
// determines whether the given error is a Proton human verification request; if it isn't then it returns -> nil, nil (no details, no error)
// if it is a HV req. then it tries to parse the json data and verify that the captcha method is included; if either fails -> nil, err
// if the HV request was successfully decoded and the preconditions were met it returns the hv details -> hvDetails, nil.
func VerifyAndExtractHvRequest(err error) (*proton.APIHVDetails, error) {
if err == nil {
return nil, nil
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
hvDetails, hvErr := protonErr.GetHVDetails()
if hvErr != nil {
return nil, fmt.Errorf("received HV request, but can't decode HV details")
}
return hvDetails, nil
}
return nil, nil
}
func IsHvRequest(err error) bool {
if err == nil {
return false
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
return true
}
return false
}
func FormatHvURL(details *proton.APIHVDetails) string {
return fmt.Sprintf("https://verify.proton.me/?methods=%v&token=%v",
strings.Join(details.Methods, ","),
details.Token)
}

144
internal/hv/hv_test.go Normal file
View File

@ -0,0 +1,144 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.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 <https://www.gnu.org/licenses/>.
package hv
import (
"encoding/json"
"fmt"
"testing"
"github.com/ProtonMail/go-proton-api"
"github.com/stretchr/testify/require"
)
func TestVerifyAndExtractHvRequest(t *testing.T) {
det1, _ := json.Marshal("test")
det2, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email"}, Token: "test"})
det3, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha"}, Token: "test"})
det4, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email", "test"}, Token: "test"})
det5, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha", "ownership-email"}, Token: "test"})
tests := []struct {
err error
hasHvDetails bool
hasErr bool
}{
{err: nil,
hasHvDetails: false,
hasErr: false},
{err: fmt.Errorf("test"),
hasHvDetails: false,
hasErr: false},
{err: new(proton.APIError),
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 429},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 9001},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Code: 9001},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det1},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det2},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det3},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det4},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det5},
hasHvDetails: true,
hasErr: false},
}
for _, test := range tests {
hvDetails, err := VerifyAndExtractHvRequest(test.err)
hasHv := hvDetails != nil
hasErr := err != nil
require.True(t, hasHv == test.hasHvDetails && hasErr == test.hasErr)
}
}
func TestIsHvRequest(t *testing.T) {
tests := []struct {
err error
result bool
}{
{
err: nil,
result: false,
},
{
err: fmt.Errorf("test"),
result: false,
},
{
err: new(proton.APIError),
result: false,
},
{
err: &proton.APIError{Status: 429},
result: false,
},
{
err: &proton.APIError{Status: 9001},
result: false,
},
{
err: &proton.APIError{Code: 9001},
result: true,
},
}
for _, test := range tests {
isHvRequest := IsHvRequest(test.err)
require.Equal(t, test.result, isHvRequest)
}
}
func TestFormatHvURL(t *testing.T) {
tests := []struct {
details *proton.APIHVDetails
result string
}{
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: "test"},
result: "https://verify.proton.me/?methods=test&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{""}, Token: "test"},
result: "https://verify.proton.me/?methods=&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: ""},
result: "https://verify.proton.me/?methods=test&token=",
},
}
for _, el := range tests {
result := FormatHvURL(el.details)
require.Equal(t, el.result, result)
}
}

View File

@ -23,12 +23,15 @@ import (
"errors"
"fmt"
"net/mail"
"strings"
"sync/atomic"
"time"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/gluon/connector"
"github.com/ProtonMail/gluon/imap"
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/gluon/rfc5322"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
@ -54,6 +57,7 @@ type Connector struct {
identityState sharedIdentity
client APIClient
telemetry Telemetry
reporter reporter.Reporter
panicHandler async.PanicHandler
sendRecorder *sendrecorder.SendRecorder
@ -75,6 +79,7 @@ func NewConnector(
sendRecorder *sendrecorder.SendRecorder,
panicHandler async.PanicHandler,
telemetry Telemetry,
reporter reporter.Reporter,
showAllMail bool,
syncState *SyncState,
) *Connector {
@ -90,6 +95,7 @@ func NewConnector(
client: apiClient,
telemetry: telemetry,
reporter: reporter,
panicHandler: panicHandler,
sendRecorder: sendRecorder,
@ -279,7 +285,7 @@ func (s *Connector) CreateMessage(ctx context.Context, _ connector.IMAPStateWrit
if messageID, ok, err := s.sendRecorder.HasEntryWait(ctx, hash, time.Now().Add(90*time.Second), toList); err != nil {
return imap.Message{}, nil, fmt.Errorf("failed to check send hash: %w", err)
} else if ok {
s.log.WithField("messageID", messageID).Warn("Message already sent")
s.log.WithField("messageID", messageID).Warn("Message already in sent mailbox")
// Query the server-side message.
full, err := s.client.GetFullMessage(ctx, messageID, usertypes.NewProtonAPIScheduler(s.panicHandler), proton.NewDefaultAttachmentAllocator())
@ -671,11 +677,21 @@ func (s *Connector) importMessage(
) (imap.Message, []byte, error) {
var full proton.FullMessage
// addr is primary for combined mode or active for split mode
addr, ok := s.identityState.GetAddress(s.addrID)
if !ok {
return imap.Message{}, nil, fmt.Errorf("could not find address")
}
p, err2 := parser.New(bytes.NewReader(literal))
if err2 != nil {
return imap.Message{}, nil, fmt.Errorf("failed to parse literal: %w", err2)
}
isDraft := slices.Contains(labelIDs, proton.DraftsLabel)
s.reportGODT3185(isDraft, addr.Email, p, s.addressMode == usertypes.AddressModeCombined)
if err := s.identityState.WithAddrKR(s.addrID, func(_, addrKR *crypto.KeyRing) error {
primaryKey, errKey := addrKR.FirstKey()
if errKey != nil {
@ -683,11 +699,8 @@ func (s *Connector) importMessage(
}
var messageID string
p, err2 := parser.New(bytes.NewReader(literal))
if err2 != nil {
return fmt.Errorf("failed to parse literal: %w", err2)
}
if slices.Contains(labelIDs, proton.DraftsLabel) {
if isDraft {
msg, err := s.createDraftWithParser(ctx, p, primaryKey, addr)
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
@ -850,3 +863,94 @@ func defaultMailboxPermanentFlags() imap.FlagSet {
func defaultMailboxAttributes() imap.FlagSet {
return imap.NewFlagSet()
}
func stripPlusAlias(a string) string {
iPlus := strings.Index(a, "+")
iAt := strings.Index(a, "@")
if iPlus <= 0 || iAt <= 0 || iPlus >= iAt {
return a
}
return a[:iPlus] + a[iAt:]
}
func equalAddresses(a, b string) bool {
return strings.EqualFold(stripPlusAlias(a), stripPlusAlias(b))
}
func (s *Connector) reportGODT3185(isDraft bool, defaultAddr string, p *parser.Parser, isCombinedMode bool) {
reportAction := "draft"
if !isDraft {
reportAction = "import"
}
reportMode := "combined"
if !isCombinedMode {
reportMode = "split"
}
senderAddr := ""
if p != nil && p.Root() != nil && p.Root().Header.Len() != 0 {
addrField := p.Root().Header.Get("From")
if addrField == "" {
addrField = p.Root().Header.Get("Sender")
}
if addrField != "" {
sender, err := rfc5322.ParseAddressList(addrField)
if err == nil && len(sender) > 0 {
senderAddr = sender[0].Address
} else {
s.log.WithError(err).Warn("Invalid sender address in reporter")
}
}
}
if equalAddresses(defaultAddr, senderAddr) {
return
}
isDisabled := false
isUserAddress := false
for _, a := range s.identityState.GetAddresses() {
if !equalAddresses(a.Email, senderAddr) {
continue
}
isUserAddress = true
isDisabled = !bool(a.Send) || (a.Status != proton.AddressStatusEnabled)
break
}
if !isUserAddress && senderAddr != "" {
return
}
reportResult := "using sender address"
if !isCombinedMode {
reportResult = "error address not match"
}
reportAddress := ""
if senderAddr == "" {
reportAddress = " invalid"
reportResult = "error import/draft"
}
if isDisabled {
reportAddress = " disabled"
if isDraft {
reportResult = "error draft"
}
}
report := fmt.Sprintf(
"GODT-3185: %s with non-default%s address in %s mode: %s",
reportAction, reportAddress, reportMode, reportResult,
)
s.log.Warn(report)
if s.reporter != nil {
_ = s.reporter.ReportMessage(report)
}
}

View File

@ -203,3 +203,35 @@ func TestFixGODT3003Labels_Noop(t *testing.T) {
require.NoError(t, err)
require.False(t, applied)
}
func TestStripPlusAlias(t *testing.T) {
cases := map[string]string{
"one@three.com": "one@three.com",
"one+two@three.com": "one@three.com",
"one@three+two.com": "one@three+two.com",
"+one@three.com": "+one@three.com",
"@three.com": "@three.com",
}
for given, want := range cases {
require.Equal(t, want, stripPlusAlias(given), "input was %q", given)
}
}
func TestEqualAddresse(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"one@three.com", "one@three.com", true},
{"one@three.com", "one+two@three.com", true},
{"OnE@thReE.com", "One@THree.com", true},
{"one@three.com", "two@three.com", false},
{"one+two@three.com", "two@three.com", false},
{"one@three.com", "one@three+two.com", false},
}
for _, c := range cases {
require.Equal(t, c.want, equalAddresses(c.a, c.b), "input was %q and %q", c.a, c.b)
}
}

View File

@ -508,6 +508,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.sendRecorder,
s.panicHandler,
s.telemetry,
s.reporter,
s.showAllMail,
s.syncStateProvider,
)
@ -525,6 +526,7 @@ func (s *Service) buildConnectors() (map[string]*Connector, error) {
s.sendRecorder,
s.panicHandler,
s.telemetry,
s.reporter,
s.showAllMail,
s.syncStateProvider,
)

View File

@ -155,6 +155,7 @@ func addNewAddressSplitMode(ctx context.Context, s *Service, addrID string) erro
s.sendRecorder,
s.panicHandler,
s.telemetry,
s.reporter,
s.showAllMail,
s.syncStateProvider,
)

View File

@ -46,6 +46,7 @@ func defaultMessageJobOpts() message.JobOptions {
AddExternalID: true, // Whether to include ExternalID as X-Pm-External-Id.
AddMessageDate: true, // Whether to include message time as X-Pm-Date.
AddMessageIDReference: true, // Whether to include the MessageID in References.
SanitizeMBOXHeaderLine: true, // Whether to ignore header line representing MBOX delimiter
}
}

View File

@ -55,9 +55,8 @@ type Service struct {
panicHandler async.PanicHandler
reporter reporter.Reporter
loadedUserCount int
log *logrus.Entry
tasks *async.Group
log *logrus.Entry
tasks *async.Group
uidValidityGenerator imap.UIDValidityGenerator
telemetry Telemetry
@ -108,6 +107,16 @@ func (sm *Service) Init(ctx context.Context, group *async.Group, subscription ev
})
})
if err := sm.serveIMAP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start IMAP server")
return err
}
if err := sm.serveSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server")
return err
}
return nil
}
@ -255,30 +264,16 @@ func (sm *Service) run(ctx context.Context, subscription events.Subscription) {
}
func (sm *Service) handleLoadedUserCountChange(ctx context.Context) {
sm.log.Infof("Validating Listener State %v", sm.loadedUserCount)
if sm.shouldStartServers() {
if sm.imapListener == nil {
if err := sm.serveIMAP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start IMAP server")
}
sm.log.Infof("Validating Listener State")
if sm.imapListener == nil {
if err := sm.serveIMAP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start IMAP server")
}
}
if sm.smtpListener == nil {
if err := sm.restartSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server")
}
}
} else {
if sm.imapListener != nil {
if err := sm.stopIMAPListener(ctx); err != nil {
sm.log.WithError(err).Error("Failed to stop IMAP server")
}
}
if sm.smtpListener != nil {
if err := sm.closeSMTPServer(ctx); err != nil {
sm.log.WithError(err).Error("Failed to stop SMTP server")
}
if sm.smtpListener == nil {
if err := sm.restartSMTP(ctx); err != nil {
sm.log.WithError(err).Error("Failed to start SMTP server")
}
}
}
@ -307,12 +302,7 @@ func (sm *Service) handleAddIMAPUser(ctx context.Context,
) error {
// Due to the many different error exits, performer user count change at this stage rather we split the incrementing
// of users from the logic.
err := sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider)
if err == nil {
sm.loadedUserCount++
}
return err
return sm.handleAddIMAPUserImpl(ctx, connector, addrID, idProvider, syncStateProvider)
}
func (sm *Service) handleAddIMAPUserImpl(ctx context.Context,
@ -436,8 +426,6 @@ func (sm *Service) handleRemoveIMAPUser(ctx context.Context, withData bool, idPr
return fmt.Errorf("failed to remove IMAP user ID: %w", err)
}
}
sm.loadedUserCount--
}
return nil
@ -542,11 +530,7 @@ func (sm *Service) restartIMAP(ctx context.Context) error {
sm.eventPublisher.PublishEvent(ctx, events.IMAPServerStopped{})
}
if sm.shouldStartServers() {
return sm.serveIMAP(ctx)
}
return nil
return sm.serveIMAP(ctx)
}
func (sm *Service) restartSMTP(ctx context.Context) error {
@ -560,11 +544,7 @@ func (sm *Service) restartSMTP(ctx context.Context) error {
sm.smtpServer = newSMTPServer(sm.smtpAccounts, sm.smtpSettings)
if sm.shouldStartServers() {
return sm.serveSMTP(ctx)
}
return nil
return sm.serveSMTP(ctx)
}
func (sm *Service) serveSMTP(ctx context.Context) error {
@ -679,8 +659,6 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
return fmt.Errorf("failed to close IMAP: %w", err)
}
sm.loadedUserCount = 0
if err := moveGluonCacheDir(sm.imapSettings, currentGluonDir, newGluonDir); err != nil {
sm.log.WithError(err).Error("failed to move GluonCacheDir")
@ -700,19 +678,13 @@ func (sm *Service) handleSetGluonDir(ctx context.Context, newGluonDir string) er
sm.imapServer = imapServer
if sm.shouldStartServers() {
if err := sm.serveIMAP(ctx); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
if err := sm.serveIMAP(ctx); err != nil {
return fmt.Errorf("failed to serve IMAP: %w", err)
}
return nil
}
func (sm *Service) shouldStartServers() bool {
return sm.loadedUserCount >= 1
}
type smRequestClose struct{}
type smRequestRestartIMAP struct{}

View File

@ -27,14 +27,14 @@ var ErrInvalidReturnPath = errors.New("invalid return path")
var ErrNoSuchUser = errors.New("no such user")
var ErrTooManyErrors = errors.New("too many failed requests, please try again later")
type ErrCanNotSendOnAddress struct {
type ErrCannotSendFromAddress struct {
address string
}
func NewErrCanNotSendOnAddress(address string) *ErrCanNotSendOnAddress {
return &ErrCanNotSendOnAddress{address: address}
func NewErrCannotSendFromAddress(address string) *ErrCannotSendFromAddress {
return &ErrCannotSendFromAddress{address: address}
}
func (e ErrCanNotSendOnAddress) Error() string {
return fmt.Sprintf("can't send on address: %v", e.address)
func (e ErrCannotSendFromAddress) Error() string {
return fmt.Sprintf("cannot send from address: %v", e.address)
}

View File

@ -103,8 +103,8 @@ func (s *Service) smtpSendMail(ctx context.Context, authID string, from string,
}
if !fromAddr.Send || fromAddr.Status != proton.AddressStatusEnabled {
s.log.Errorf("Can't send emails on address: %v", fromAddr.Email)
return &ErrCanNotSendOnAddress{address: fromAddr.Email}
s.log.Errorf("Cannot send emails from address: %v", fromAddr.Email)
return &ErrCannotSendFromAddress{address: fromAddr.Email}
}
// Load the user's mail settings.

View File

@ -146,7 +146,7 @@ func mkdirAllClear(path string) error {
func checksum(path string) (hash string) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
logrus.WithError(err).WithField("path", path).Error("Cannot open file for checksum")
logrus.WithError(err).WithField("path", path).Warn("Cannot open file for checksum")
return
}
defer file.Close() //nolint:errcheck,gosec

View File

@ -22,9 +22,11 @@ import (
"errors"
"fmt"
"reflect"
"runtime"
"sync"
"time"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/sirupsen/logrus"
)
@ -216,11 +218,7 @@ func isUsable(helper credentials.Helper, err error) bool {
return false
}
creds := &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
creds := getTestCredentials()
if err := retry(func() error {
return helper.Add(creds)
@ -242,6 +240,23 @@ func isUsable(helper credentials.Helper, err error) bool {
return true
}
func getTestCredentials() *credentials.Credentials {
// On macOS, a handful of users experience failures of the test credentials.
if runtime.GOOS == "darwin" {
return &credentials.Credentials{
ServerURL: hostURL(constants.KeyChainName) + fmt.Sprintf("/check_%v", time.Now().UTC().UnixMicro()),
Username: "", // username is ignored on macOS, it's extracted from splitting the server URL
Secret: "check",
}
}
return &credentials.Credentials{
ServerURL: "bridge/check",
Username: "check",
Secret: "check",
}
}
func retry(condition func() error) error {
var maxRetry = 5
for r := 0; ; r++ {

View File

@ -19,6 +19,7 @@ package message
import (
"bytes"
"fmt"
"mime"
"net/mail"
"strings"
@ -46,6 +47,12 @@ var (
const InternalIDDomain = `protonmail.internalid`
func BuildRFC822Into(kr *crypto.KeyRing, decrypted *DecryptedMessage, opts JobOptions, buf *bytes.Buffer) error {
if opts.SanitizeMBOXHeaderLine {
if err := sanitizeMBOXHeaderLine(decrypted); err != nil {
return fmt.Errorf("failed to sanitize MBOX header: %w", err)
}
}
switch {
case len(decrypted.Msg.Attachments) > 0:
return buildMultipartRFC822(decrypted, opts, buf)
@ -560,3 +567,80 @@ func (bw *boundary) gen() string {
bw.val = algo.HashHexSHA256(bw.val)
return bw.val
}
func mboxFrom() []byte {
return []byte("From ")
}
func mboxGtFrom() []byte {
return []byte(">From ")
}
func sanitizeMBOXHeaderLine(decrypted *DecryptedMessage) error {
if decrypted == nil {
return nil
}
if decrypted.Body.Len() == 0 {
return nil
}
i := indexMBOXHeaderLine(decrypted)
for i >= 0 {
var buf bytes.Buffer
// copy until mbox line
if i > 0 {
if _, err := buf.Write(decrypted.Body.Next(i)); err != nil {
return fmt.Errorf("cannot copy first lines: %w", err)
}
}
// dump mbox line
eol := bytes.IndexRune(decrypted.Body.Bytes(), '\n')
if eol == 0 || eol == -1 {
return errors.New("cannot find end of mbox line")
}
_ = decrypted.Body.Next(eol + 1)
// copy rest
if _, err := buf.Write(decrypted.Body.Bytes()); err != nil {
return fmt.Errorf("cannot rest of message: %w", err)
}
decrypted.Body = buf
i = indexMBOXHeaderLine(decrypted)
}
return nil
}
func indexMBOXHeaderLine(decrypted *DecryptedMessage) int {
b := decrypted.Body.Bytes()
headerEnd := bytes.Index(b, []byte("\n\n"))
if headerEnd < 0 {
headerEnd = bytes.Index(b, []byte("\r\n\r\n"))
}
if headerEnd < 0 {
headerEnd = len(b)
}
for i := 0; i < headerEnd; i++ {
if i != 0 && b[i] != '\n' {
continue
}
j := 0
if i != 0 {
j = i + 1
}
if bytes.HasPrefix(b[j:], mboxFrom()) || bytes.HasPrefix(b[j:], mboxGtFrom()) {
return j
}
}
return -1
}

View File

@ -18,6 +18,7 @@
package message
import (
"bytes"
"net/mail"
"os"
"path/filepath"
@ -1298,3 +1299,96 @@ func TestBuildComplexMIMEType(t *testing.T) {
expectContentTypeParam(`name`, is(`Cat_August_2010-4.jpeg`)).
expectContentDispositionParam(`filename`, is(`Cat_August_2010-4.jpeg`))
}
func TestHasMBOXHeaderLine(t *testing.T) {
cases := map[string]struct {
index, indexCRLF int
}{
"From: ok\nTo: Ok": {-1, -1},
"From: ok\nTo: Ok\n\nFrom - 123": {-1, -1},
"From: ok\nTo: Ok\n\n>From - 123": {-1, -1},
">From: ok\nTo: Ok": {-1, -1},
">From: ok\nTo: Ok\n\nFrom - 123": {-1, -1},
">From: ok\nTo: Ok\n\n>From - 123": {-1, -1},
"From - 123\nFrom: ok\nTo: Ok": {0, 0},
"From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": {0, 0},
"From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": {0, 0},
"From: ok\nFrom - 123\nTo: Ok": {9, 10},
"From: ok\nFrom - 123\nTo: Ok\n\nFrom - 123": {9, 10},
"From: ok\nFrom - 123\nTo: Ok\n\n>From - 123": {9, 10},
">From - 123\nFrom: ok\nTo: Ok": {0, 0},
">From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": {0, 0},
">From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": {0, 0},
"From: ok\n>From - 123\nTo: Ok": {9, 10},
"From: ok\n>From - 123\nTo: Ok\n\nFrom - 123": {9, 10},
"From: ok\n>From - 123\nTo: Ok\n\n>From - 123": {9, 10},
}
test := func(t *testing.T, wantIndex int, given string, useCRLF bool) {
decrypted := &DecryptedMessage{}
if useCRLF {
decrypted.Body = *bytes.NewBufferString(strings.ReplaceAll(given, "\n", "\r\n"))
} else {
decrypted.Body = *bytes.NewBufferString(given)
}
require.Equal(t, wantIndex, indexMBOXHeaderLine(decrypted))
}
for given, want := range cases {
t.Run("LF-"+given, func(t *testing.T) { test(t, want.index, given, false) })
t.Run("CRLF-"+given, func(t *testing.T) { test(t, want.indexCRLF, given, true) })
}
}
func TestSanitizeMBOXHeaderLine(t *testing.T) {
cases := map[string]string{
"From: ok\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
">From: ok\nTo: Ok": ">From: ok\nTo: Ok",
">From: ok\nTo: Ok\n\nFrom - 123": ">From: ok\nTo: Ok\n\nFrom - 123",
">From: ok\nTo: Ok\n\n>From - 123": ">From: ok\nTo: Ok\n\n>From - 123",
"From - 123\nFrom: ok\nTo: Ok": "From: ok\nTo: Ok",
"From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
"From: ok\nFrom - 123\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\nFrom - 123\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\nFrom - 123\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
">From - 123\nFrom: ok\nTo: Ok": "From: ok\nTo: Ok",
">From - 123\nFrom: ok\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
">From - 123\nFrom: ok\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
"From: ok\n>From - 123\nTo: Ok": "From: ok\nTo: Ok",
"From: ok\n>From - 123\nTo: Ok\n\nFrom - 123": "From: ok\nTo: Ok\n\nFrom - 123",
"From: ok\n>From - 123\nTo: Ok\n\n>From - 123": "From: ok\nTo: Ok\n\n>From - 123",
}
test := func(t *testing.T, given, want string, useCRLF bool) {
decrypted := &DecryptedMessage{}
if useCRLF {
decrypted.Body = *bytes.NewBufferString(strings.ReplaceAll(given, "\n", "\r\n"))
want = strings.ReplaceAll(want, "\n", "\r\n")
} else {
decrypted.Body = *bytes.NewBufferString(given)
}
require.NoError(t, sanitizeMBOXHeaderLine(decrypted))
require.Equal(t, []byte(want), decrypted.Body.Bytes())
}
for given, want := range cases {
t.Run("LF"+given, func(t *testing.T) { test(t, given, want, false) })
t.Run("CRLF"+given, func(t *testing.T) { test(t, given, want, true) })
}
}

View File

@ -24,4 +24,5 @@ type JobOptions struct {
AddExternalID bool // Whether to include ExternalID as X-Pm-External-Id.
AddMessageDate bool // Whether to include message time as X-Pm-Date.
AddMessageIDReference bool // Whether to include the MessageID in References.
SanitizeMBOXHeaderLine bool // Whether to ignore header line representing MBOX delimiter
}

View File

@ -45,7 +45,7 @@ import (
"google.golang.org/grpc"
)
var defaultVersion = semver.MustParse("3.0.6")
var defaultVersion = semver.MustParse("3.10.0")
type testUser struct {
name string // the test user name

View File

@ -205,3 +205,30 @@ func (s *scenario) theBodyInTheResponseToIs(method, path string, value *godog.Do
return nil
}
func (s *scenario) theMessageUsedKeyForSending(address string) error {
addrID := s.t.getUserByAddress(address).getAddrID(address)
call, err := s.t.getLastCallExcludingHTTPOverride("POST", "/mail/v4/messages")
if err != nil {
return err
}
var body, want map[string]any
if err := json.Unmarshal(call.ResponseBody, &body); err != nil {
return err
}
want = map[string]any{
"Message": map[string]any{
"AddressID": addrID,
},
}
if !IsSub(body, want) {
return fmt.Errorf("have body %v, want %v", body, want)
}
return nil
}

View File

@ -0,0 +1,42 @@
Feature: IMAP client authentication with address modes
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: IMAP client can authenticate successfully with secondary address in combine mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
Then user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
Scenario: IMAP client can authenticate successfully with secondary address in split mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
Then user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client cannot authenticate successfully with disabled alias in combine mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
# GODT-3307 it should succeed
When user "[user:user]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client cannot authenticate successfully with disabled alias in split mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
# GODT-3307 it should succeed
When user "[user:user]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"

View File

@ -20,13 +20,6 @@ Feature: A user can authenticate an IMAP client
Scenario: IMAP client can authenticate successfully with secondary address
Given user "[user:user]" connects and authenticates IMAP client "1" with address "[alias:alias]@[domain]"
# Need to find way to setup disabled address on black
@skip-black
Scenario: IMAP client can not authenticate successfully with disable address
Given the account "[user:user2]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Then user "[user:user2]" connects and can not authenticate IMAP client "1" with address "[alias:disabled]@[domain]"
Scenario: IMAP client can authenticate successfully
When user "[user:user]" connects IMAP client "1"
Then IMAP client "1" can authenticate

View File

@ -57,6 +57,7 @@ Feature: IMAP create messages
And IMAP client "1" eventually sees the following messages in "All Mail":
| from | to | subject | body |
| [alias:alias]@[domain] | john.doe@email.com | foo | bar |
And bridge reports a message with "GODT-3185: import with non-default address in combined mode: using sender address"
Scenario: Imports an unrelated message to inbox
When IMAP client "1" appends the following messages to "INBOX":

View File

@ -164,6 +164,7 @@ Feature: IMAP Draft messages
And IMAP client "1" eventually sees the following messages in "Drafts":
| to | subject | body |
| someone@example.com | Draft without From | This is a Draft without From in header |
And bridge reports a message with "GODT-3185: draft with non-default invalid address in combined mode: error import/draft"
@regression
Scenario: Only one draft in Drafts and All Mail after editing it locally multiple times

View File

@ -0,0 +1,45 @@
Feature: SMTP client authentication with address modes
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: SMTP client can authenticate successfully with secondary address in combine mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
Then it succeeds
Scenario: SMTP client can authenticate successfully with secondary address in split mode
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
Then it succeeds
# Need to find way to setup disabled address on black
@skip-black
Scenario: SMTP client can authenticate successfully with disabled alias in combine mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:disabled]@[domain]"
Then it fails
# Need to find way to setup disabled address on black
@skip-black
Scenario: SMTP client can authenticate successfully with disabled alias in split mode
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:disabled]@[domain]"
Then it fails

View File

@ -20,5 +20,5 @@ Feature: SMTP wrong messages
Hello
"""
And it fails with error "Error: can't send on address: [user:disabled]@[domain]"
And it fails with error "Error: cannot send from address: [user:disabled]@[domain]"

View File

@ -331,4 +331,4 @@ Feature: SMTP sending of plain messages
}
]
}
"""
"""

View File

@ -0,0 +1,79 @@
Feature: Address key usage during SMTP send
Background:
Given there exists an account with username "[user:user]" and password "password"
And the account "[user:user]" has additional address "[alias:alias]@[domain]"
And it succeeds
Scenario: Non-active sender in combined mode using non-active key
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:alias]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:alias]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it succeeds
And the message used "[alias:alias]@[domain]" key for sending
Scenario: Non-active sender in split mode using non-active key
Given bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:alias]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:alias]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it succeeds
And the message used "[alias:alias]@[domain]" key for sending
# Need to find way to setup disabled address on black
@skip-black
Scenario: Disabled sender in combined mode fails to send
Given the account "[user:user]" has additional disabled address "[user:disabled]@[domain]"
And it succeeds
And bridge starts
And the user logs in with username "[user:user]" and password "password"
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[user:user]@[domain]"
And SMTP client "1" sends the following message from "[alias:disabled]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:disabled]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it fails
# Need to find way to setup disabled address on black
@skip-black
Scenario: Disabled sender in split mode fails to send
Given the account "[user:user]" has additional disabled address "[alias:disabled]@[domain]"
And it succeeds
And bridge starts
And the user logs in with username "[user:user]" and password "password"
And the user sets the address mode of user "[user:user]" to "split"
And user "[user:user]" finishes syncing
And it succeeds
When user "[user:user]" connects and authenticates SMTP client "1" with address "[alias:alias]@[domain]"
And SMTP client "1" sends the following message from "[alias:disabled]@[domain]" to "pm.bridge.qa@gmail.com":
"""
From: Bridge Test <[alias:disabled]@[domain]>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then it fails

View File

@ -38,6 +38,8 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^the network port range (\d+)-(\d+) is busy$`, s.networkPortRangeIsBusy)
ctx.Step(`^bridge IMAP port is (\d+)`, s.bridgeIMAPPortIs)
ctx.Step(`^bridge SMTP port is (\d+)`, s.bridgeSMTPPortIs)
ctx.Step(`^the message used "([^"]*)" key for sending$`, s.theMessageUsedKeyForSending)
// ==== SETUP ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
ctx.Step(`^there exists a disabled account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPasswordWithDisablePrimary)

View File

@ -28,7 +28,6 @@ import (
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/bradenaw/juniper/iterator"
@ -330,25 +329,14 @@ func (s *scenario) drafAtIndexWasMovedToTrashForAddressOfAccount(draftIndex int,
}
func (s *scenario) userLogsInWithUsernameAndPassword(username, password string) error {
smtpEvtCh, cancelSMTP := s.t.bridge.GetEvents(events.SMTPServerReady{})
defer cancelSMTP()
imapEvtCh, cancelIMAP := s.t.bridge.GetEvents(events.IMAPServerReady{})
defer cancelIMAP()
userID, err := s.t.bridge.LoginFull(context.Background(), username, []byte(password), nil, nil)
if err != nil {
s.t.pushError(err)
} else {
// We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid
// blocking on multiple Logins.
if !s.t.imapServerStarted {
<-imapEvtCh
s.t.imapServerStarted = true
}
if !s.t.smtpServerStarted {
<-smtpEvtCh
s.t.smtpServerStarted = true
}
s.t.imapServerStarted = true
s.t.smtpServerStarted = true
if userID != s.t.getUserByName(username).getUserID() {
return errors.New("user ID mismatch")
@ -366,25 +354,14 @@ func (s *scenario) userLogsInWithUsernameAndPassword(username, password string)
}
func (s *scenario) userLogsInWithAliasAddressAndPassword(alias, password string) error {
smtpEvtCh, cancelSMTP := s.t.bridge.GetEvents(events.SMTPServerReady{})
defer cancelSMTP()
imapEvtCh, cancelIMAP := s.t.bridge.GetEvents(events.IMAPServerReady{})
defer cancelIMAP()
userID, err := s.t.bridge.LoginFull(context.Background(), s.t.getUserByAddress(alias).getName(), []byte(password), nil, nil)
if err != nil {
s.t.pushError(err)
} else {
// We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid
// blocking on multiple Logins.
if !s.t.imapServerStarted {
<-imapEvtCh
s.t.imapServerStarted = true
}
if !s.t.smtpServerStarted {
<-smtpEvtCh
s.t.smtpServerStarted = true
}
s.t.imapServerStarted = true
s.t.smtpServerStarted = true
if userID != s.t.getUserByAddress(alias).getUserID() {
return errors.New("user ID mismatch")

View File

@ -0,0 +1,20 @@
@@ -0,0 +1,10 @@
# Vault Editor
Bridge uses an encrypted vault to store persistent data. One of the parameters stored in this vault is the roll factor (between 0.0 and 1.0)
It can be built with `make vault-editor` in the bridge source code root directory.
Example usage:
Setting the rollout value:
```bash
$ ./bridge-rollout set -v=0.81
0.81
```
Note that the provided value will be clamped between 0 and 1.
```bash
$ ./bridge-rollout get
0.81
```

View File

@ -0,0 +1,85 @@
// 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 <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"github.com/ProtonMail/gluon/async"
"github.com/ProtonMail/proton-bridge/v3/internal/app"
"github.com/ProtonMail/proton-bridge/v3/internal/locations"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/keychain"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func main() {
logrus.SetLevel(logrus.ErrorLevel)
app := cli.NewApp()
app.Commands = []*cli.Command{
{
Name: "get",
Action: getRollout,
Usage: "get the bridge rollout value",
},
{
Name: "set",
Action: setRollout,
Flags: []cli.Flag{
&cli.Float64Flag{
Name: "value",
Usage: "the rollout value",
Required: true,
Aliases: []string{"v"},
},
},
Usage: "set the bridge rollout value",
},
}
if err := app.Run(os.Args); err != nil {
logrus.Fatal(err)
}
}
func getRollout(_ *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
fmt.Println(vault.GetUpdateRollout())
return nil
})
})
})
}
func setRollout(c *cli.Context) error {
return app.WithLocations(func(locations *locations.Locations) error {
return app.WithKeychainList(async.NoopPanicHandler{}, func(keychains *keychain.List) error {
return app.WithVault(locations, keychains, async.NoopPanicHandler{}, func(vault *vault.Vault, insecure, corrupt bool) error {
clamped := max(0.0, min(1.0, c.Float64("value")))
if err := vault.SetUpdateRollout(clamped); err != nil {
return err
}
return getRollout(c)
})
})
})
}

View File

@ -28,6 +28,12 @@ main(){
jq -r '.finding | select( (.osv != null) and (.trace[0].function != null) ) | .osv ' < vulns.json > vulns_osv_ids.txt
ignore GO-2023-2328 "GODT-3124 RESTY race condition"
ignore GO-2024-2598 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2599 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2600 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2609 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2610 "BRIDGE-16 Update Go to 1.21.9"
ignore GO-2024-2687 "BRIDGE-16 Update Go to 1.21.9"
has_vulns