Compare commits

...
128 Commits
Author SHA1 Message Date
MrMirDan 051f4ca6ef removed external add_executable 2026-10-16 11:08:49 +03:00
MrMirDan 69d87e70ac some changes for apiCountryListModel test 2026-10-16 10:46:27 +03:00
MrMirDan b7ea6c498d added apiCountryListModel test 2026-10-15 16:25:51 +03:00
MrMirDan 8ad00d533e changed api news and devices tests 2026-10-15 16:18:13 +03:00
MrMirDan b4c5c381ba trying to fix error C1075 2026-10-15 14:47:23 +03:00
MrMirDan 3c25dfce55 some changes for build 2026-10-15 14:04:01 +03:00
MrMirDan d8fa1f5161 changed api tests after rebase 2026-10-15 12:57:31 +03:00
MrMirDan 6960287ef5 fixed syntax error 2026-10-15 11:28:55 +03:00
MrMirDan 1386336f10 changed some expected values for services model and controller test 2026-10-15 11:28:55 +03:00
MrMirDan c482fa51a7 removed testApiDevicesModel's testRolesNames 2026-10-15 11:28:55 +03:00
MrMirDan f49fa1ac60 added apiDevicesModel test 2026-10-15 11:28:54 +03:00
MrMirDan 34101a91d9 added apiCountryModel test 2026-10-15 11:28:42 +03:00
MrMirDan 972b4e1274 added apiBenefitsModel test 2026-10-15 11:28:28 +03:00
MrMirDan df47fdcae0 changed some expected values 2026-10-15 11:28:13 +03:00
MrMirDan bc5ae6e410 fixed typo in naming 2026-10-15 11:27:12 +03:00
MrMirDan 0d38b22162 added apiAccountInfoModel test 2026-10-15 11:24:31 +03:00
MrMirDan 4773fb0821 services test little changes 2026-10-15 11:24:17 +03:00
MrMirDan a91e1a20f0 subscription controller test changes 2026-10-15 11:24:17 +03:00
MrMirDan 24b74fcb3e controller fix 2026-10-15 11:24:17 +03:00
MrMirDan ed3deef2dd added subscriptionUiController test 2026-10-15 11:24:15 +03:00
MrMirDan 185c85e183 some service test changes 2026-10-15 11:23:54 +03:00
MrMirDan bb7cf69cfc removed unused test 2026-10-15 11:23:54 +03:00
MrMirDan 5ba704ffcf returned some dependencies 2026-10-15 11:23:54 +03:00
MrMirDan 1b47d79f26 some fixes for news test 2026-10-15 11:23:54 +03:00
MrMirDan a5cb5679ae temporary checks 2026-10-15 11:23:54 +03:00
MrMirDan 6aefc6be47 various changes 2026-10-15 11:23:03 +03:00
MrMirDan 378b30f553 fix: removed last arg for makeServiceItem 2026-10-15 11:23:03 +03:00
MrMirDan 812ee905f4 fix: proper object name in services tests 2026-10-15 11:23:03 +03:00
MrMirDan b16d603435 changed prem_key to amnezia_test_api_key 2026-10-15 11:23:03 +03:00
MrMirDan c107c01cab rewrited news test to use gateway and added services test to cmake and workflow 2026-10-15 11:23:01 +03:00
MrMirDan 80ff2e0676 added ui services model and controller api test 2026-10-15 11:22:04 +03:00
MrMirDan 4b6f09d55f fix: typos 2026-10-15 11:22:04 +03:00
MrMirDan c6958d8b70 added news ui test 2026-10-15 11:21:36 +03:00
vkamn a36399a4c3 chore: minor ui fixes (#3289) 2026-10-15 12:58:24 +08:00
vkamn 8940f8e273 chore: minor ui fixes (#3288) 2026-10-15 12:13:31 +08:00
NickVs2015 705a373d51 feat: cover test gateway service config import (#3239)
* feature: cover gateway service config import

* fix: use AmneziaApplication in gateway service import test

* feature: cover gateway service update

* fix: drain gateway workers before test teardown
2026-10-14 21:52:29 +08:00
vkamn 3d4a45246a fix: fix missing includes (#3281) 2026-10-14 11:28:24 +08:00
vkamn 11355e1de0 feat: new design system (#3280)
* refactor: focus controller refactoring

* feat: update to new design system

* feat: update to new desing system

* chore: bump version
2026-10-14 11:13:36 +08:00
yp 3e8077f2ca feat: split app/tunnel logs viewer (#3262)
* feat: split app/tunnel log streams and add in-app log viewer

* fix: report real save result on android

* remove dead logging code & open qt log on start, stop ne logging on auto-off, pin log date locale

* drop dead share api and simplify logs ui components

* add ru strings for logs redesign
2026-10-13 22:10:30 +08:00
yp c52b1a50a8 fix: hint xray settings file on main import (#3276) 2026-10-13 21:26:55 +08:00
yp 2c0f169946 fix: location separation texts (#3275) 2026-10-13 21:26:20 +08:00
vkamn 693d00ded5 feat: use libagw (#3028)
* feat: use libagw

* feat: add conanfile for libagw

* chore: fix android build with agw

* refactor: gateway error code parsing

* fix: build fixes

* fix: return proxy cache

* chore: update link to libagw

* fix: libagw 1.0.6, log request trace, persist state synchronously

* chore: fetch libagw as a release archive with sha256 instead of git clone
2026-10-13 21:25:58 +08:00
Yakimov GrigoryandGrigory Yakimov f1af8efb4b feat: add Thai (th_TH) localization (#3253)
* Add Thai language

* Update amneziavpn_th_TH.ts

* Add small test for check languages

---------

Co-authored-by: Grigory Yakimov <GYakimov@alfabank.ru>
2026-10-12 11:50:24 +08:00
NickVs2015 4c2e950864 fix: restore backup from content:// uri on android (#3218) 2026-10-08 23:59:22 +08:00
yp 9017984b1d fix: rework xray settings - split client/server data, unify saving, fix tls (#3006)
* Feat: split xray client and server settings

* remove test logs

* in-place save and TLS pin

* fixed xhttp/mkcp + tls

* remove check version xray in start connect

* address review findings for the settings split

* reset namespace

* reset namespace

* remove template xray

* fix: stop xray defaults from overwriting entered settings

* revert dropdown selection refactor from the defaults fix

* move save 'updateServerConfig'

* remove comment
2026-10-08 23:58:25 +08:00
yp c5ecf6281f feat: location separation (#3181)
* Feat: location separation

* remove temp code

* remove tab filter

* fixed models

* remove settings button in native configs

* reset & fixed crash focus

* derive country list use-case set from listId

* fixed: search field: Esc on empty field -> passed on, page closes

* country list: three levels, split by full catalog

* favorites: keep unstarred row until list refresh

* favorites on configuration files screen

* split country list by visible locations

* hysteresis for region split: split above 15, merge below 13

* section counters ignore search, follow chip

* empty state: suggest switching keyboard layout

* section header: counter in parentheses on collapsed sections only

* country list: scroll-linked large title header

* current section expanded on entry without overwriting saved collapse

* country list: raise scrollbar above pinned overlays, drawer above it

* qml: collapsing header component, config files header, country list parts extracted

* country list: ignore unknown use case ids from catalog

* country list: use case names from catalog (nameLocalized), ui language in model

* country list: collapsed sections kept for the session only, as in the task

* country list: design review fixes

* favorites limit: toast from the mockup with 'show' action

* toast: geometry, typography, shadow and action button from figma
2026-10-08 23:31:05 +08:00
NickVs2015 3e9b70a5f1 feature: bump go toolchain to 1.27.1 (#3238)
* chore: bump go toolchain to 1.27.1

* chore: move go version back into recipe
2026-10-05 09:20:53 +02:00
vkamn 94b51df247 chore: bump version (#3187) 2026-09-18 20:44:38 +08:00
Mezooz de93650a90 fix: make Conan recipes parse on Python < 3.12 (#2870)
Three recipes use f-string syntax that is only valid on Python 3.12+
(PEP 701): reusing the same quote character inside an f-string
expression, and using a backslash inside one.

  recipes/amnezia-xray-bindings/conanfile.py:115
  recipes/tun2socks/conanfile.py:118
  recipes/hev-socks5-tunnel/conanfile.py:63-65

On older interpreters Conan fails to load the recipe with a SyntaxError
before resolving a single dependency, so the build cannot start:

  SyntaxError: unexpected character after line continuation character

This is not platform specific. It is invisible in CI only because
ubuntu-latest ships Python 3.12+. Debian 12 (bookworm), current stable
and the base for Raspberry Pi OS, ships Python 3.11.

Hoist the expressions out of the f-strings, and use single quotes for
the nested string literals. Behaviour is unchanged on every version.

Verified with python3 -m py_compile on Python 3.11.2 (aarch64,
Debian 12): all three fail before this change and pass after.
2026-09-18 13:59:14 +08:00
vkamn 448baad3e1 chore: bump version (#3185)
* chore: bump version

* feat: update translations
2026-09-18 13:52:33 +08:00
vkamn aca1b7dc5c feat: add format version for future config refactoring (#3184) 2026-09-18 13:31:07 +08:00
vkamn de36a81b58 chore: change android aqt host (#3182) 2026-09-17 13:43:35 +08:00
vkamn 76b81b43c1 chore: pin install-qt-action to v4.3.1 (#3180) 2026-09-16 22:24:34 +08:00
MrMirDan bea2f5f7db feat: clickable notification (#3178) 2026-09-16 22:22:51 +08:00
Yaroslav Gurov a4d38b5642 fix: windows certificate signing issues (#3175)
* fix: windows certificate signing issues

* fix: add has_ui option and phys/cloud opts
2026-09-16 22:22:19 +08:00
NickVs2015andMrMirDan b76c88bba3 feat: migrate test to main repo (#3172)
* basic tests  adaptation

* fix: ui servers model and controller test awg config

* fix: platforms test builds

* build dirs inspection

* fix: changed paths for tests and tests artifacts

* fix: 'deploy' parent dir

* windows ctest configuration and macos dependencies inspection

* windows check executable and macos copy openssl libs

* windows test executables check and macos copy openssl

* changed some path on windows

* android apk list

* fix: updated android apk path

* Verify Android test files

* fix: build tests only with AMNEZIA_BUILD_TESTS

* fix: copy openssl runtime next to test_openssl_library

* fix: awg2 test config and inverted description checks

* chore: move run_android_tests.sh to client/tests and drop personal paths

* chore: gate tests by option, scope secrets and drop global concurrency in ci

* chore: serialized vps test job, scoped secrets, backup secret guard, test data lookup

---------

Co-authored-by: MrMirDan <8mirdan8@gmail.com>
2026-09-16 17:05:43 +08:00
vkamn eab14fd957 chore: add define for app update (#3164) 2026-09-14 12:37:58 +08:00
SPTApyo 2a8242d306 feat: add French (fr_FR) localization (#3135)
* feat: add French (fr_FR) localization

* fix: align french catalogue with current telegram link
2026-09-09 21:44:51 +08:00
vkamn e60842bd9d chore: cleanup entitlements (#3149)
* chore: cleanup entitlements for macos ne

* chore: bump version
2026-09-09 21:12:03 +08:00
vkamn 327e5985df chore: bump version (#3120) 2026-09-03 21:02:31 +08:00
MrMirDan 124da5a499 feat: amnezia.host website button (#3102)
* added amnezia.host website button

* added desktop platform check

* check for IsMacOsNeBuild
2026-09-03 19:10:13 +08:00
NickVs2015 2ee02bc893 fix: log purchaseToken on successful Play purchase (#3108) 2026-09-03 14:02:49 +08:00
MrMirDan 398697c101 chore: added utm for amnezia.org website link in app (#3101) 2026-09-02 21:17:37 +08:00
MrMirDan c8810dde5f chore: changed tg.me links to telegram.me (#3111) 2026-09-02 20:02:35 +08:00
Yaroslav Gurov 9a7db3475f fix: adjust awg selfhosted defaults (#3115) 2026-09-02 20:02:02 +08:00
vkamn 628dd5b6d3 chore: bump version (#3103) 2026-08-31 22:52:55 +08:00
vkamn 71b99c799c feat: new update page (#3092) 2026-08-31 20:54:30 +08:00
NickVs2015 0643a2424b fix: fallback to default file picker instead documentsui (#3087) 2026-08-31 20:51:37 +08:00
yp dedfc08cc2 feat: add TProxy (#3034)
* server-side container stack

* core model, enums and install pipeline

* settings UI and setup wizard

* fix workers

* remove debug logs

* address review findings

* fix validate email

* update docker

* remove write port & add default port 443/80
2026-08-31 19:29:48 +08:00
yp dddc181299 feat: server country code l10n (#3069) 2026-08-28 23:10:41 +08:00
vkamn 5689706fd9 fix: various fixes (#3077)
* fix: post install cleanup

* fix: wg show output parsing

* chore: update translations

* fix: finish already expired transactions
2026-08-28 23:09:59 +08:00
5501fbd54e feat: google play iap (#2682)
* Add Google Play Billing Library

* Disable Qt debugger sleep

* Add billing module, provide a separate build for Google Play

* Add a method to detect Google Play build

* Add method to get the list of offers

* Add methods to verify and purchase subscriptions

* refactor: modify response json

* feat: add purchase to UI

* feat: add test purchase android

* fix: merge with new structure, fix payment flow

* fix: build flow for new conan structure

* fix: add build android via Win

* fix: fix multi-ABI filter

* fix: split payment to detect payment type

* fix: add artifact apk

* fix: add check purchase on restore

* fix: release keystore

* fix: copy apk to right path

* fix: separate oss/play build

* fix: simply build AAB

* fix: add custom target for Play billing

* fix: add logs billing

* fix: add support market types, add logs

* fix: revert market field, up android GP version, fix subscription plan parser

* fix: add support getSubscriptionInfo and subscriptions/restore

* fix: update api endpoint and pay logic

* fix: add logs and rename restore func

* fix: add ios logs

* fix: rename restore function, fix icon resource error

* fix: double purchase android

* fix: update Google Play Billing Library to 8.0.0

* fix: prefer free trial subscription offer on Android purchase

Bump APP version to 2137.

* fix: up kotlin version compiler

* fix: sync price for PlayMarket from GP

* fix: add logs for Google Play price merge

* fix: show App Store intro offer discount price in catalog

* feat: add trial support for multi platform, redesign PageSetupWizardApiPremiumInfo

* feat: add logs android

* fix: tighten spacing Change plan and test period tag

* fix: UI text changes

* fix: upgrade existing android subscription and add renew logic

* feat: add support upgrade CP plan without call subscription

* fix: add billing logs for plan

* fix: show upgrade success as notification

* fix: call getSubscriptionInfo/importServiceFromMarket on upgrade instead of skipping

* fix: restore ios prod var

* fix: remove noisy debug logs

* chore: up version code

* fix: getSubscriptionInfo used removed GatewayRequestData after rebase

* fix: aab in sep tasks

* fix: warn before subscription upgrade/downgrade purchase

* chore: add acknowledgePlayPurchase

* refactor: add store purchase controller

* feat: use same acknowledge method as appstore

* fix: IOS add upgrade

* fix: change QML warn message

* refactor: dedupe Play purchase validation and startup billing query

* chore: log purchaseToken through the Android purchase flow

* fix: resolve Android active plan by base plan id

* fix: remove comment

* fix: subscribe to Transaction.updates before Transaction.unfinished

* fix: update days values

* fix: genuine restore failure when another transaction was a duplicate

* fix: remove debug lines and comment

* chore: cleanup logs

---------

Co-authored-by: albexk <albexk@proton.me>
Co-authored-by: vkamn <vk@amnezia.org>
2026-08-28 11:24:38 +08:00
NickVs2015 fc4bf3eb2b chore: bump android version code to 2157 (#3074) 2026-08-28 11:23:05 +08:00
Дневник UNIX'оида c8722c619a chore: added StartupWMClass line to the Desktop Entry (#2968) 2026-08-27 23:18:31 +08:00
HugoFiermein 4c5bb29ab0 fix: fixed wgshow parsing (#3046) 2026-08-27 23:05:51 +08:00
Yaroslav Gurov 4976828914 fix: do not force warning loglevel on Android (#3070) 2026-08-27 16:41:46 +02:00
Zak 5d74b2cbcf fix: preserve app signature and scope launchd socket (#3062)
* fix(macos): preserve signed service plist resource

* fix(macos): bind service socket to loopback
2026-08-27 22:33:28 +08:00
Yaroslav Gurov 7072661244 chore: update xray (#3012)
* chore: update xray

* chore: update xray on mobiles
2026-08-24 16:19:52 +02:00
vkamn 7d4f3e0f50 fix: various fixes (#3015)
* fix: fixed ios brandings

* chore: bump version

* chore: update translations

* fix: fixed ios awg3.1 parser

* chore: bump version

* fix: remove awg 3.1 params from native config if disbaled

* chore: bump version

* chore: remove comment
2026-08-21 22:43:01 +08:00
NickVs2015andvkamn b1a37b3779 feat: add brandings (#2839)
* feat: move amnezia hardcode from sources to cmake

* feat: move amnezia hardcode from sources to cmake

* refactor: remove prem v1 migration

* chore: move more branding

* fix: use host-matching architecture for iOS simulator builds

x86_64 was hardcoded; Apple Silicon Macs run arm64 simulators and Xcode 26
has no Rosetta simulator destinations, so simulators showed as unsupported.

* fix: mark the branded media assets catalog as a bundle resource

MACOSX_PACKAGE_LOCATION was set on the upstream Media.xcassets path while
target_sources uses CLIENT_IOS_MEDIA_ASSETS_PATH, so a branded catalog was
never compiled by actool and the app got a blank icon.

* fix: parametrize the Swift app-group identifier used by Log.swift

Log.swift hardcoded Amnezia's own app-group instead of the branded one,
since Swift -D flags can't carry the string value like the ObjC++ side
does. Generate a small Swift constants file instead, same pattern as
version.h.in, wired into all four targets that compile Log.swift.

* fix: use APPLICATION_NAME instead of hardcoded Amnezia in log line format

Every log line printed "Amnezia" regardless of branding; the log file name
two lines above this already used APPLICATION_NAME correctly.

* fix: restore branding fixes dropped by rebase, rename swiftInterop to swiftBridge

* fix: move more branding variables to cmake

* feat: move macOS branding from sources to cmake

- macos/app/Info.plist.in: app group, camera usage text, UTI ids and
  descriptions now come from CLIENT_APPLICATION_NAME / BUILD_* variables,
  mirroring ios/app/Info.plist.in
- macos/networkextension/Info.plist.in: bundle names and app group ids are
  templated; the plist is configured by CMake into the binary dir because
  Xcode only expands its own $(...) settings
- new knobs: CLIENT_MACOS_MEDIA_ASSETS_PATH, CLIENT_MACOS_APP_ICNS_PATH,
  CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER(_DEBUG),
  CLIENT_MACOS_NE_PROVISIONING_PROFILE_SPECIFIER(_DEBUG)

* fix: resolve macOS pf template paths relative to the client source dir

CMAKE_SOURCE_DIR points at the wrapper project when the client is built as a
subproject, so the pf template lookup failed there.

* chore: separate brandings defaults

---------

Co-authored-by: vkamn <vk@amnezia.org>
2026-08-18 20:41:45 +08:00
vkamn 86ae77e3d0 fix: treat the “off” toggle as a “missing” parameter (#3003) 2026-08-18 17:42:38 +08:00
vkamn 541a6e07d6 fix: return qml translations (#3002) 2026-08-18 16:48:56 +08:00
f135400d3b chore: update translations (#2999)
* chore: update translations

* feat: add Korean translation (#2971)

* update(i18n): add Korean translation

* fix: add full QML catalog for Korean

---------

Co-authored-by: ParkSnoopy <ParkSnoopy@users.noreply.github.com>

* feat: add Spanish translation (#2035)

* feat: add spanish translation

* feat: add spanish translations to client

* fix: fix grammar

---------

Co-authored-by: vkamn <vk@amnezia.org>

* chore: update es and kr translations

---------

Co-authored-by: ParkSnoopy <117149837+ParkSnoopy@users.noreply.github.com>
Co-authored-by: ParkSnoopy <ParkSnoopy@users.noreply.github.com>
Co-authored-by: Stageddat <121111148+Stageddat@users.noreply.github.com>
2026-08-18 15:17:29 +08:00
Kushida da184c0ddf fix: config import parsing and VPN route edge cases (#2820)
* fix: harden config import parsing

* fix: handle vpn route edge cases
2026-08-17 18:22:01 +08:00
vkamn 4116c5c4bc refactor: now iap and test purchase flags ships from backend (#2995) 2026-08-17 13:46:47 +08:00
yp 667d98f7d7 fix: remove dead code & save config (#2975) 2026-08-14 13:56:44 +08:00
vkamn 44c10b39e3 feat: awg3.1 support (#2984)
* feat: add awg3.1 support

* fix: add warning for big native awg qr codes

* fix: show default subnet address if not exist in server config

* fix: use range keep alive only with awg3 params

* chore: update awg go versions in conan

* chore: update awg-go version
2026-08-14 13:55:56 +08:00
vkamn e4b7e09f86 feat: add distribution filed to api calls (#2983)
* feat: add distribution filed to api calls

* chore: remove debug log
2026-08-14 13:55:40 +08:00
NickVs2015 04c2b2af33 chore: bump android version code to 2148 (#2977) 2026-08-14 13:04:10 +08:00
NickVs2015 e643fa008c fix: ipc input validation (#2852)
* fix: resolve critical IPC security vulnerabilities

- Validate IP/CIDR values from IPC before passing to Linux firewall
- Replace shell interpolation with direct execve in firewall update functions
- Block dangerous OpenVPN/WireGuard arguments in sanitizeArguments()
- Add programId bounds check in IpcServerProcess::setProgram()
- Add SO_PEERCRED peer authentication for IPC connections on Linux

* fix: extend IPC security validation to macOS firewall

* fix: suppress linux firewall errors and add missing 400.allowPIA anchor

* fix: review changes

* fix: review changes next

* fix: remove UID check from IPC server

* fix: IPC security review fixes

* fix: address IPC review fixes
2026-08-13 14:24:54 +08:00
vkamn b575df05c7 fix: fixed adding subnet to ip split tunnel (#2973) 2026-08-11 00:19:42 +08:00
vkamn 85bd102efa fix: return vcredist to windows bundle (#2949) 2026-08-11 00:18:21 +08:00
Маэстро автоматизации 717323f138 fix: restore ios condition for paste menu item to avoid clipboard permission prompt (#2955) 2026-08-10 18:46:55 +08:00
vkamn 8ed7a5a1d3 fix: android icons (#2953) 2026-08-10 18:44:49 +08:00
NickVs2015 6ece4e0ac5 chore: bump android version code to 2144 (#2961) 2026-08-10 12:10:52 +08:00
vkamn 9d45f3d7ad feat: add long country code support (#2969) 2026-08-10 12:10:16 +08:00
MrMirDan dcf53b989e chore: update ru translations (#2928)
* update translation RU

* fix: some text fixes
2026-08-07 00:14:27 +08:00
vkamn e27f74e4fc chore: add warning on home page (#2948) 2026-08-06 22:41:20 +08:00
vkamn dbe1a06e35 feat: pass uuid, appversion, osversion, app language to all gateway requests (#2944) 2026-08-06 22:08:34 +08:00
NickVs2015andvkamn d251d5f0ac chore: add Android armv7/arm64-v8a build on Qt 6.10.1 (#2892)
* chore: add Android armv7/arm64-v8a build on Qt 6.10.1

* chore: split Qt 6.10.1/6.10.3 Android AAB by min/max SDK range

* chore: bump version to 2141

* chore: drop upper SDK bound on Android 11+ AAB

* fix: stage android package source snapshot after libxray.aar is copied in

* chore: align Build-Android APK/AAB versionCode and naming to android11+

* chore: add x86/x86_64 APK builds to Qt 6.10.1 Android job

* chore: simplify android deploy workflow

* chore: add Qt 6.10.3 android9-10 APK build as fallback

---------

Co-authored-by: vkamn <vk@amnezia.org>
2026-08-06 20:34:00 +08:00
yp d702dc0b2a fix: various fixes for Telemt, XRay (VLESS), Captcha (#2903)
* fix worker & fix captha

* fixed read config (xxd) snapshot

* remove comment

* fix snapshot

* fixed xray save

* move subtitleText -> hint (Xray)

* xray config apply via reinstall

* apply server config via container reinstall

* transport compatibility and xray-core v26 config

* seed reality defaults on fresh deploy

* server scripts for xray-core v26 (keys, udp, image)

* placeholder defaults and hint tooltip control

* wire placeholders and hints into settings pages

* gate flow and security by transport in UI

* encode raw tg link in QR

* remove comment

* restore telegram-proxy transport mode and domain on server re-scan

* remove drop SessionKey, add edit

* fixed xray/mtproxy/telemt
2026-08-06 13:44:00 +08:00
vkamn 2ad6721061 feat: awg3 support selfhosted (#2908)
* chore: bump version

* feat: add awg3 support to selfhosted

* chore: remove HeaderProtectionKey from ui

* fix: fixed regexp in configure_container.sh for awg

* chore: return server protocol version to config

* fix: fixed native wg obfuscation

* fix: fixed range validator for awg text field

* feat: add warning for awg 1/2

* fix: remove warning from other protocols
2026-08-06 10:54:26 +08:00
vkamnandAlexei 0d116bfc67 fix: use native NSStatusItem for the macOS tray menu (#2759) (#2941)
* fix: use native NSStatusItem for the macOS tray menu (#2759)

On macOS 14+ the status-item menu is tracked out of process, so Qt's
QCocoaSystemTrayIcon menu-tracking observer (installed by setContextMenu)
fires asynchronously and calls emitActivated(), which reads
-[NSEvent clickCount] when NSApp.currentEvent is no longer a mouse event.
clickCount then raises NSInternalInconsistencyException, crashing the app
the first time the tray menu is opened.

Stop using QSystemTrayIcon on macOS and own the NSStatusItem ourselves
(MacOSStatusIcon), attaching the menu's backing native NSMenu via
QMenu::toNSMenu(). AppKit shows the menu natively - with menu-bar highlight
and correct anchoring - without registering the crashing observer, and Qt
keeps the NSMenu in sync with the QActions so the existing menu logic
(enable/disable, translations) is reused. The icon stays colored per state
and notifications go through UNUserNotificationCenter. Windows and Linux
keep QSystemTrayIcon unchanged.

This also wires up the previously-unused MacOSStatusIcon and removes the
orphaned neNotificationHandler.h.

* chore: return error messages

---------

Co-authored-by: Alexei <alexei.selivanov@gmail.com>
2026-08-06 00:13:05 +08:00
romanio427andromanio427 25a18bdf97 fix: avoid reentrant DNS flush while resolving site routes (#2930)
Rebase onto dev after #2811 (multi-IP site routes). Keep multi-IP
resolution and addVpnSite(QStringList), but stop calling
waitForFinished() from QHostInfo callbacks: that nested event loop
re-entered the same callback path until stack overflow (0xc00000fd).

Track outstanding lookups and flush DNS once asynchronously via
QRemoteObjectPendingCallWatcher when any new routes were installed.
Capture gw/ip by value for the async routeAddList call.

Co-authored-by: romanio427 <romanio427@users.noreply.github.com>
2026-08-06 00:02:29 +08:00
aiamnezia f73697d3f4 fix: enhance split tunneling IP handling (#2811)
- Refactored `VpnConnection` and `IpSplitTunnelingController` to support multiple IPs per site, improving the handling of split tunneling configurations.
- Adjusted methods to utilize `QStringList` for IP addresses, ensuring better management of site IPs.
- Enhanced the `SecureAppSettingsRepository` to normalize and manage IP lists effectively.
- Updated UI models to reflect changes in data structure for sites with multiple IPs.
2026-08-05 21:20:36 +08:00
cd-amn 53121f20a3 fix: OpenVPN binary crashes on connection (#2861)
* fix: set proper rpath's for openvpn on linux

* feat: run compilation on linux in max number of threads using deploy script

* feat: replace runpath for prebuilt openvpn

* fix: avoid double-installing of Qt libraries on linux

* fix: set proper rpath's for openvpn on macos

* fix: no DNS routes on Windows in blacklist split-tunnelling with OpenVPN
2026-08-05 21:18:53 +08:00
NickVs2015 d6cb9b930f chore: bump android version to 2141 (#2922) 2026-08-05 12:30:53 +08:00
vkamn c4b3ccacc9 refactor: ios qt6.9 support (#2916)
* chore: remove configs from ios logs

* fix: fix scrolling on user list

* fix: fixed container installation after adding a new server

* refactor: qt6.9.3 support for ios
2026-08-05 12:30:02 +08:00
yp e38a233904 fix: fix link RemoteObjects for linux (#2884) 2026-07-31 14:27:50 +08:00
Yaroslav Gurov 9b0495f5e1 fix: proper linking of openssl and other libraries on linux (#2910) 2026-07-31 10:18:20 +08:00
NickVs2015andYaroslav Gurov 30ec46a567 fix: openvpn runtime futex error (#2894)
* fix: pin go/1.23.12 for openvpn-pt-android to avoid android/arm crash

* chore: add fixme annotation

---------

Co-authored-by: Yaroslav Gurov <ygurov@proton.me>
2026-07-29 21:21:15 +08:00
NickVs2015 4055e1b05e fix: amnezia-libxray apply patch to all release (#2885)
* fix: amnezia-libxray apply patch to all releases

* chore: bump version to 2138
2026-07-29 21:20:42 +08:00
vkamn 4d28f81965 fix: set timeout for MarketplaceUpdateController 2026-07-26 15:20:53 +08:00
vkamn 5c035bb6cf chore: bump version 2026-07-25 23:38:25 +08:00
NickVs2015 26c22327af fix: netlink message lookup and buffer size 2026-07-25 19:31:37 +08:00
vkamn 5e9def4184 feat: add awg3 support 2026-07-25 19:29:54 +08:00
yp a98c60d967 feat: add update dialog on mobile platforms (#2867)
* add force update

* add native UI & remove func force update
2026-07-25 19:29:24 +08:00
vkamn 0faf3e6d2f fix: pass keepalive from config to uapi 2026-07-25 00:34:38 +08:00
NickVs2015 a32a26bb67 fix: add 16kb page size support to amnezia-libxray android build (#2865) 2026-07-23 20:02:52 +08:00
vkamn 431ece7fff chore: update links in readme (#2860) 2026-07-23 19:24:15 +08:00
vkamn 5e82c6a558 fix: set macos keychain path (#2858) 2026-07-22 20:46:52 +08:00
vkamn 84a2d13b68 fix: various fixes (#2856)
* fix: fixed endless preparing

* fix: remove update drawer when open news
2026-07-22 00:25:37 +08:00
vkamn 324b09cca3 fix: various fixes (#2850)
* fix: return macos ne icon

* fix: return default sni to googletagmanager

* fix: context menu did not appear on ios
2026-07-21 21:37:13 +08:00
vkamn e70563a071 chore: update links in readme (#2855) 2026-07-21 19:12:38 +08:00
Yaroslav Gurov bc2a0ca649 fix: copy PF files to debug directory (#2849) 2026-07-20 16:37:59 +08:00
Yaroslav Gurov 3c6ac431b4 chore: update awg (#2845)
* chore: update awg

* chore: update xray

* chore: update awg-apple checksum

* chore: update awg-windows checksum
2026-07-20 15:38:02 +08:00
Yaroslav Gurov e96a2b171c fix: get rid of empty i1-i5 params (#2846) 2026-07-20 10:44:07 +08:00
vkamn 06d219b92b chore: update xcode version in deploy.yml (#2838) 2026-07-16 23:22:22 +08:00
582 changed files with 107389 additions and 40910 deletions

No files matched your search

+732 -31
View File
@@ -28,7 +28,7 @@ jobs:
- 'cmake/recipes_bootstrap.cmake'
Bake-Prebuilts-Linux:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
needs: Detect-Changes
if: needs.Detect-Changes.outputs.recipes_changed == 'true'
@@ -63,7 +63,7 @@ jobs:
# ------------------------------------------------------
Build-Linux-Ubuntu:
runs-on: android-runner
runs-on: ubuntu-22.04
needs: Bake-Prebuilts-Linux
if: ${{ always() }}
@@ -118,8 +118,16 @@ jobs:
shell: bash
env:
QT_INSTALL_DIR: ${{ runner.temp }}
AMNEZIA_BUILD_TESTS: ON
run: deploy/build.sh --generator Ninja --installer all
- name: 'Upload Linux test build'
uses: actions/upload-artifact@v7
with:
name: linux-build
path: deploy/build/client/tests
retention-days: 7
- name: 'Upload installer artifact'
uses: actions/upload-artifact@v7
with:
@@ -242,10 +250,20 @@ jobs:
shell: cmd
env:
QT_INSTALL_DIR: ${{ runner.temp }}
AMNEZIA_BUILD_TESTS: ON
run: |
set WIX_ROOT_PATH="${{ env.USERPROFILE }}\.dotnet\tools"
deploy\build.bat --installer all
- name: 'Upload Windows test build'
uses: actions/upload-artifact@v7
with:
name: windows-build
path: |
deploy/build/client/tests
deploy/build/client/Release
retention-days: 7
- name: 'Upload WIX installer artifact'
uses: actions/upload-artifact@v7
with:
@@ -501,10 +519,10 @@ jobs:
- name: 'Setup xcode'
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.6.0'
xcode-version: '26.3.0'
- name: 'Install Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'mac'
@@ -531,12 +549,22 @@ jobs:
QT_INSTALL_DIR: ${{ runner.temp }}
CODESIGN_SIGNATURE: ${{ secrets.MAC_SIGNER_ID }}
CODESIGN_INSTALLER_SIGNATURE: ${{ secrets.MAC_INSTALLER_SIGNER_ID }}
CODESIGN_KEYCHAIN: ${{ steps.setup-keychain.outputs.keychain-path }}
CODESIGN_INSTALLER_KEYCHAIN: ${{ steps.setup-keychain.outputs.keychain-path }}
NOTARYTOOL_TEAM_ID: ${{ secrets.MAC_TEAM_ID }}
NOTARYTOOL_EMAIL: ${{ secrets.APPLE_DEV_EMAIL }}
NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_DEV_PASSWORD }}
AMNEZIA_BUILD_TESTS: ON
shell: bash
run: deploy/build.sh --generator Ninja --installer all
- name: 'Upload macOS test build'
uses: actions/upload-artifact@v7
with:
name: macos-build
path: deploy/build/client/tests
retention-days: 7
- name: 'Upload installer artifact'
uses: actions/upload-artifact@v7
with:
@@ -743,11 +771,37 @@ jobs:
needs: Bake-Prebuilts-Android
if: ${{ always() }}
strategy:
fail-fast: false
matrix:
include:
- label: 'android9-10'
qt_version: '6.10.1'
min_sdk: 28
max_sdk: 29
version_code_offset: 0
build_aab: true
- label: 'android11+'
qt_version: '6.10.3'
min_sdk: 30
max_sdk: ''
version_code_offset: 1
build_aab: true
- label: 'android9-10-qt610-3'
qt_version: '6.10.3'
min_sdk: 28
max_sdk: 29
version_code_offset: 0
build_aab: false
env:
ANDROID_PLATFORM: android-28
ANDROID_PLATFORM: android-${{ matrix.min_sdk }}
NDK_VERSION: 27.0.11718014
QT_VERSION: 6.10.3
QT_VERSION: ${{ matrix.qt_version }}
QT_MODULES: 'qtremoteobjects qt5compat qtimageformats qtshadertools'
APP_ANDROID_MIN_SDK: ${{ matrix.min_sdk }}
APP_ANDROID_MAX_SDK: ${{ matrix.max_sdk }}
APP_ANDROID_VERSION_CODE_OFFSET: ${{ matrix.version_code_offset }}
PROD_AGW_PUBLIC_KEY: ${{ secrets.PROD_AGW_PUBLIC_KEY }}
PROD_S3_ENDPOINT: ${{ secrets.PROD_S3_ENDPOINT }}
FALLBACK_S3_ENDPOINT: ${{ secrets.FALLBACK_S3_ENDPOINT }}
@@ -764,7 +818,7 @@ jobs:
submodules: 'true'
- name: 'Install desktop Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
@@ -776,10 +830,10 @@ jobs:
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Install android_x86_64 Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
host: 'all_os'
target: 'android'
arch: 'android_x86_64'
modules: ${{ env.QT_MODULES }}
@@ -788,10 +842,10 @@ jobs:
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Install android_x86 Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
host: 'all_os'
target: 'android'
arch: 'android_x86'
modules: ${{ env.QT_MODULES }}
@@ -800,10 +854,10 @@ jobs:
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Install android_armv7 Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
host: 'all_os'
target: 'android'
arch: 'android_armv7'
modules: ${{ env.QT_MODULES }}
@@ -812,10 +866,10 @@ jobs:
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Install android_arm64_v8a Qt'
uses: jurplel/install-qt-action@v4
uses: jurplel/install-qt-action@v4.3.1
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
host: 'all_os'
target: 'android'
arch: 'android_arm64_v8a'
modules: ${{ env.QT_MODULES }}
@@ -855,62 +909,108 @@ jobs:
QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
QT_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
LABEL: ${{ matrix.label }}
BUILD_AAB: ${{ matrix.build_aab }}
shell: bash
run: |
deploy/build.sh -t android --sign --aab
OUT=deploy/artifacts
rm -rf $OUT && mkdir -p $OUT
if [[ "$BUILD_AAB" == "true" ]]; then
deploy/build.sh -t android --sign --aab
else
deploy/build.sh -t android --sign
fi
VERSION=$(grep CMAKE_PROJECT_VERSION:STATIC deploy/build/CMakeCache.txt | cut -d= -f2)
(cd deploy/build/client/android-build && mv AmneziaVPN.apk AmneziaVPN_${VERSION}_android9+_universal.apk)
(cd deploy/build/client/android-build/build/outputs/bundle/release && mv android-build-release.aab AmneziaVPN_${VERSION}.aab)
mv deploy/build/client/android-build/AmneziaVPN.apk \
$OUT/AmneziaVPN_${VERSION}_${LABEL}_universal.apk
if [[ "$BUILD_AAB" == "true" ]]; then
mv deploy/build/client/android-build/build/outputs/bundle/ossRelease/android-build-oss-release.aab \
$OUT/AmneziaVPN_${VERSION}_${LABEL}_oss.aab
fi
for abi in arm64-v8a armeabi-v7a x86 x86_64; do
deploy/build.sh -t android --sign --abi ${abi} --build ./deploy/build/${abi}
(cd deploy/build/${abi}/client/android-build && mv AmneziaVPN.apk AmneziaVPN_${VERSION}_android9+_${abi}.apk)
mv deploy/build/${abi}/client/android-build/AmneziaVPN.apk \
$OUT/AmneziaVPN_${VERSION}_${LABEL}_${abi}.apk
done
ls -l $OUT
- name: 'Build Play AAB'
if: ${{ matrix.build_aab }}
env:
QT_INSTALL_DIR: ${{ runner.temp }}
QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
QT_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
LABEL: ${{ matrix.label }}
shell: bash
run: |
OUT=deploy/artifacts
VERSION=$(grep CMAKE_PROJECT_VERSION:STATIC deploy/build/CMakeCache.txt | cut -d= -f2)
deploy/build.sh -t android --sign --aab --play --build ./deploy/build/play
mv deploy/build/play/client/android-build/build/outputs/bundle/playRelease/android-build-play-release.aab \
$OUT/AmneziaVPN_${VERSION}_${LABEL}_play.aab
- name: 'Upload universal APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/client/android-build/*.apk
archive: false
retention-days: 7
- name: 'Upload AAB'
uses: actions/upload-artifact@v7
with:
path: deploy/build/client/android-build/build/outputs/bundle/release/*.aab
path: deploy/artifacts/*_universal.apk
archive: false
retention-days: 7
- name: 'Upload arm64-v8a APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/arm64-v8a/client/android-build/*.apk
path: deploy/artifacts/*_arm64-v8a.apk
archive: false
retention-days: 7
- name: 'Upload armeabi-v7a APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/armeabi-v7a/client/android-build/*.apk
path: deploy/artifacts/*_armeabi-v7a.apk
archive: false
retention-days: 7
- name: 'Upload x86 APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/x86/client/android-build/*.apk
path: deploy/artifacts/*_x86.apk
archive: false
retention-days: 7
- name: 'Upload x86_64 APK'
uses: actions/upload-artifact@v7
with:
path: deploy/build/x86_64/client/android-build/*.apk
path: deploy/artifacts/*_x86_64.apk
archive: false
retention-days: 7
- name: 'Upload OSS AAB'
if: ${{ matrix.build_aab }}
uses: actions/upload-artifact@v7
with:
path: deploy/artifacts/*_oss.aab
archive: false
retention-days: 7
- name: 'Upload Play AAB'
if: ${{ matrix.build_aab }}
uses: actions/upload-artifact@v7
with:
path: deploy/artifacts/*_play.aab
archive: false
retention-days: 7
# ------------------------------------------------------
Extra:
runs-on: ubuntu-latest
steps:
@@ -929,3 +1029,604 @@ jobs:
echo "Pull request:" >> $GITHUB_STEP_SUMMARY
echo "[[#${{ fromJSON(steps.pull_request.outputs.data)[0].number }}] ${{ fromJSON(steps.pull_request.outputs.data)[0].title }}](${{ fromJSON(steps.pull_request.outputs.data)[0].html_url }})" >> $GITHUB_STEP_SUMMARY
# ------------------------------------------------------
test-linux:
name: Test Linux (${{ matrix.filter }})
needs: Build-Linux-Ubuntu
strategy:
fail-fast: false
matrix:
filter:
- MultipleImportsTest
- ServerEditTest
- DefaultServerChangeTest
- ServerEdgeCasesTest
- SignalOrderTest
- ServersModelSyncTest
- ServerRenameTest
- SettingsSignalsTest
- UiServersModelAndControllerTest
- XraySerializationTest
- UiLanguageModelAndControllerTest
- UiIpModelAndControllerTest
- UiAppSTModelAndControllerTest
- UiAllowedDnsModelAndControllerTest
- BackupExportImportTest
- ConnectionTest
- OpenSSLLibraryTest
- GatewayServiceImportTest
- UiNewsModelAndControllerTest
- UiServicesModelAndControllerTest
- UiSubscriptionControllerTest
- UiApiAccountInfoModelTest
- ApiBenefitsModelTest
- ApiCountryModelTest
- ApiCountryListModelTest
- ApiDevicesModelTest
runs-on: ubuntu-22.04
env:
QT_VERSION: 6.10.1
QTEST_FUNCTION_TIMEOUT: 7200000
THIRD_PARTY_AWG_VPN_KEY: ${{ secrets.THIRD_PARTY_AWG_VPN_KEY }}
THIRD_PARTY_WIRE_GUARD_VPN_KEY: ${{ secrets.THIRD_PARTY_WIRE_GUARD_VPN_KEY }}
THIRD_PARTY_XRAY_VPN_KEY: ${{ secrets.THIRD_PARTY_XRAY_VPN_KEY }}
THIRD_PARTY_OPEN_VPN_KEY: ${{ secrets.THIRD_PARTY_OPEN_VPN_KEY }}
THIRD_PARTY_CLOAK_VPN_KEY: ${{ secrets.THIRD_PARTY_CLOAK_VPN_KEY }}
THIRD_PARTY_SS_VPN_KEY: ${{ secrets.THIRD_PARTY_SS_VPN_KEY }}
THIRD_PARTY_IKEV_VPN_KEY: ${{ secrets.THIRD_PARTY_IKEV_VPN_KEY }}
THIRD_PARTY_PREMIUM_VPN_KEY: ${{ secrets.THIRD_PARTY_PREMIUM_VPN_KEY }}
THIRD_PARTY_VMESS_NEW_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_NEW_IMPORT_DATA }}
THIRD_PARTY_VMESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_IMPORT_DATA }}
THIRD_PARTY_TROJAN_IMPORT_DATA: ${{ secrets.THIRD_PARTY_TROJAN_IMPORT_DATA }}
THIRD_PARTY_VLESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VLESS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA }}
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'true'
fetch-depth: 10
- name: Decode backup file
if: matrix.filter == 'BackupExportImportTest'
shell: bash
env:
BACKUP_FILE_BASE64: ${{ secrets.BACKUP_FILE_BASE64 }}
run: |
if [ -z "$BACKUP_FILE_BASE64" ]; then
echo "::error::BACKUP_FILE_BASE64 secret is not set"
exit 1
fi
echo "$BACKUP_FILE_BASE64" | base64 --decode > "$RUNNER_TEMP/test.backup"
echo "BACKUP_PATH=$RUNNER_TEMP/test.backup" >> "$GITHUB_ENV"
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
version: ${{ env.QT_VERSION }}
host: linux
target: desktop
arch: linux_gcc_64
modules: qtremoteobjects qt5compat qtshadertools
dir: ${{ runner.temp }}
set-env: true
aqtversion: '==3.3.0'
py7zrversion: '==0.22.*'
extra: '--base ${{ env.QT_MIRROR }}'
- name: Install system packages
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-x11-0 \
libsecret-1-dev
- name: Download build
uses: actions/download-artifact@v4
with:
name: linux-build
path: deploy/build/client/tests
- name: Restore executable permissions
run: |
find deploy/build/client/tests \
-type f \
-name "test_*" \
-exec chmod +x {} \;
- name: Run tests
env:
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
shell: bash
run: |
QT_ROOT="${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64"
export LD_LIBRARY_PATH="$PWD/deploy/build/client/tests:$QT_ROOT/lib:${LD_LIBRARY_PATH:-}"
export QT_QPA_PLATFORM=offscreen
ctest --test-dir deploy/build/client/tests --output-on-failure -VV --timeout 600 -R "^${{ matrix.filter }}$"
# ------------------------------------------------------
test-vps:
name: Test self-hosted VPS
needs: Build-Linux-Ubuntu
# These tests install and remove docker containers on a single shared VPS:
# two runs at once corrupt each other's state, so only one job may touch it.
concurrency:
group: vps-tests
cancel-in-progress: false
runs-on: ubuntu-22.04
env:
QT_VERSION: 6.10.1
QTEST_FUNCTION_TIMEOUT: 7200000
SELF_HOSTED_SERVER_HOST_NAME: ${{ secrets.SELF_HOSTED_SERVER_HOST_NAME }}
SELF_HOSTED_SERVER_USER_NAME: ${{ secrets.SELF_HOSTED_SERVER_USER_NAME }}
SELF_HOSTED_SERVER_PASSWORD: ${{ secrets.SELF_HOSTED_SERVER_PASSWORD }}
SELF_HOSTED_SERVER_SSH_PORT: ${{ secrets.SELF_HOSTED_SERVER_SSH_PORT }}
SELF_HOSTED_ADMIN_EXPORT_ROUND_TRIP_VPN_KEY: ${{ secrets.SELF_HOSTED_ADMIN_EXPORT_ROUND_TRIP_VPN_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'true'
fetch-depth: 10
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
version: ${{ env.QT_VERSION }}
host: linux
target: desktop
arch: linux_gcc_64
modules: qtremoteobjects qt5compat qtshadertools
dir: ${{ runner.temp }}
set-env: true
aqtversion: '==3.3.0'
py7zrversion: '==0.22.*'
extra: '--base ${{ env.QT_MIRROR }}'
- name: Install system packages
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-x11-0 \
libsecret-1-dev
- name: Download build
uses: actions/download-artifact@v4
with:
name: linux-build
path: deploy/build/client/tests
- name: Restore executable permissions
run: |
find deploy/build/client/tests \
-type f \
-name "test_*" \
-exec chmod +x {} \;
- name: Run tests
shell: bash
run: |
QT_ROOT="${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64"
export LD_LIBRARY_PATH="$PWD/deploy/build/client/tests:$QT_ROOT/lib:${LD_LIBRARY_PATH:-}"
export QT_QPA_PLATFORM=offscreen
ctest --test-dir deploy/build/client/tests --output-on-failure -VV \
-R "^(SelfHostedServerSetupTest|MultipleExportsTest|AdminSelfHostedExportTest)$"
# ------------------------------------------------------
test-windows:
name: Test Windows (${{ matrix.filter }})
needs: Build-Windows
strategy:
fail-fast: false
matrix:
filter:
- MultipleImportsTest
- ServerEditTest
- DefaultServerChangeTest
- ServerEdgeCasesTest
- SignalOrderTest
- ServersModelSyncTest
- ServerRenameTest
- SettingsSignalsTest
- UiServersModelAndControllerTest
- XraySerializationTest
- UiLanguageModelAndControllerTest
- UiIpModelAndControllerTest
- UiAppSTModelAndControllerTest
- UiAllowedDnsModelAndControllerTest
- BackupExportImportTest
- ConnectionTest
- OpenSSLLibraryTest
- GatewayServiceImportTest
- UiNewsModelAndControllerTest
- UiServicesModelAndControllerTest
- UiSubscriptionControllerTest
- UiApiAccountInfoModelTest
- ApiBenefitsModelTest
- ApiCountryModelTest
- ApiCountryListModelTest
- ApiDevicesModelTest
runs-on: windows-latest
env:
QT_VERSION: 6.10.1
QTEST_FUNCTION_TIMEOUT: 7200000
THIRD_PARTY_AWG_VPN_KEY: ${{ secrets.THIRD_PARTY_AWG_VPN_KEY }}
THIRD_PARTY_WIRE_GUARD_VPN_KEY: ${{ secrets.THIRD_PARTY_WIRE_GUARD_VPN_KEY }}
THIRD_PARTY_XRAY_VPN_KEY: ${{ secrets.THIRD_PARTY_XRAY_VPN_KEY }}
THIRD_PARTY_OPEN_VPN_KEY: ${{ secrets.THIRD_PARTY_OPEN_VPN_KEY }}
THIRD_PARTY_CLOAK_VPN_KEY: ${{ secrets.THIRD_PARTY_CLOAK_VPN_KEY }}
THIRD_PARTY_SS_VPN_KEY: ${{ secrets.THIRD_PARTY_SS_VPN_KEY }}
THIRD_PARTY_IKEV_VPN_KEY: ${{ secrets.THIRD_PARTY_IKEV_VPN_KEY }}
THIRD_PARTY_PREMIUM_VPN_KEY: ${{ secrets.THIRD_PARTY_PREMIUM_VPN_KEY }}
THIRD_PARTY_VMESS_NEW_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_NEW_IMPORT_DATA }}
THIRD_PARTY_VMESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_IMPORT_DATA }}
THIRD_PARTY_TROJAN_IMPORT_DATA: ${{ secrets.THIRD_PARTY_TROJAN_IMPORT_DATA }}
THIRD_PARTY_VLESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VLESS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA }}|
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'true'
fetch-depth: 10
- name: Decode backup file
if: matrix.filter == 'BackupExportImportTest'
shell: pwsh
env:
BACKUP_FILE_BASE64: ${{ secrets.BACKUP_FILE_BASE64 }}
run: |
if ([string]::IsNullOrWhiteSpace($env:BACKUP_FILE_BASE64)) {
Write-Output "::error::BACKUP_FILE_BASE64 secret is not set"
exit 1
}
$backup = Join-Path $env:RUNNER_TEMP "test.backup"
$bytes = [Convert]::FromBase64String($env:BACKUP_FILE_BASE64)
[IO.File]::WriteAllBytes($backup, $bytes)
"BACKUP_PATH=$backup" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Download build
uses: actions/download-artifact@v4
with:
name: windows-build
path: deploy/build/client
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: windows
target: desktop
arch: win64_msvc2022_64
modules: qtremoteobjects qt5compat qtshadertools
dir: ${{ runner.temp }}
set-env: true
- name: Setup MSVC
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Run tests
env:
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
shell: cmd
run: |
rem Without an attached console (GitHub runner), QTest sends its output to
rem OutputDebugString instead of stdout, so ctest captures nothing.
set QT_FORCE_STDERR_LOGGING=1
set PATH=%PATH%;%CD%\client\3rd-prebuilt\deploy-prebuilt\windows\x64
set PATH=%PATH%;%Qt6_DIR%\..\..\..\bin
ctest --test-dir deploy\build\client\tests -C Release -R "${{ matrix.filter }}" --output-on-failure -VV --timeout 600
# ------------------------------------------------------
test-macos:
name: Test macOS (${{ matrix.filter }})
needs: Build-MacOS
strategy:
fail-fast: false
matrix:
filter:
- MultipleImportsTest
- ServerEditTest
- DefaultServerChangeTest
- ServerEdgeCasesTest
- SignalOrderTest
- ServersModelSyncTest
- ServerRenameTest
- SettingsSignalsTest
- UiServersModelAndControllerTest
- XraySerializationTest
- UiLanguageModelAndControllerTest
- UiIpModelAndControllerTest
- UiAppSTModelAndControllerTest
- UiAllowedDnsModelAndControllerTest
- BackupExportImportTest
- ConnectionTest
- OpenSSLLibraryTest
- GatewayServiceImportTest
- UiNewsModelAndControllerTest
- UiServicesModelAndControllerTest
- UiSubscriptionControllerTest
- UiApiAccountInfoModelTest
- ApiBenefitsModelTest
- ApiCountryModelTest
- ApiCountryListModelTest
- ApiDevicesModelTest
runs-on: macos-15
env:
QT_VERSION: 6.10.1
QTEST_FUNCTION_TIMEOUT: 7200000
THIRD_PARTY_AWG_VPN_KEY: ${{ secrets.THIRD_PARTY_AWG_VPN_KEY }}
THIRD_PARTY_WIRE_GUARD_VPN_KEY: ${{ secrets.THIRD_PARTY_WIRE_GUARD_VPN_KEY }}
THIRD_PARTY_XRAY_VPN_KEY: ${{ secrets.THIRD_PARTY_XRAY_VPN_KEY }}
THIRD_PARTY_OPEN_VPN_KEY: ${{ secrets.THIRD_PARTY_OPEN_VPN_KEY }}
THIRD_PARTY_CLOAK_VPN_KEY: ${{ secrets.THIRD_PARTY_CLOAK_VPN_KEY }}
THIRD_PARTY_SS_VPN_KEY: ${{ secrets.THIRD_PARTY_SS_VPN_KEY }}
THIRD_PARTY_IKEV_VPN_KEY: ${{ secrets.THIRD_PARTY_IKEV_VPN_KEY }}
THIRD_PARTY_PREMIUM_VPN_KEY: ${{ secrets.THIRD_PARTY_PREMIUM_VPN_KEY }}
THIRD_PARTY_VMESS_NEW_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_NEW_IMPORT_DATA }}
THIRD_PARTY_VMESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VMESS_IMPORT_DATA }}
THIRD_PARTY_TROJAN_IMPORT_DATA: ${{ secrets.THIRD_PARTY_TROJAN_IMPORT_DATA }}
THIRD_PARTY_VLESS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_VLESS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_IMPORT_DATA }}
THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA: ${{ secrets.THIRD_PARTY_SHADOWSOCKS_SUBSCRIPTION_IMPORT_DATA }}
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
steps:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.3.0"
- name: Checkout
uses: actions/checkout@v4
with:
submodules: 'true'
fetch-depth: 10
- name: Decode backup file
if: matrix.filter == 'BackupExportImportTest'
shell: bash
env:
BACKUP_FILE_BASE64: ${{ secrets.BACKUP_FILE_BASE64 }}
run: |
if [ -z "$BACKUP_FILE_BASE64" ]; then
echo "::error::BACKUP_FILE_BASE64 secret is not set"
exit 1
fi
echo "$BACKUP_FILE_BASE64" | base64 --decode > "$RUNNER_TEMP/test.backup"
echo "BACKUP_PATH=$RUNNER_TEMP/test.backup" >> "$GITHUB_ENV"
- name: Download build
uses: actions/download-artifact@v4
with:
name: macos-build
path: deploy/build/client/tests
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: mac
target: desktop
arch: clang_64
modules: qtremoteobjects qt5compat qtshadertools
dir: ${{ runner.temp }}
set-env: true
- name: Restore executable permissions
run: |
find deploy/build/client/tests \
-type f \
-name "test_*" \
-exec chmod +x {} \;
- name: Run tests
env:
AMNEZIA_TEST_API_KEY: ${{ secrets.AMNEZIA_TEST_API_KEY }}
shell: bash
run: |
QT_ROOT="$RUNNER_TEMP/Qt/$QT_VERSION/macos"
export DYLD_LIBRARY_PATH="$PWD/deploy/build/client/tests:$QT_ROOT/lib:${DYLD_LIBRARY_PATH:-}"
ctest --test-dir deploy/build/client/tests -R "${{ matrix.filter }}" --output-on-failure -VV --timeout 600
# ------------------------------------------------------
test-android:
runs-on: android-runner
env:
ANDROID_PLATFORM: android-28
NDK_VERSION: 27.0.11718014
QT_VERSION: 6.10.1
QT_MODULES: 'qtremoteobjects qt5compat qtshadertools'
steps:
- name: 'Get sources'
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 10
- name: 'Decode backup file'
run: |
echo "${{ secrets.BACKUP_FILE_BASE64 }}" | base64 --decode > /tmp/test.backup
echo "BACKUP_PATH=/tmp/test.backup" >> $GITHUB_ENV
- name: 'Install desktop Qt'
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: 'linux'
target: 'desktop'
arch: 'linux_gcc_64'
modules: ${{ env.QT_MODULES }}
dir: ${{ runner.temp }}
py7zrversion: '==0.22.*'
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Install android_x86_64 Qt'
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
host: 'all_os'
target: 'android'
arch: 'android_x86_64'
modules: ${{ env.QT_MODULES }}
dir: ${{ runner.temp }}
py7zrversion: '==0.22.*'
extra: '--base ${{ env.QT_MIRROR }}'
- name: 'Setup Java'
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: 'Setup Android SDK'
uses: android-actions/setup-android@v4
with:
packages: "platforms;${{ env.ANDROID_PLATFORM }} ndk;${{ env.NDK_VERSION }}"
- name: 'Setup python'
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: 'Install conan'
run: pip install "conan==2.28.0"
# Uncomment to sign APK with release keystore (requires secrets):
# - name: 'Decode keystore'
# env:
# KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
# run: echo $KEYSTORE_BASE64 | base64 --decode > android.keystore
- name: 'Configure (Android x86_64)'
env:
QT_ANDROID: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/android_x86_64
QT_HOST: ${{ runner.temp }}/Qt/${{ env.QT_VERSION }}/gcc_64
CMAKE_ANDROID_NDK: ${{ env.ANDROID_HOME }}/ndk/${{ env.NDK_VERSION }}
run: |
cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_TOOLCHAIN_FILE=$QT_ANDROID/lib/cmake/Qt6/qt.toolchain.cmake \
-DANDROID_PLATFORM=${{ env.ANDROID_PLATFORM }} \
-DCMAKE_ANDROID_NDK=$CMAKE_ANDROID_NDK \
-DQT_HOST_PATH=$QT_HOST \
-DANDROID_SDK_ROOT=$ANDROID_HOME \
-DAMNEZIA_BUILD_TESTS=ON
- name: 'Build Android tests'
# env:
# QT_ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android.keystore
# QT_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_ALIAS }}
# QT_ANDROID_KEYSTORE_STORE_PASS: ${{ secrets.ANDROID_RELEASE_KEYSTORE_KEY_PASS }}
run: |
cmake --build build --target test_android_openssl_make_apk
cmake --build build --target test_android_restore_backup_make_apk
- name: 'Write Android test runner script'
run: |
cat > ci_run_android_tests.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
ADB=$(which adb)
echo ">>> Waiting for package manager to be ready..."
"$ADB" shell 'while ! pm list packages > /dev/null 2>&1; do sleep 2; done'
echo ">>> Package manager ready."
run_test() {
local target="$1"
local apk="build/client/tests/android-build/${target}.apk"
local pkg_name="org.qtproject.example.${target}"
local activity="${pkg_name}/org.qtproject.qt.android.bindings.QtActivity"
echo ">>> Installing ${target} APK..."
timeout 120 "$ADB" install -r "$apk"
"$ADB" shell "run-as $pkg_name sh -c 'mkdir -p files && : > files/stdout.txt'" 2>/dev/null || true
"$ADB" shell "run-as $pkg_name sh -c 'rm -f files/qtest_last_exit_code'" 2>/dev/null || true
echo ">>> Launching ${target}..."
"$ADB" shell am start -n "$activity"
echo ">>> Waiting for test to finish (up to 120s)..."
local elapsed=0 exit_code=""
while [[ $elapsed -lt 120 ]]; do
sleep 2
elapsed=$((elapsed + 2))
exit_code=$("$ADB" shell \
"run-as $pkg_name cat files/qtest_last_exit_code 2>/dev/null" \
| tr -d '[:space:]' || true)
[[ -n "$exit_code" ]] && break
done
local stdout_content
stdout_content=$("$ADB" shell \
"run-as $pkg_name cat files/stdout.txt 2>/dev/null" || true)
if [[ -n "$stdout_content" ]]; then
echo ">>> Test output:"
echo "$stdout_content"
fi
if [[ -z "$exit_code" ]]; then
echo ">>> TIMEOUT: ${target} did not complete within 120s."
exit 1
elif [[ "$exit_code" != "0" ]]; then
echo ">>> ${target} FAILED (exit code: $exit_code)"
exit 1
else
echo ">>> ${target} PASSED"
fi
}
run_test test_android_openssl
run_test test_android_restore_backup
EOF
chmod +x ci_run_android_tests.sh
- name: 'Run Android tests on emulator'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 28
arch: x86_64
emulator-options: -no-snapshot -no-window -no-audio -no-boot-anim -accel on
disable-animations: true
script: bash ci_run_android_tests.sh
+3
View File
@@ -10,6 +10,8 @@ deploy/build_64/*
winbuild*.bat
.cache/
.vscode/
.venv/
.cursor*
# Qt-es
@@ -144,3 +146,4 @@ macos-with-sign-build.sh
DeveloperIdApplicationCertificate.p12
DeveloperIdInstallerCertificate.p12
gwenv.sh
+13 -4
View File
@@ -4,7 +4,9 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(PROJECT AmneziaVPN)
set(AMNEZIAVPN_VERSION 4.9.0.4)
if(NOT AMNEZIAVPN_VERSION)
set(AMNEZIAVPN_VERSION 5.1.0.0 CACHE STRING "Client app version")
endif()
set(QT_CREATOR_SKIP_PACKAGE_MANAGER_SETUP ON CACHE BOOL "" FORCE)
set(CMAKE_PROJECT_TOP_LEVEL_INCLUDES
@@ -18,6 +20,8 @@ project(${PROJECT} VERSION ${AMNEZIAVPN_VERSION}
HOMEPAGE_URL "https://amnezia.org/"
)
option(AMNEZIA_BUILD_TESTS "Build test executables" OFF)
# trigger conan to kick off `conan install` globally
find_package(OpenSSL REQUIRED)
if (PREBUILTS_ONLY)
@@ -28,7 +32,12 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d")
set(RELEASE_DATE "${CURRENT_DATE}")
set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
set(APP_ANDROID_VERSION_CODE 2133)
# bump by 2 on every release, because we're releasing two versions on the play store
set(APP_ANDROID_VERSION_CODE 2173)
if(DEFINED APP_ANDROID_VERSION_CODE_OFFSET)
math(EXPR APP_ANDROID_VERSION_CODE "${APP_ANDROID_VERSION_CODE} + ${APP_ANDROID_VERSION_CODE_OFFSET}")
endif()
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(MZ_PLATFORM_NAME "linux")
@@ -54,14 +63,14 @@ if(APPLE AND NOT IOS)
endif()
configure_file(
"${CMAKE_SOURCE_DIR}/deploy/data/pf-templates/amn.400.allowPIA.conf.in"
"${CMAKE_CURRENT_SOURCE_DIR}/deploy/data/pf-templates/amn.400.allowPIA.conf.in"
"${CMAKE_CURRENT_BINARY_DIR}/amn.400.allowPIA.conf"
@ONLY
)
file(COPY_FILE
"${CMAKE_CURRENT_BINARY_DIR}/amn.400.allowPIA.conf"
"${CMAKE_SOURCE_DIR}/deploy/data/macos/pf/amn.400.allowPIA.conf"
"${CMAKE_CURRENT_SOURCE_DIR}/deploy/data/macos/pf/amn.400.allowPIA.conf"
ONLY_IF_DIFFERENT
)
endif()
+7 -7
View File
@@ -38,14 +38,14 @@
## Links
- [https://amnezia.org](https://amnezia.org) - Project website | [Alternative link (mirror)](https://storage.googleapis.com/kldscp/amnezia.org)
- [https://docs.amnezia.org](https://docs.amnezia.org) - Documentation
- [https://amnezia.org](https://amnezia.org/?utm_source=github&utm_campaign=amnezia_website-read) - Project website | [Alternative link (mirror)](https://storage.googleapis.com/amnezia/amnezia.org?utm_source=github&utm_campaign=amnezia_website-read)
- [https://docs.amnezia.org](https://docs.amnezia.org/?utm_source=github&utm_campaign=amnezia_website-read) - Documentation | [Alternative link (mirror)](https://storage.googleapis.com/amnezia/docs?utm_source=github&utm_campaign=amnezia_website-read)
- [https://www.reddit.com/r/AmneziaVPN](https://www.reddit.com/r/AmneziaVPN) - Reddit
- [https://t.me/amnezia_vpn_en](https://t.me/amnezia_vpn_en) - Telegram support channel (English)
- [https://t.me/amnezia_vpn_ir](https://t.me/amnezia_vpn_ir) - Telegram support channel (Farsi)
- [https://t.me/amnezia_vpn_mm](https://t.me/amnezia_vpn_mm) - Telegram support channel (Myanmar)
- [https://t.me/amnezia_vpn](https://t.me/amnezia_vpn) - Telegram support channel (Russian)
- [https://vpnpay.io/en/amnezia-premium/](https://vpnpay.io/en/amnezia-premium/) - Amnezia Premium
- [https://telegram.me/amnezia_vpn_en](https://telegram.me/amnezia_vpn_en) - Telegram support channel (English)
- [https://telegram.me/amnezia_vpn_ir](https://telegram.me/amnezia_vpn_ir) - Telegram support channel (Farsi)
- [https://telegram.me/amnezia_vpn_mm](https://telegram.me/amnezia_vpn_mm) - Telegram support channel (Myanmar)
- [https://telegram.me/amnezia_vpn](https://telegram.me/amnezia_vpn) - Telegram support channel (Russian)
- [Get Premium for 6 or 12 months](https://storage.googleapis.com/amnezia/pay?utm_source=github&utm_campaign=ampay-read)
## Tech
+7 -7
View File
@@ -35,14 +35,14 @@
## Ссылки
- [https://amnezia.org](https://amnezia.org) - Веб-сайт проекта | [Альтернативная ссылка (зеркало)](https://storage.googleapis.com/kldscp/amnezia.org)
- [https://docs.amnezia.org](https://docs.amnezia.org) - Документация
- [https://amnezia.org](https://amnezia.org/?utm_source=github&utm_campaign=amnezia_website-read) - Веб-сайт проекта | [Альтернативная ссылка (зеркало)](https://storage.googleapis.com/amnezia/amnezia.org?utm_source=github&utm_campaign=amnezia_website-read)
- [https://docs.amnezia.org](https://docs.amnezia.org/?utm_source=github&utm_campaign=amnezia_website-read) - Документация | [Альтернативная ссылка (зеркало)](https://storage.googleapis.com/amnezia/docs?utm_source=github&utm_campaign=amnezia_website-read)
- [https://www.reddit.com/r/AmneziaVPN](https://www.reddit.com/r/AmneziaVPN) - Reddit
- [https://t.me/amnezia_vpn_en](https://t.me/amnezia_vpn_en) - Канал поддержки в Telegram (Английский)
- [https://t.me/amnezia_vpn_ir](https://t.me/amnezia_vpn_ir) - Канал поддержки в Telegram (Фарси)
- [https://t.me/amnezia_vpn_mm](https://t.me/amnezia_vpn_mm) - Канал поддержки в Telegram (Мьянма)
- [https://t.me/amnezia_vpn](https://t.me/amnezia_vpn) - Канал поддержки в Telegram (Русский)
- [https://vpnpay.io/en/amnezia-premium/](https://vpnpay.io/en/amnezia-premium/) - Amnezia Premium | [Зеркало](https://storage.googleapis.com/kldscp/vpnpay.io/ru/amnezia-premium\)
- [https://telegram.me/amnezia_vpn_en](https://telegram.me/amnezia_vpn_en) - Канал поддержки в Telegram (Английский)
- [https://telegram.me/amnezia_vpn_ir](https://telegram.me/amnezia_vpn_ir) - Канал поддержки в Telegram (Фарси)
- [https://telegram.me/amnezia_vpn_mm](https://telegram.me/amnezia_vpn_mm) - Канал поддержки в Telegram (Мьянма)
- [https://telegram.me/amnezia_vpn](https://telegram.me/amnezia_vpn) - Канал поддержки в Telegram (Русский)
- [Оформите Premium на 6 или 12 месяцев](https://storage.googleapis.com/amnezia/pay?utm_source=github&utm_campaign=ampay-read)
## Технологии
+72 -26
View File
@@ -1,6 +1,8 @@
cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR)
set(PROJECT AmneziaVPN)
include(${CMAKE_CURRENT_LIST_DIR}/cmake/clientBrandingDefaults.cmake)
set(PROJECT ${CLIENT_TARGET_NAME})
project(${PROJECT})
set_property(GLOBAL PROPERTY USE_FOLDERS ON)
@@ -39,14 +41,24 @@ if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
endif()
find_package(Qt6 REQUIRED COMPONENTS ${PACKAGES})
find_package(OpenSSL REQUIRED)
find_package(libagw REQUIRED)
set(LIBS ${LIBS}
Qt6::Core Qt6::Gui
Qt6::Network Qt6::Xml Qt6::RemoteObjects
Qt6::Quick Qt6::Svg Qt6::QuickControls2
Qt6::Core5Compat Qt6::Concurrent
amnezia::libagw
)
find_package(Qt6 QUIET OPTIONAL_COMPONENTS CorePrivate)
if(TARGET Qt6::CorePrivate)
set(LIBS ${LIBS} Qt6::CorePrivate)
add_compile_definitions(AMNEZIA_HAS_QZIPWRITER)
endif()
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
set(LIBS ${LIBS} Qt6::Widgets)
endif()
@@ -56,6 +68,9 @@ qt_add_executable(${PROJECT} MANUAL_FINALIZATION)
target_include_directories(${PROJECT} PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
)
target_compile_definitions(${PROJECT} PRIVATE
CLIENT_KEYCHAIN_NAME_STR=\"${CLIENT_KEYCHAIN_NAME}\"
)
if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID))
qt_add_repc_replicas(${PROJECT} ${CMAKE_CURRENT_LIST_DIR}/../ipc/ipc_interface.rep)
@@ -65,25 +80,38 @@ endif()
qt6_add_resources(QRC ${QRC}
${CMAKE_CURRENT_LIST_DIR}/images/images.qrc
${CMAKE_CURRENT_LIST_DIR}/images/flagKit.qrc
${CMAKE_CURRENT_LIST_DIR}/resources/countries.qrc
${CMAKE_CURRENT_LIST_DIR}/client_scripts/clientScripts.qrc
${CMAKE_CURRENT_LIST_DIR}/ui/qml/qml.qrc
${CLIENT_QML_QRC_FILE}
${CMAKE_CURRENT_LIST_DIR}/server_scripts/serverScripts.qrc
${CLIENT_EXTRA_QRC}
)
# -- i18n begin
set(AMNEZIAVPN_TS_FILES
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_ru_RU.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_zh_CN.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_fa_IR.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_ar_EG.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_my_MM.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_uk_UA.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_ur_PK.ts
${CMAKE_CURRENT_LIST_DIR}/translations/amneziavpn_hi_IN.ts
)
if(CLIENT_TS_FILES)
set(AMNEZIAVPN_TS_FILES ${CLIENT_TS_FILES})
else()
set(AMNEZIAVPN_TS_FILES
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_ru_RU.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_zh_CN.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_fa_IR.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_ar_EG.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_my_MM.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_uk_UA.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_ur_PK.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_hi_IN.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_es_ES.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_ko_KR.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_fr_FR.ts
${CMAKE_CURRENT_LIST_DIR}/translations/${CLIENT_TS_PREFIX}_th_TH.ts
)
endif()
file(GLOB_RECURSE AMNEZIAVPN_QML_FILES ${CMAKE_CURRENT_LIST_DIR}/ui/qml/*.qml)
qt6_add_translations(${PROJECT}
TS_FILES ${AMNEZIAVPN_TS_FILES}
SOURCES ${AMNEZIAVPN_QML_FILES}
RESOURCE_PREFIX "/translations"
)
# -- i18n end
@@ -100,13 +128,18 @@ endif()
include(${CMAKE_CURRENT_LIST_DIR}/cmake/3rdparty.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/cmake/sources.cmake)
include_directories(
set(_client_include_dirs
${CMAKE_CURRENT_LIST_DIR}/../ipc
${CMAKE_CURRENT_LIST_DIR}/../common/logger
${CMAKE_CURRENT_LIST_DIR}/../common/crypto
${CMAKE_CURRENT_LIST_DIR}
${CMAKE_CURRENT_BINARY_DIR}
)
if(CLIENT_BRANDING_INCLUDE_DIR)
list(APPEND _client_include_dirs ${CLIENT_BRANDING_INCLUDE_DIR})
endif()
list(APPEND _client_include_dirs ${CMAKE_CURRENT_LIST_DIR})
include_directories(${_client_include_dirs})
if(MACOS_NE)
message("MACOS_NE is ON")
@@ -122,6 +155,26 @@ include_directories(mozilla/shared)
include_directories(mozilla/models)
configure_file(${CMAKE_CURRENT_LIST_DIR}/../version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h)
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/core/utils)
configure_file(
${CMAKE_CURRENT_LIST_DIR}/core/utils/appUiConfig.h.in
${CMAKE_CURRENT_BINARY_DIR}/core/utils/appUiConfig.h
@ONLY
)
configure_file(
${CMAKE_CURRENT_LIST_DIR}/core/utils/swiftBridgeConfig.h.in
${CMAKE_CURRENT_BINARY_DIR}/core/utils/swiftBridgeConfig.h
@ONLY
)
# Swift can't consume a `-DGROUP_ID="value"` compile define (Swift -D flags are
# presence-only), unlike the Objective-C++ sources that read GROUP_ID directly.
# Generate a Swift constant instead so Log.swift can use the branded app group.
set(CLIENT_GROUP_IDENTIFIER_SWIFT_FILE ${CMAKE_CURRENT_BINARY_DIR}/platforms/ios/GroupIdentifier.swift)
configure_file(
${CMAKE_CURRENT_LIST_DIR}/platforms/ios/GroupIdentifier.swift.in
${CLIENT_GROUP_IDENTIFIER_SWIFT_FILE}
@ONLY
)
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
target_compile_definitions(${PROJECT} PRIVATE "MZ_DEBUG")
@@ -149,18 +202,6 @@ if(APPLE)
cmake_policy(SET CMP0099 NEW)
cmake_policy(SET CMP0114 NEW)
if(NOT BUILD_OSX_APP_IDENTIFIER)
set(BUILD_OSX_APP_IDENTIFIER org.amnezia.AmneziaVPN CACHE STRING "OSX Application identifier")
endif()
if(NOT BUILD_IOS_APP_IDENTIFIER)
set(BUILD_IOS_APP_IDENTIFIER org.amnezia.AmneziaVPN CACHE STRING "iOS Application identifier")
endif()
if(NOT BUILD_IOS_GROUP_IDENTIFIER)
set(BUILD_IOS_GROUP_IDENTIFIER group.org.amnezia.AmneziaVPN CACHE STRING "iOS Group identifier")
endif()
if(NOT BUILD_VPN_DEVELOPMENT_TEAM)
set(BUILD_VPN_DEVELOPMENT_TEAM X7UJ388FXK CACHE STRING "Amnezia VPN Development Team")
endif()
set(CMAKE_XCODE_GENERATE_SCHEME FALSE)
set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM ${BUILD_VPN_DEVELOPMENT_TEAM})
@@ -174,6 +215,7 @@ endif()
if(LINUX AND NOT ANDROID)
set(LIBS ${LIBS} -static-libstdc++ -static-libgcc -ldl)
link_directories(${CMAKE_CURRENT_LIST_DIR}/platforms/linux)
set(LIBS ${LIBS} -Wl,--no-as-needed $<TARGET_LINKER_FILE:Qt6::RemoteObjects> -Wl,--as-needed)
endif()
if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID))
@@ -231,7 +273,7 @@ install(RUNTIME_DEPENDENCY_SET client_deps
[[hvsifiletrust\.dll]]
[[libc\.so\..*]] [[libgcc_s\.so\..*]] [[libm\.so\..*]] [[libstdc\+\+\.so\..*]]
[[.*\.framework]]
[[^[Qq]t.*]]
[[^(lib)?[Qq]t.*]]
POST_EXCLUDE_REGEXES
[[^.*[\\/]system32[\\/].*\.dll$]]
[[^/lib.*]]
@@ -283,3 +325,7 @@ if(OVPN_SCRIPTS)
WORLD_READ WORLD_EXECUTE
)
endif()
if(AMNEZIA_BUILD_TESTS)
add_subdirectory(tests)
endif()
+28 -4
View File
@@ -18,14 +18,23 @@
#include <QtQuick/QQuickWindow>
#include <QWindow>
#include "core/controllers/updateController.h"
#include "core/protocols/qmlRegisterProtocols.h"
#include "logger.h"
#include "ui/controllers/qml/pageController.h"
#include "ui/models/installedAppsModel.h"
#include "ui/utils/mtProxyPublicHostInput.h"
#include "version.h"
#include "core/utils/appUiConfig.h"
#include "platforms/ios/QRCodeReaderBase.h"
#ifdef Q_OS_IOS
#include "platforms/ios/ioscontextmenu.h"
#endif
#ifdef Q_OS_ANDROID
#include "platforms/android/android_controller.h"
#endif
bool AmneziaApplication::m_forceQuit = false;
@@ -99,7 +108,7 @@ void AmneziaApplication::init()
{
m_engine = new QQmlApplicationEngine;
const QUrl url(QStringLiteral("qrc:/ui/qml/main2.qml"));
const QUrl url(QStringLiteral(APP_QML_ENTRYPOINT));
QObject::connect(
m_engine, &QQmlApplicationEngine::objectCreated, this,
[this, url](QObject *obj, const QUrl &objUrl) {
@@ -139,13 +148,23 @@ void AmneziaApplication::init()
m_engine->rootContext()->setContextProperty("IsMacOsNeBuild", false);
#endif
#ifdef Q_OS_IOS
m_engine->rootContext()->setContextProperty("IosContextMenu", new IosContextMenu(this));
#endif
#ifdef Q_OS_ANDROID
m_engine->rootContext()->setContextProperty("IsPlayBuild", AndroidController::instance()->isPlay());
#else
m_engine->rootContext()->setContextProperty("IsPlayBuild", false);
#endif
m_vpnConnection.reset(new VpnConnection(nullptr, nullptr));
m_vpnConnection->moveToThread(&m_vpnConnectionThread);
m_vpnConnectionThread.start();
m_coreController.reset(new CoreController(m_vpnConnection, m_settings, m_engine));
m_engine->addImportPath("qrc:/ui/qml/Modules/");
m_engine->addImportPath(QStringLiteral(APP_QML_IMPORT_PATH));
if (m_parser.isSet(m_optImport)) {
const QString data = m_parser.value(m_optImport);
@@ -160,6 +179,10 @@ void AmneziaApplication::init()
m_coreController->setQmlRoot();
#if CLIENT_ENABLE_APP_UPDATES
m_coreController->checkForAppUpdates();
#endif
#ifdef Q_OS_WIN //TODO
if (m_parser.isSet(m_optAutostart))
m_coreController->pageController()->showOnStartup();
@@ -228,13 +251,14 @@ void AmneziaApplication::registerTypes()
amnezia::declareQmlProtocolEnum();
Vpn::declareQmlVpnConnectionStateEnum();
PageLoader::declareQmlPageEnum();
UpdateState::declareQmlUpdateStateEnum();
}
void AmneziaApplication::loadFonts()
{
QQuickStyle::setStyle("Basic");
QFontDatabase::addApplicationFont(":/fonts/pt-root-ui_vf.ttf");
QFontDatabase::addApplicationFont(QStringLiteral(APP_UI_FONT_RESOURCE));
}
bool AmneziaApplication::parseCommands()
@@ -261,7 +285,7 @@ bool AmneziaApplication::parseCommands()
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
void AmneziaApplication::startLocalServer() {
const QString serverName("AmneziaVPNInstance");
const QString serverName(APP_INSTANCE_NAME);
QLocalServer::removeServer(serverName);
QLocalServer *server = new QLocalServer(this);
+19
View File
@@ -0,0 +1,19 @@
plugins {
id(libs.plugins.android.library.get().pluginId)
id(libs.plugins.kotlin.android.get().pluginId)
}
kotlin {
jvmToolchain(17)
}
android {
namespace = "org.amnezia.vpn.billing"
}
dependencies {
compileOnly(project(":utils"))
implementation(libs.androidx.core)
implementation(libs.kotlinx.coroutines)
implementation(libs.android.billing)
}
@@ -0,0 +1,65 @@
import com.android.billingclient.api.BillingClient.BillingResponseCode.BILLING_UNAVAILABLE
import com.android.billingclient.api.BillingClient.BillingResponseCode.DEVELOPER_ERROR
import com.android.billingclient.api.BillingClient.BillingResponseCode.ERROR
import com.android.billingclient.api.BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_NOT_OWNED
import com.android.billingclient.api.BillingClient.BillingResponseCode.ITEM_UNAVAILABLE
import com.android.billingclient.api.BillingClient.BillingResponseCode.NETWORK_ERROR
import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_DISCONNECTED
import com.android.billingclient.api.BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE
import com.android.billingclient.api.BillingClient.BillingResponseCode.USER_CANCELED
import com.android.billingclient.api.BillingResult
import org.amnezia.vpn.util.ErrorCode
internal class BillingException(
billingResult: BillingResult,
retryable: Boolean? = null
) : Exception(billingResult.toString()) {
constructor(msg: String) : this(BillingResult.newBuilder()
.setResponseCode(DEVELOPER_ERROR)
.setDebugMessage(msg)
.build())
val errorCode: Int
val isCanceled = billingResult.responseCode == USER_CANCELED
val isRetryable = retryable ?: (billingResult.responseCode in setOf(
NETWORK_ERROR,
SERVICE_DISCONNECTED,
SERVICE_UNAVAILABLE,
ERROR
))
init {
when (billingResult.responseCode) {
ERROR -> {
errorCode = ErrorCode.BillingGooglePlayError
}
BILLING_UNAVAILABLE, SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE -> {
errorCode = ErrorCode.BillingUnavailable
}
DEVELOPER_ERROR, FEATURE_NOT_SUPPORTED, ITEM_NOT_OWNED -> {
errorCode = ErrorCode.BillingError
}
ITEM_ALREADY_OWNED -> {
errorCode = ErrorCode.SubscriptionAlreadyOwned
}
ITEM_UNAVAILABLE -> {
errorCode = ErrorCode.SubscriptionUnavailable
}
NETWORK_ERROR -> {
errorCode = ErrorCode.BillingNetworkError
}
else -> {
errorCode = ErrorCode.BillingError
}
}
}
}
@@ -0,0 +1,349 @@
import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.BillingResponseCode
import com.android.billingclient.api.BillingClient.ProductType
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.GetBillingConfigParams
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryProductDetailsParams.Product
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
import org.amnezia.vpn.util.ErrorCode
import org.amnezia.vpn.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.text.NumberFormat
import java.util.Currency
private const val TAG = "BillingProvider"
private const val PRODUCT_ID = "premium"
private val billingPeriodRegex = Regex("""P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?""")
private fun billingPeriodToMonths(period: String): Double {
val match = billingPeriodRegex.matchEntire(period) ?: return 0.0
val years = match.groupValues[1].toDoubleOrNull() ?: 0.0
val months = match.groupValues[2].toDoubleOrNull() ?: 0.0
val weeks = match.groupValues[3].toDoubleOrNull() ?: 0.0
val days = match.groupValues[4].toDoubleOrNull() ?: 0.0
return years * 12.0 + months + weeks * 7.0 / 30.0 + days / 30.0
}
private fun billingPeriodToDays(period: String): Int {
val match = billingPeriodRegex.matchEntire(period) ?: return 0
val years = match.groupValues[1].toIntOrNull() ?: 0
val months = match.groupValues[2].toIntOrNull() ?: 0
val weeks = match.groupValues[3].toIntOrNull() ?: 0
val days = match.groupValues[4].toIntOrNull() ?: 0
return years * 365 + months * 30 + weeks * 7 + days
}
private fun displayPricePerMonth(priceAmountMicros: Long, currencyCode: String, billingMonths: Double): String? {
if (billingMonths <= 1e-6 || currencyCode.isBlank()) return null
return try {
val amountPerMonth = (priceAmountMicros / 1_000_000.0) / billingMonths
NumberFormat.getCurrencyInstance().apply { currency = Currency.getInstance(currencyCode) }
.format(amountPerMonth)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Unknown currency code from Play Billing: $currencyCode")
null
}
}
class BillingProvider(context: Context) : AutoCloseable {
private var billingClient: BillingClient
private var subscriptionPurchases = MutableStateFlow<Pair<BillingResult, List<Purchase>?>?>(null)
private val purchasesUpdatedListeners = PurchasesUpdatedListener { billingResult, purchases ->
subscriptionPurchases.value = billingResult to purchases
}
init {
billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListeners)
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
.build()
}
private suspend fun connect() {
if (billingClient.isReady) return
val connection = CompletableDeferred<Unit>()
withContext(Dispatchers.IO) {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.isOk) {
connection.complete(Unit)
} else {
Log.e(TAG, "Billing setup failed: $billingResult")
connection.completeExceptionally(BillingException(billingResult))
}
}
override fun onBillingServiceDisconnected() {
Log.w(TAG, "Billing service disconnected")
}
})
}
connection.await()
}
private suspend fun handleBillingApiCall(block: suspend () -> JSONObject): JSONObject {
val numberAttempts = 3
var attemptCount = 0
while (true) {
try {
return block()
} catch (e: BillingException) {
if (e.isCanceled) {
Log.w(TAG, "Billing canceled")
return JSONObject().put("responseCode", ErrorCode.BillingCanceled)
} else if (e.isRetryable && attemptCount < numberAttempts) {
Log.d(TAG, "Retryable error: $e")
++attemptCount
delay(1000)
} else {
Log.e(TAG, "Billing error: $e")
return JSONObject().put("responseCode", e.errorCode)
}
} catch (_: CancellationException) {
Log.w(TAG, "Billing coroutine canceled")
return JSONObject().put("responseCode", ErrorCode.BillingCanceled)
}
}
}
suspend fun getSubscriptionPlans(): JSONObject {
val productDetailsList = getProductDetails()
val resultJson = JSONObject().put("responseCode", ErrorCode.NoError)
val productArray = JSONArray().also { resultJson.put("products", it) }
productDetailsList?.forEach { productDetails ->
val product = JSONObject().also { productArray.put(it) }
.put("productId", productDetails.productId)
.put("name", productDetails.name)
val offers = JSONArray().also { product.put("offers", it) }
productDetails.subscriptionOfferDetails?.forEach { offerDetails ->
val offer = JSONObject().also { offers.put(it) }
.put("basePlanId", offerDetails.basePlanId)
.put("offerId", offerDetails.offerId)
.put("offerToken", offerDetails.offerToken)
val pricingPhases = JSONArray().also { offer.put("pricingPhases", it) }
offerDetails.pricingPhases.pricingPhaseList.forEach { phase ->
val billingMonths = billingPeriodToMonths(phase.billingPeriod)
JSONObject().also { pricingPhases.put(it) }
.put("billingCycleCount", phase.billingCycleCount)
.put("billingPeriod", phase.billingPeriod)
.put("formattedPrice", phase.formattedPrice)
.put("priceAmountMicros", phase.priceAmountMicros)
.put("recurrenceMode", phase.recurrenceMode)
.put("subscriptionBillingMonths", billingMonths)
.apply {
displayPricePerMonth(phase.priceAmountMicros, phase.priceCurrencyCode, billingMonths)?.let {
put("displayPricePerMonth", it)
}
}
}
val trialPhase = offerDetails.pricingPhases.pricingPhaseList.firstOrNull()
val hasFreeTrial = trialPhase != null && trialPhase.priceAmountMicros == 0L
offer.put("hasFreeTrial", hasFreeTrial)
if (hasFreeTrial) {
offer.put("trialDays", billingPeriodToDays(trialPhase!!.billingPeriod))
}
}
}
return resultJson
}
private suspend fun getProductDetails(): List<ProductDetails>? {
val productDetailsParams = Product.newBuilder()
.setProductId(PRODUCT_ID)
.setProductType(ProductType.SUBS)
.build()
val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
.setProductList(listOf(productDetailsParams))
.build()
val result = withContext(Dispatchers.IO) {
billingClient.queryProductDetails(queryProductDetailsParams)
}
if (!result.billingResult.isOk) {
Log.e(TAG, "Failed to get product details: ${result.billingResult}")
throw BillingException(result.billingResult)
}
return result.productDetailsList
}
suspend fun getCustomerCountryCode(): JSONObject {
val deferred = CompletableDeferred<String>()
withContext(Dispatchers.IO) {
billingClient.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build(),
{ billingResult, billingConfig ->
Log.v(TAG, "Billing config: $billingResult, ${billingConfig?.countryCode}")
if (billingResult.isOk) {
deferred.complete(billingConfig?.countryCode ?: "")
} else {
deferred.completeExceptionally(BillingException(billingResult))
}
})
}
val countryCode = deferred.await()
return JSONObject()
.put("responseCode", ErrorCode.NoError)
.put("countryCode", countryCode)
}
suspend fun purchaseSubscription(
activity: Activity,
offerToken: String,
oldPurchaseToken: String? = null
): JSONObject {
if (offerToken.isBlank()) throw BillingException("offerToken can not be empty")
subscriptionPurchases.value = null
val productDetails = getProductDetails()?.firstOrNull { it.productId == PRODUCT_ID }
?: throw BillingException("Product details not found")
val productDetail = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val subscriptionUpdateParams = oldPurchaseToken?.let {
// Per Google Play docs, switching between auto-renewing plans within the same
// subscription only supports CHARGE_FULL_PRICE or WITHOUT_PRORATION - any other mode
// (e.g. CHARGE_PRORATED_PRICE) is rejected with DEVELOPER_ERROR "Requested replacement
// mode is not supported for this request".
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(ReplacementMode.CHARGE_FULL_PRICE)
.build()
}
val billingResult = withContext(Dispatchers.Main.immediate) {
billingClient.launchBillingFlow(activity, BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetail))
.apply { subscriptionUpdateParams?.let { setSubscriptionUpdateParams(it) } }
.build())
}
if (billingResult.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED) {
Log.w(TAG, "Attempting to purchase already owned product")
val purchases = queryPurchases()
if (purchases.any { PRODUCT_ID in it.products }) throw BillingException(billingResult)
else throw BillingException(billingResult, retryable = true)
} else if (billingResult.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
Log.w(TAG, "Attempting to replace not owned product")
val purchases = queryPurchases()
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(billingResult)
else throw BillingException(billingResult, retryable = true)
} else if (!billingResult.isOk) throw BillingException(billingResult)
subscriptionPurchases.firstOrNull { it != null }?.let { (billingResult, purchases) ->
if (!billingResult.isOk) throw BillingException(billingResult, retryable = false)
return JSONObject()
.put("responseCode", ErrorCode.NoError)
.put("purchases", processPurchases(purchases))
} ?: throw BillingException("Purchase failed")
}
private fun processPurchases(purchases: List<Purchase>?): JSONArray {
val purchaseArray = JSONArray()
purchases?.forEach { purchase ->
Log.v(TAG, "processPurchases: orderId=${purchase.orderId} state=${purchase.purchaseState} products=${purchase.products}")
JSONObject().also { purchaseArray.put(it) }
.put("purchaseToken", purchase.purchaseToken)
.put("purchaseTime", purchase.purchaseTime)
.put("purchaseState", purchase.purchaseState)
.put("isAcknowledged", purchase.isAcknowledged)
.put("isAutoRenewing", purchase.isAutoRenewing)
.put("orderId", purchase.orderId)
.put("productIds", JSONArray(purchase.products))
}
return purchaseArray
}
suspend fun acknowledge(purchaseToken: String): JSONObject {
Log.v(TAG, "Acknowledge purchase")
val result = withContext(Dispatchers.IO) {
billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
)
}
Log.v(TAG, "Acknowledge purchase result: $result")
if (result.responseCode == BillingResponseCode.ITEM_NOT_OWNED) {
Log.w(TAG, "Attempting to acknowledge not owned product")
val purchases = queryPurchases()
if (purchases.all { PRODUCT_ID !in it.products }) throw BillingException(result)
else throw BillingException(result, retryable = true)
} else if (!result.isOk && result.responseCode != BillingResponseCode.ITEM_ALREADY_OWNED) {
throw BillingException(result)
}
return JSONObject().put("responseCode", ErrorCode.NoError)
}
suspend fun getPurchases(): JSONObject {
val purchases = queryPurchases()
return JSONObject()
.put("responseCode", ErrorCode.NoError)
.put("purchases", processPurchases(purchases))
}
private suspend fun queryPurchases(): List<Purchase> {
val result = withContext(Dispatchers.IO) {
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build()
)
}
Log.v(TAG, "Query purchases result: ${result.billingResult}")
if (!result.billingResult.isOk) throw BillingException(result.billingResult)
return result.purchasesList
}
override fun close() {
billingClient.endConnection()
}
companion object {
suspend fun withBillingProvider(context: Context, block: suspend BillingProvider.() -> JSONObject): String =
BillingProvider(context).use { bp ->
bp.handleBillingApiCall {
bp.connect()
bp.block()
}.toString()
}
}
}
internal val BillingResult.isOk: Boolean
get() = responseCode == BillingResponseCode.OK
+117 -6
View File
@@ -20,6 +20,7 @@ android {
namespace = "org.amnezia.vpn"
buildFeatures {
buildConfig = true
viewBinding = true
}
@@ -33,13 +34,56 @@ android {
jniLibs.useLegacyPackaging = true
}
val abiList = qtTargetAbiList.split(",")
defaultConfig {
applicationId = "org.amnezia.vpn"
targetSdk = qtTargetSdkVersion.toInt()
// keeps language resources for only the locales specified below
resourceConfigurations += listOf("en", "ru", "b+zh+Hans")
ndk.abiFilters += qtTargetAbiList.split(",")
// ndk.abiFilters is only used for single-ABI builds; multi-ABI uses splits below
if (abiList.size == 1) {
ndk.abiFilters += abiList
}
}
signingConfigs {
register("release") {
storeFile = providers.environmentVariable("QT_ANDROID_KEYSTORE_PATH").orNull?.let { file(it) }
storePassword = providers.environmentVariable("QT_ANDROID_KEYSTORE_STORE_PASS").orNull
keyAlias = providers.environmentVariable("QT_ANDROID_KEYSTORE_ALIAS").orNull
keyPassword = providers.environmentVariable("QT_ANDROID_KEYSTORE_STORE_PASS").orNull
}
}
buildTypes {
release {
// exclude coroutine debug resource from release build
packaging {
resources.excludes += "DebugProbesKt.bin"
}
signingConfig = signingConfigs["release"]
}
create("fdroid") {
initWith(getByName("release"))
signingConfig = null
matchingFallbacks += "release"
}
}
flavorDimensions += "billing"
productFlavors {
create("oss") {
dimension = "billing"
buildConfigField("boolean", "IS_PLAY_BUILD", "false")
}
create("play") {
dimension = "billing"
buildConfigField("boolean", "IS_PLAY_BUILD", "true")
}
}
sourceSets {
@@ -51,13 +95,75 @@ android {
assets.setSrcDirs(listOf("assets"))
jniLibs.setSrcDirs(listOf("libs"))
}
getByName("oss") {
java.setSrcDirs(listOf("oss"))
}
getByName("play") {
java.setSrcDirs(listOf("play"))
}
}
buildTypes {
release {
// exclude coroutine debug resource from release build
packaging {
resources.excludes += "DebugProbesKt.bin"
splits {
abi {
// splits only make sense for multi-ABI builds; single-ABI uses ndk.abiFilters
isEnable = abiList.size > 1
reset()
include(*abiList.toTypedArray())
isUniversalApk = false
}
}
// fix for Qt Creator to allow deploying the application to a device
// to enable this fix, add the line outputBaseName=android-build to local.properties
if (outputBaseName.isNotEmpty()) {
applicationVariants.all {
outputs.map { it as BaseVariantOutputImpl }
.forEach { output ->
if (output.outputFileName.endsWith(".apk")) {
output.outputFileName = "$outputBaseName-${buildType.name}.apk"
}
}
}
}
// androiddeployqt expects:
// APK: build/outputs/apk/{base}-{buildType}[-unsigned].apk (no flavor subdir)
// AAB: build/outputs/bundle/{buildType}/{base}-{buildType}.aab (no flavor subdir)
// where {base} = outputBaseName (set by Qt Creator) or "android-build" (CI fallback).
// Release APK gets -unsigned suffix (Qt cmake signs it); debug does not.
// Copy only oss flavor to the flat output dir that androiddeployqt/Qt Creator expect.
// Play flavor is built via android_play_apk/android_play_aab cmake targets and uses
// its native Gradle output paths directly.
applicationVariants.all {
val flavorName = productFlavors.firstOrNull()?.name ?: ""
val buildTypeName = buildType.name
if (flavorName == "oss") {
val base = outputBaseName.ifEmpty { "android-build" }
val unsignedSuffix = if (buildTypeName == "release") "-unsigned" else ""
packageApplicationProvider.configure {
doLast {
val srcDir = layout.buildDirectory.dir("outputs/apk/oss/$buildTypeName").get().asFile
val dstDir = layout.buildDirectory.dir("outputs/apk").get().asFile
dstDir.mkdirs()
srcDir.listFiles()?.filter { it.name.endsWith(".apk") }?.forEach { apk ->
apk.copyTo(File(dstDir, "$base-$buildTypeName$unsignedSuffix.apk"), overwrite = true)
}
}
}
val variantName = name
tasks.named("bundle${variantName.replaceFirstChar { it.uppercase() }}") {
doLast {
val srcDir = layout.buildDirectory.dir("outputs/bundle/$variantName").get().asFile
val dstDir = layout.buildDirectory.dir("outputs/bundle/$buildTypeName").get().asFile
dstDir.mkdirs()
srcDir.listFiles()?.filter { it.name.endsWith(".aab") }?.forEach { aab ->
aab.copyTo(File(dstDir, "$base-$buildTypeName.aab"), overwrite = true)
}
}
}
}
}
@@ -84,4 +190,9 @@ dependencies {
implementation(libs.google.mlkit)
implementation(libs.androidx.datastore)
implementation(libs.androidx.biometric)
playImplementation(project(":billing"))
}
fun DependencyHandler.playImplementation(dependency: Any): Dependency? =
add("playImplementation", dependency)
+3 -1
View File
@@ -1,6 +1,7 @@
[versions]
agp = "8.6.1"
kotlin = "1.9.24"
kotlin = "2.1.0"
android-billing = "8.0.0"
androidx-core = "1.13.1"
androidx-activity = "1.9.1"
androidx-annotation = "1.8.2"
@@ -14,6 +15,7 @@ kotlinx-serialization = "1.6.3"
google-mlkit = "17.3.0"
[libraries]
android-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "android-billing" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
@@ -0,0 +1,13 @@
package org.amnezia.vpn
import android.app.Activity
import android.content.Context
class BillingPaymentRepository(@Suppress("UNUSED_PARAMETER") context: Context) : BillingRepository {
override suspend fun getCountryCode(): String = ""
override suspend fun getSubscriptionPlans(): String = ""
override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String = ""
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String = ""
override suspend fun acknowledge(purchaseToken: String): String = ""
override suspend fun queryPurchases(): String = ""
}
@@ -0,0 +1,34 @@
package org.amnezia.vpn
import android.app.Activity
import android.content.Context
import BillingProvider.Companion.withBillingProvider
class BillingPaymentRepository(private val context: Context) : BillingRepository {
override suspend fun getCountryCode(): String = withBillingProvider(context) {
getCustomerCountryCode()
}
override suspend fun getSubscriptionPlans(): String = withBillingProvider(context) {
getSubscriptionPlans()
}
override suspend fun purchaseSubscription(activity: Activity, offerToken: String): String =
withBillingProvider(context) {
purchaseSubscription(activity, offerToken)
}
override suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String =
withBillingProvider(context) {
purchaseSubscription(activity, offerToken, oldPurchaseToken)
}
override suspend fun acknowledge(purchaseToken: String): String = withBillingProvider(context) {
acknowledge(purchaseToken)
}
override suspend fun queryPurchases(): String = withBillingProvider(context) {
getPurchases()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF0E0E11</color>
<color name="black">#FF101012</color>
<style name="NoActionBar">
<item name="android:windowBackground">@color/black</item>
<item name="android:colorBackground">@color/black</item>
+1
View File
@@ -30,6 +30,7 @@ rootProject.buildFileName = "build.gradle.kts"
include(":qt")
include(":utils")
include(":billing")
include(":protocolApi")
include(":wireguard")
include(":awg")
@@ -37,6 +37,7 @@ import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
@@ -44,6 +45,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import java.io.IOException
import java.io.OutputStream
import kotlin.LazyThreadSafetyMode.NONE
import kotlin.coroutines.CoroutineContext
import kotlin.text.RegexOption.IGNORE_CASE
@@ -56,7 +58,6 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.amnezia.vpn.protocol.getStatistics
import org.amnezia.vpn.protocol.getStatus
import org.amnezia.vpn.qt.QtAndroidController
@@ -78,6 +79,7 @@ private const val CHECK_NOTIFICATION_PERMISSION_ACTION_CODE = 4
private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION_ASKED"
private const val OPEN_FILE_AFTER_RESUME_DELAY_MS = 400L
private const val KEY_PENDING_OPEN_FILE_URI = "pending_open_file_uri"
private const val FILE_PROVIDER_AUTHORITY = "org.amnezia.vpn.qtprovider"
class AmneziaActivity : QtActivity() {
@@ -90,6 +92,7 @@ class AmneziaActivity : QtActivity() {
private var notificationStateReceiver: BroadcastReceiver? = null
private lateinit var vpnServiceMessenger: IpcMessenger
private var pfd: ParcelFileDescriptor? = null
private lateinit var billingRepository: BillingRepository
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
@@ -100,6 +103,7 @@ class AmneziaActivity : QtActivity() {
private var pendingOpenFileUri: String? = null
private var openFileDeliveryScheduled = false
private val vpnServiceEventHandler: Handler by lazy(NONE) {
object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
@@ -206,6 +210,7 @@ class AmneziaActivity : QtActivity() {
registerBroadcastReceivers()
intent?.let(::processIntent)
runBlocking { vpnProto = proto.await() }
billingRepository = BillingPaymentRepository(applicationContext)
}
override fun onSaveInstanceState(outState: Bundle) {
@@ -731,28 +736,69 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused")
fun saveFile(fileName: String, data: String) {
Log.d(TAG, "Save file $fileName")
createFile(fileName, "text/*") { os -> os.bufferedWriter().use { it.write(data) } }
}
@Suppress("unused")
fun saveFileBytes(fileName: String, data: ByteArray, mime: String) {
Log.d(TAG, "Save file $fileName, ${data.size} bytes, $mime")
createFile(fileName, mime) { os -> os.write(data) }
}
private fun createFile(fileName: String, mime: String, write: (OutputStream) -> Unit) {
mainScope.launch {
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/*"
type = mime
putExtra(Intent.EXTRA_TITLE, fileName)
}.also {
try {
startActivityForResult(it, CREATE_FILE_ACTION_CODE, ActivityResultHandler(
onSuccess = {
it?.data?.let { uri ->
val uri = it?.data
var saved = false
if (uri != null) {
Log.v(TAG, "Save file to $uri")
try {
contentResolver.openOutputStream(uri)?.use { os ->
os.bufferedWriter().use { it.write(data) }
write(os)
saved = true
}
} catch (e: IOException) {
Log.e(TAG, "Failed to save file $uri: $e")
// todo: send error to Qt
}
}
QtAndroidController.onFileSaved(saved, false)
},
onFail = {
QtAndroidController.onFileSaved(false, true)
}
))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this@AmneziaActivity, "Unsupported", Toast.LENGTH_LONG).show()
QtAndroidController.onFileSaved(false, false)
}
}
}
}
@Suppress("unused")
fun shareFile(path: String, mime: String) {
Log.d(TAG, "Share file $path, $mime")
mainScope.launch {
val uri = try {
FileProvider.getUriForFile(this@AmneziaActivity, FILE_PROVIDER_AUTHORITY, File(path))
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Failed to share file $path: $e")
return@launch
}
Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}.also {
try {
startActivity(Intent.createChooser(it, null))
} catch (_: ActivityNotFoundException) {
Toast.makeText(this@AmneziaActivity, "Unsupported", Toast.LENGTH_LONG).show()
}
@@ -797,35 +843,47 @@ class AmneziaActivity : QtActivity() {
}
}
`package` = systemPickerPackage
if (resolveActivity(packageManager) == null) {
`package` = null
}
}
} else {
Intent(this@AmneziaActivity, TvFilePicker::class.java)
}
try {
startActivityForResult(intent, OPEN_FILE_ACTION_CODE, ActivityResultHandler(
onAny = {
if (isOnTv() && it?.hasExtra("activityNotFound") == true) {
showNoFileBrowserAlertDialog()
val resultHandler = ActivityResultHandler(
onAny = {
if (isOnTv() && it?.hasExtra("activityNotFound") == true) {
showNoFileBrowserAlertDialog()
}
val uri = it?.data?.let { u ->
if (u.scheme == "content") {
try { grantUriPermission(packageName, u, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {}
}
val uri = it?.data?.let { u ->
if (u.scheme == "content") {
try { grantUriPermission(packageName, u, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {}
}
u
}?.toString() ?: ""
Log.v(TAG, "Open file: $uri")
if (uri.isNotEmpty()) {
pendingOpenFileUri = uri
} else {
mainScope.launch {
qtInitialized.await()
QtAndroidController.onFileOpened(uri)
}
u
}?.toString() ?: ""
Log.v(TAG, "Open file: $uri")
if (uri.isNotEmpty()) {
pendingOpenFileUri = uri
} else {
mainScope.launch {
qtInitialized.await()
QtAndroidController.onFileOpened(uri)
}
}
))
}
)
try {
startActivityForResult(intent, OPEN_FILE_ACTION_CODE, resultHandler)
} catch (_: ActivityNotFoundException) {
if (intent.`package` != null) {
intent.`package` = null
try {
startActivityForResult(intent, OPEN_FILE_ACTION_CODE, resultHandler)
return@launch
} catch (_: ActivityNotFoundException) {}
}
showNoFileBrowserAlertDialog()
mainScope.launch {
qtInitialized.await()
@@ -957,20 +1015,6 @@ class AmneziaActivity : QtActivity() {
}
}
@Suppress("unused")
fun exportLogsFile(fileName: String) {
Log.v(TAG, "Export logs file")
saveFile(fileName, Log.getLogs())
}
@Suppress("unused")
fun clearLogs() {
Log.v(TAG, "Clear logs")
mainScope.launch {
Log.clearLogs()
}
}
@Suppress("unused")
fun setScreenshotsEnabled(enabled: Boolean) {
Log.v(TAG, "Set screenshots enabled: $enabled")
@@ -999,15 +1043,9 @@ class AmneziaActivity : QtActivity() {
@Suppress("unused")
fun getAppList(): String {
Log.v(TAG, "Get app list")
var appList = ""
runBlocking {
mainScope.launch {
withContext(Dispatchers.IO) {
appList = AppListProvider.getAppList(packageManager, packageName)
}
}.join()
return blockingCall(Dispatchers.IO) {
AppListProvider.getAppList(packageManager, packageName)
}
return appList
}
@Suppress("unused")
@@ -1176,11 +1214,48 @@ class AmneziaActivity : QtActivity() {
return super.dispatchTrackballEvent(ev)
}
@Suppress("unused")
fun isPlay(): Boolean = BuildConfig.FLAVOR == "play"
@Suppress("unused")
fun getCountryCode(): String {
return blockingCall { billingRepository.getCountryCode() }
}
@Suppress("unused")
fun getSubscriptionPlans(): String {
return blockingCall { billingRepository.getSubscriptionPlans() }
}
@Suppress("unused")
fun purchaseSubscription(offerToken: String): String {
return blockingCall { billingRepository.purchaseSubscription(this@AmneziaActivity, offerToken) }
}
@Suppress("unused")
fun upgradeSubscription(offerToken: String, oldPurchaseToken: String): String {
Log.v(TAG, "Upgrade subscription")
return blockingCall {
billingRepository.upgradeSubscription(this@AmneziaActivity, offerToken, oldPurchaseToken)
}
}
@Suppress("unused")
fun acknowledgePurchase(purchaseToken: String): String {
Log.v(TAG, "Acknowledge purchase")
return blockingCall { billingRepository.acknowledge(purchaseToken) }
}
@Suppress("unused")
fun queryPurchases(): String {
return blockingCall { billingRepository.queryPurchases() }
}
/**
* Utils methods
*/
private fun <T> blockingCall(
context: CoroutineContext = Dispatchers.Main.immediate,
context: CoroutineContext = Dispatchers.Default,
block: suspend () -> T
) = runBlocking {
mainScope.async(context) { block() }.await()
@@ -1,5 +1,6 @@
package org.amnezia.vpn
import android.system.Os
import androidx.camera.camera2.Camera2Config
import androidx.camera.core.CameraSelector
import androidx.camera.core.CameraXConfig
@@ -12,6 +13,9 @@ private const val TAG = "AmneziaApplication"
class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
override fun onCreate() {
if (BuildConfig.DEBUG) {
Os.setenv("QT_ANDROID_DEBUGGER_MAIN_THREAD_SLEEP_MS", "0", true)
}
super.onCreate()
Prefs.init(this)
Log.init(this)
@@ -0,0 +1,12 @@
package org.amnezia.vpn
import android.app.Activity
interface BillingRepository {
suspend fun getCountryCode(): String
suspend fun getSubscriptionPlans(): String
suspend fun purchaseSubscription(activity: Activity, offerToken: String): String
suspend fun upgradeSubscription(activity: Activity, offerToken: String, oldPurchaseToken: String): String
suspend fun acknowledge(purchaseToken: String): String
suspend fun queryPurchases(): String
}
@@ -22,6 +22,7 @@ object QtAndroidController {
external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long)
external fun onFileOpened(uri: String)
external fun onFileSaved(success: Boolean, cancelled: Boolean)
external fun onConfigImported(data: String)
@@ -0,0 +1,14 @@
package org.amnezia.vpn.util
// keep synchronized with client/core/utils/errorCodes.h amnezia::ErrorCode
object ErrorCode {
const val NoError = 0
const val BillingCanceled = 1300
const val BillingError = 1301
const val BillingGooglePlayError = 1302
const val BillingUnavailable = 1303
const val SubscriptionAlreadyOwned = 1304
const val SubscriptionUnavailable = 1305
const val BillingNetworkError = 1306
}
+138 -48
View File
@@ -1,5 +1,6 @@
package org.amnezia.vpn.util
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.Process
@@ -22,9 +23,18 @@ import org.amnezia.vpn.util.Log.Priority.W
import android.util.Log as NativeLog
private const val TAG = "Log"
private const val LOG_FILE_NAME = "amneziaVPN.log"
private const val ROTATE_LOG_FILE_NAME = "amneziaVPN.rotate.log"
private const val APP_LOG_FILE_NAME = "app.log"
private const val APP_ROTATE_LOG_FILE_NAME = "app.rotate.log"
private const val TUNNEL_LOG_FILE_NAME = "tunnel.log"
private const val TUNNEL_ROTATE_LOG_FILE_NAME = "tunnel.rotate.log"
private const val LEGACY_LOG_FILE_NAME = "amneziaVPN.log"
private const val LEGACY_ROTATE_LOG_FILE_NAME = "amneziaVPN.rotate.log"
private const val LOCK_FILE_NAME = ".lock"
private const val TILE_SERVICE_PROCESS_SUFFIX = ":amneziaTileService"
private const val AWG_SERVICE_PROCESS_SUFFIX = ":amneziaAwgService"
private const val AWG_LOGCAT_TAG_PREFIX = " AmneziaWG/"
private const val STREAM_APP = 0
private const val STREAM_TUNNEL = 1
private const val DATE_TIME_PATTERN = "MM-dd HH:mm:ss.SSS"
private const val PREFS_SAVE_LOGS_KEY = "SAVE_LOGS"
private const val LOG_MAX_FILE_SIZE = 1024 * 1024
@@ -42,12 +52,16 @@ object Log {
private val dateTimeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)
private lateinit var logDir: File
private val logFile: File by lazy { File(logDir, LOG_FILE_NAME) }
private val rotateLogFile: File by lazy { File(logDir, ROTATE_LOG_FILE_NAME) }
private lateinit var logFile: File
private lateinit var rotateLogFile: File
private val fileLock: FileChannel by lazy { RandomAccessFile(File(logDir, LOCK_FILE_NAME).path, "rw").channel }
private val threadLock: ReentrantLock by lazy { ReentrantLock() }
private var isAwgProcess: Boolean = false
private val awgLogcatLock = Any()
private var awgLogcatReader: AwgLogcatReader? = null
@Volatile
private var _saveLogs: Boolean = false
var saveLogs: Boolean
@@ -60,6 +74,9 @@ object Log {
}
_saveLogs = value
Prefs.save(PREFS_SAVE_LOGS_KEY, value)
if (isAwgProcess) {
if (value) startAwgLogcatReader() else stopAwgLogcatReader()
}
}
}
@@ -96,21 +113,60 @@ object Log {
fun init(context: Context) {
v(TAG, "Init Log")
logDir = File(context.cacheDir, "logs")
val processName = Application.getProcessName()
val stream = if (':' in processName && !processName.endsWith(TILE_SERVICE_PROCESS_SUFFIX)) {
STREAM_TUNNEL
} else {
STREAM_APP
}
val (rotate, current) = streamFiles(stream)
rotateLogFile = rotate
logFile = current
isAwgProcess = processName.endsWith(AWG_SERVICE_PROCESS_SUFFIX)
saveLogs = Prefs.load(PREFS_SAVE_LOGS_KEY)
}
fun getLogs(): String =
"${deviceInfo()}\n${readLogs()}\nLOGCAT:\n${getLogcat()}"
@JvmStatic
fun getLogFiles(stream: Int): String {
val files = streamFiles(stream).toMutableList()
if (stream == STREAM_APP) files.addAll(0, legacyFiles().filter { it.exists() })
return files.joinToString("\n") { it.absolutePath }
}
fun clearLogs() {
@JvmStatic
fun getDeviceInfo(): String {
val sb = StringBuilder()
sb.append("Model: ").appendLine(Build.MODEL)
sb.append("Brand: ").appendLine(Build.BRAND)
sb.append("Product: ").appendLine(Build.PRODUCT)
sb.append("Device: ").appendLine(Build.DEVICE)
sb.append("Codename: ").appendLine(Build.VERSION.CODENAME)
sb.append("Release: ").appendLine(Build.VERSION.RELEASE)
sb.append("SDK: ").appendLine(Build.VERSION.SDK_INT)
sb.append("ABI: ").appendLine(Build.SUPPORTED_ABIS.joinToString())
return sb.toString()
}
@JvmStatic
fun clearStream(stream: Int) {
if (logDir.exists()) {
withLock {
logFile.delete()
rotateLogFile.delete()
streamFiles(stream).forEach { it.delete() }
if (stream == STREAM_APP) legacyFiles().forEach { it.delete() }
}
}
}
private fun streamFiles(stream: Int): List<File> =
if (stream == STREAM_TUNNEL) {
listOf(File(logDir, TUNNEL_ROTATE_LOG_FILE_NAME), File(logDir, TUNNEL_LOG_FILE_NAME))
} else {
listOf(File(logDir, APP_ROTATE_LOG_FILE_NAME), File(logDir, APP_LOG_FILE_NAME))
}
private fun legacyFiles(): List<File> =
listOf(File(logDir, LEGACY_ROTATE_LOG_FILE_NAME), File(logDir, LEGACY_LOG_FILE_NAME))
private fun log(tag: String, msg: String, priority: Priority) {
if (saveLogs && priority != V) saveLogMsg(formatLogMsg(tag, msg, priority))
@@ -136,51 +192,85 @@ object Log {
}
}
private fun startAwgLogcatReader() {
synchronized(awgLogcatLock) {
if (awgLogcatReader == null) {
awgLogcatReader = AwgLogcatReader().also { it.start() }
}
}
}
private fun stopAwgLogcatReader() {
synchronized(awgLogcatLock) {
awgLogcatReader?.shutdown()
awgLogcatReader = null
}
}
private class AwgLogcatReader : Thread("AwgLogcatReader") {
private val lock = Any()
private var process: java.lang.Process? = null
@Volatile
private var stopped = false
init {
isDaemon = true
}
override fun run() {
try {
follow()
} finally {
synchronized(awgLogcatLock) {
if (awgLogcatReader === this) awgLogcatReader = null
}
}
}
private fun follow() {
val logcat = try {
ProcessBuilder(
"logcat", "-v", "threadtime", "-v", "UTC", "--pid=${Process.myPid()}", "-T", "1"
).redirectErrorStream(true).start()
} catch (e: IOException) {
NativeLog.e(TAG, "Failed to start logcat: $e")
return
}
synchronized(lock) {
if (stopped) {
logcat.destroy()
return
}
process = logcat
}
try {
logcat.inputStream.bufferedReader().useLines { lines ->
lines.filter { it.contains(AWG_LOGCAT_TAG_PREFIX) }.forEach {
if (!stopped) saveLogMsg("$it\n")
}
}
if (!stopped) NativeLog.w(TAG, "Logcat exited unexpectedly")
} catch (e: IOException) {
if (!stopped) NativeLog.e(TAG, "Failed to read logcat: $e")
} finally {
logcat.destroy()
}
}
fun shutdown() {
synchronized(lock) {
stopped = true
process?.destroy()
}
}
}
private fun formatLogMsg(tag: String, msg: String, priority: Priority): String {
val utcDate = ZonedDateTime.now(ZoneOffset.UTC).format(dateTimeFormat)
return "${utcDate}Z ${Process.myPid()} ${Process.myTid()} $priority [${Thread.currentThread().name}] " +
"$tag: $msg\n"
}
private fun deviceInfo(): String {
val sb = StringBuilder()
sb.append("Model: ").appendLine(Build.MODEL)
sb.append("Brand: ").appendLine(Build.BRAND)
sb.append("Product: ").appendLine(Build.PRODUCT)
sb.append("Device: ").appendLine(Build.DEVICE)
sb.append("Codename: ").appendLine(Build.VERSION.CODENAME)
sb.append("Release: ").appendLine(Build.VERSION.RELEASE)
sb.append("SDK: ").appendLine(Build.VERSION.SDK_INT)
sb.append("ABI: ").appendLine(Build.SUPPORTED_ABIS.joinToString())
return sb.toString()
}
private fun readLogs(): String {
var logText = ""
withLock {
try {
if (rotateLogFile.exists()) logText = rotateLogFile.readText()
if (logFile.exists()) logText += logFile.readText()
} catch (e: IOException) {
val errorMsg = "Failed to read log: $e"
NativeLog.e(TAG, errorMsg)
logText += errorMsg
}
}
return logText
}
private fun getLogcat(): String {
try {
val process = ProcessBuilder("logcat", "-d").redirectErrorStream(true).start()
return process.inputStream.reader().readText()
} catch (e: IOException) {
val errorMsg = "Failed to get logcat log: $e"
NativeLog.e(TAG, errorMsg)
return errorMsg
}
}
private fun withLock(block: () -> Unit) {
threadLock.lock()
try {
@@ -111,7 +111,7 @@ open class Wireguard : Protocol() {
configExtensionParameters(configData)
}
configData.optStringOrNull("persistent_keep_alive")?.let { setPersistentKeepalive(it.toInt()) }
configData.optStringOrNull("persistent_keep_alive")?.let { setPersistentKeepalive(it) }
configData.getString("client_priv_key").let { setPrivateKeyHex(it.base64ToHex()) }
configData.getString("server_pub_key").let { setPublicKeyHex(it.base64ToHex()) }
configData.optStringOrNull("psk_key")?.let { setPreSharedKeyHex(it.base64ToHex()) }
@@ -134,6 +134,24 @@ open class Wireguard : Protocol() {
configData.optStringOrNull("I3")?.let { setI3(it) }
configData.optStringOrNull("I4")?.let { setI4(it) }
configData.optStringOrNull("I5")?.let { setI5(it) }
configData.optStringOrNull("HeaderProtectionKey")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setHeaderProtectionKey(it.base64ToHex()) }
configData.optStringOrNull("ContentPaddingAddition")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setContentPaddingAddition(it) }
configData.optStringOrNull("RekeyAfterTime")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setRekeyAfterTime(it) }
configData.optStringOrNull("RekeyTimeout")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setRekeyTimeout(it) }
configData.optStringOrNull("RejectAfterTime")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setRejectAfterTime(it) }
configData.optStringOrNull("KeepaliveTimeout")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setKeepaliveTimeout(it) }
configData.optStringOrNull("MaxHandshakeAttempts")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setMaxHandshakeAttempts(it) }
configData.optStringOrNull("RandomTrailers")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setRandomTrailers(it) }
configData.optStringOrNull("DisableCookies")?.trim()?.takeIf { it.isNotEmpty() }
?.let { setDisableCookies(it) }
}
private fun start(
@@ -10,7 +10,7 @@ private const val WIREGUARD_DEFAULT_MTU = 1280
open class WireguardConfig protected constructor(
protocolConfigBuilder: ProtocolConfig.Builder,
val endpoint: InetEndpoint,
val persistentKeepalive: Int,
val persistentKeepalive: String?,
val publicKeyHex: String,
val preSharedKeyHex: String?,
val privateKeyHex: String,
@@ -31,6 +31,15 @@ open class WireguardConfig protected constructor(
var i3: String?,
var i4: String?,
var i5: String?,
val headerProtectionKeyHex: String?,
val contentPaddingAddition: String?,
val rekeyAfterTime: String?,
val rekeyTimeout: String?,
val rejectAfterTime: String?,
val keepaliveTimeout: String?,
val maxHandshakeAttempts: String?,
val randomTrailers: String?,
val disableCookies: String?,
) : ProtocolConfig(protocolConfigBuilder) {
protected constructor(builder: Builder) : this(
@@ -57,6 +66,15 @@ open class WireguardConfig protected constructor(
builder.i3,
builder.i4,
builder.i5,
builder.headerProtectionKeyHex,
builder.contentPaddingAddition,
builder.rekeyAfterTime,
builder.rekeyTimeout,
builder.rejectAfterTime,
builder.keepaliveTimeout,
builder.maxHandshakeAttempts,
builder.randomTrailers,
builder.disableCookies,
)
fun toWgUserspaceString(): String = with(StringBuilder()) {
@@ -87,6 +105,15 @@ open class WireguardConfig protected constructor(
i4?.let { appendLine("i4=$it") }
i5?.let { appendLine("i5=$it") }
}
headerProtectionKeyHex?.takeIf { it.isNotEmpty() }?.let { appendLine("header_protection_key=$it") }
contentPaddingAddition?.takeIf { it.isNotEmpty() }?.let { appendLine("content_padding_addition=$it") }
rekeyAfterTime?.takeIf { it.isNotEmpty() }?.let { appendLine("rekey_after_time=$it") }
rekeyTimeout?.takeIf { it.isNotEmpty() }?.let { appendLine("rekey_timeout=$it") }
rejectAfterTime?.takeIf { it.isNotEmpty() }?.let { appendLine("reject_after_time=$it") }
keepaliveTimeout?.takeIf { it.isNotEmpty() }?.let { appendLine("keepalive_timeout=$it") }
maxHandshakeAttempts?.takeIf { it.isNotEmpty() }?.let { appendLine("max_handshake_attempts=$it") }
randomTrailers?.takeIf { it.isNotEmpty() }?.let { appendLine("random_trailers=${it.toUapiBool()}") }
disableCookies?.takeIf { it.isNotEmpty() }?.let { appendLine("disable_cookies=${it.toUapiBool()}") }
}
private fun validateProtocolExtensionParameters() {
@@ -107,7 +134,7 @@ open class WireguardConfig protected constructor(
appendLine("allowed_ip=${route.inetNetwork}")
}
appendLine("endpoint=$endpoint")
if (persistentKeepalive != 0)
if (!persistentKeepalive.isNullOrEmpty() && persistentKeepalive != "0")
appendLine("persistent_keepalive_interval=$persistentKeepalive")
if (preSharedKeyHex != null)
appendLine("preshared_key=$preSharedKeyHex")
@@ -117,7 +144,7 @@ open class WireguardConfig protected constructor(
internal lateinit var endpoint: InetEndpoint
private set
internal var persistentKeepalive: Int = 0
internal var persistentKeepalive: String? = null
private set
internal lateinit var publicKeyHex: String
@@ -149,10 +176,19 @@ open class WireguardConfig protected constructor(
internal var i3: String? = null
internal var i4: String? = null
internal var i5: String? = null
internal var headerProtectionKeyHex: String? = null
internal var contentPaddingAddition: String? = null
internal var rekeyAfterTime: String? = null
internal var rekeyTimeout: String? = null
internal var rejectAfterTime: String? = null
internal var keepaliveTimeout: String? = null
internal var maxHandshakeAttempts: String? = null
internal var randomTrailers: String? = null
internal var disableCookies: String? = null
fun setEndpoint(endpoint: InetEndpoint) = apply { this.endpoint = endpoint }
fun setPersistentKeepalive(persistentKeepalive: Int) = apply { this.persistentKeepalive = persistentKeepalive }
fun setPersistentKeepalive(persistentKeepalive: String) = apply { this.persistentKeepalive = persistentKeepalive }
fun setPublicKeyHex(publicKeyHex: String) = apply { this.publicKeyHex = publicKeyHex }
@@ -178,6 +214,15 @@ open class WireguardConfig protected constructor(
fun setI3(i3: String) = apply { this.i3 = i3 }
fun setI4(i4: String) = apply { this.i4 = i4 }
fun setI5(i5: String) = apply { this.i5 = i5 }
fun setHeaderProtectionKey(headerProtectionKeyHex: String) = apply { this.headerProtectionKeyHex = headerProtectionKeyHex }
fun setContentPaddingAddition(contentPaddingAddition: String) = apply { this.contentPaddingAddition = contentPaddingAddition }
fun setRekeyAfterTime(rekeyAfterTime: String) = apply { this.rekeyAfterTime = rekeyAfterTime }
fun setRekeyTimeout(rekeyTimeout: String) = apply { this.rekeyTimeout = rekeyTimeout }
fun setRejectAfterTime(rejectAfterTime: String) = apply { this.rejectAfterTime = rejectAfterTime }
fun setKeepaliveTimeout(keepaliveTimeout: String) = apply { this.keepaliveTimeout = keepaliveTimeout }
fun setMaxHandshakeAttempts(maxHandshakeAttempts: String) = apply { this.maxHandshakeAttempts = maxHandshakeAttempts }
fun setRandomTrailers(randomTrailers: String) = apply { this.randomTrailers = randomTrailers }
fun setDisableCookies(disableCookies: String) = apply { this.disableCookies = disableCookies }
override fun build(): WireguardConfig = configBuild().run { WireguardConfig(this@Builder) }
}
@@ -189,3 +234,9 @@ open class WireguardConfig protected constructor(
@OptIn(ExperimentalStdlibApi::class)
internal fun String.base64ToHex(): String = Base64.decode(this, Base64.DEFAULT).toHexString()
/** Converts awg-quick on/off (and 0/1/true/false) to UAPI 1/0 for amneziawg-go ParseBool. */
internal fun String.toUapiBool(): String = when (trim().lowercase()) {
"on", "1", "true", "t", "yes" -> "1"
else -> "0"
}
@@ -86,10 +86,6 @@ class Xray : Protocol() {
ensureInboundAuth(xrayJsonConfig)
val xrayConfig = parseConfig(config, xrayJsonConfig)
(xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) })
.put("loglevel", "warning")
.put("access", "none") // disable access log
var xrayJsonConfigString = xrayJsonConfig.toString()
config.getString("hostName").let { hostName ->
val ipAddress = parseInetAddress(hostName).ip
+73 -2
View File
@@ -1,6 +1,12 @@
message("Client android ${CMAKE_ANDROID_ARCH_ABI} build")
set(APP_ANDROID_MIN_SDK 28)
if(NOT DEFINED APP_ANDROID_MIN_SDK)
set(APP_ANDROID_MIN_SDK 28)
endif()
# Option to build Play variant (with Google Play Billing) instead of OSS
# When ON, adds target android_play_apk: cmake --build . --target android_play_apk
option(ANDROID_BUILD_PLAY "Add android_play_apk target for Google Play Billing build" OFF)
set(ANDROID_PLATFORM "android-${APP_ANDROID_MIN_SDK}" CACHE STRING
"The minimum API level supported by the application or library" FORCE)
@@ -13,7 +19,6 @@ set_target_properties(${PROJECT} PROPERTIES
QT_ANDROID_MIN_SDK_VERSION ${APP_ANDROID_MIN_SDK}
QT_ANDROID_TARGET_SDK_VERSION 36
QT_ANDROID_SDK_BUILD_TOOLS_REVISION 36.0.0
QT_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android
)
set(QT_ANDROID_MULTI_ABI_FORWARD_VARS "QT_NO_GLOBAL_APK_TARGET_PART_OF_ALL;CMAKE_BUILD_TYPE")
@@ -53,3 +58,69 @@ file(COPY ${AMNEZIA_LIBXRAY_PATH} DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/androi
find_package(openvpn-pt-android REQUIRED)
set(LIBS ${LIBS} amnezia::openvpn-pt-android)
set_property(TARGET ${PROJECT} APPEND PROPERTY QT_ANDROID_EXTRA_LIBS ${OPENVPN_PT_ANDROID_LIBCK_OVPN_PLUGIN_PATH})
set(APP_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android)
if(APP_ANDROID_MAX_SDK)
set(APP_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_BINARY_DIR}/android-package-source)
file(REMOVE_RECURSE ${APP_ANDROID_PACKAGE_SOURCE_DIR})
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/android/ DESTINATION ${APP_ANDROID_PACKAGE_SOURCE_DIR})
set(manifest_path ${APP_ANDROID_PACKAGE_SOURCE_DIR}/AndroidManifest.xml)
set(manifest_anchor "android:installLocation=\"auto\">")
file(READ ${manifest_path} manifest_contents)
string(REPLACE
"${manifest_anchor}"
"${manifest_anchor}\n\n <uses-sdk android:maxSdkVersion=\"${APP_ANDROID_MAX_SDK}\" />"
patched_contents "${manifest_contents}")
if(patched_contents STREQUAL manifest_contents)
message(FATAL_ERROR
"Failed to set maxSdkVersion=${APP_ANDROID_MAX_SDK}: anchor '${manifest_anchor}' "
"not found in ${CMAKE_CURRENT_SOURCE_DIR}/android/AndroidManifest.xml")
endif()
file(WRITE ${manifest_path} "${patched_contents}")
endif()
set_property(TARGET ${PROJECT} PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR ${APP_ANDROID_PACKAGE_SOURCE_DIR})
if(QT_USE_TARGET_ANDROID_BUILD_DIR)
set(_android_build_dir "${CMAKE_CURRENT_BINARY_DIR}/android-build-${PROJECT}")
else()
set(_android_build_dir "${CMAKE_CURRENT_BINARY_DIR}/android-build")
endif()
add_custom_target(android_gradle_clean
COMMAND ./gradlew clean
WORKING_DIRECTORY "${_android_build_dir}"
COMMENT "Cleaning Android Gradle build cache"
)
# Always-available debug target: build Play Debug APK and copy to standard output path
# so Qt Creator's deploy step picks it up automatically
add_custom_target(android_play_debug_install
COMMAND ./gradlew assemblePlayDebug
COMMAND sh -c "cp build/outputs/apk/play/debug/*.apk build/outputs/apk/android-build-${PROJECT}-debug.apk"
WORKING_DIRECTORY "${_android_build_dir}"
COMMENT "Building Android Play Debug APK and copying to deploy path"
DEPENDS ${PROJECT}
)
if(ANDROID_BUILD_PLAY)
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
set(_gradle_suffix "Debug")
else()
set(_gradle_suffix "Release")
endif()
add_custom_target(android_play_apk
COMMAND ./gradlew assemblePlay${_gradle_suffix}
WORKING_DIRECTORY "${_android_build_dir}"
COMMENT "Building Android Play APK (assemblePlay${_gradle_suffix})"
DEPENDS ${PROJECT}
)
add_custom_target(android_play_aab
COMMAND ./gradlew bundlePlay${_gradle_suffix}
WORKING_DIRECTORY "${_android_build_dir}"
COMMENT "Building Android Play AAB (bundlePlay${_gradle_suffix})"
DEPENDS ${PROJECT}
)
endif()
+3
View File
@@ -0,0 +1,3 @@
if(NOT CLIENT_ANDROID_PACKAGE)
set(CLIENT_ANDROID_PACKAGE "org.amnezia.vpn" CACHE STRING "Android package name for Play Store version lookup")
endif()
+22
View File
@@ -0,0 +1,22 @@
if(NOT CLIENT_SWIFT_MODULE_NAME)
set(CLIENT_SWIFT_MODULE_NAME "${CLIENT_APPLICATION_NAME}" CACHE STRING "Swift module name for Objective-C interop header")
endif()
if(NOT CLIENT_SWIFT_OBJC_HEADER_NAME)
set(CLIENT_SWIFT_OBJC_HEADER_NAME "${CLIENT_SWIFT_MODULE_NAME}-Swift.h" CACHE STRING "Generated Swift Objective-C header name")
endif()
if(NOT CLIENT_NETWORK_EXTENSION_NAME)
set(CLIENT_NETWORK_EXTENSION_NAME "${CLIENT_APPLICATION_NAME}NetworkExtension" CACHE STRING "Display name for Apple network extension targets")
endif()
if(NOT BUILD_OSX_APP_IDENTIFIER)
set(BUILD_OSX_APP_IDENTIFIER org.amnezia.AmneziaVPN CACHE STRING "OSX Application identifier")
endif()
if(NOT BUILD_IOS_APP_IDENTIFIER)
set(BUILD_IOS_APP_IDENTIFIER org.amnezia.AmneziaVPN CACHE STRING "iOS Application identifier")
endif()
if(NOT BUILD_IOS_GROUP_IDENTIFIER)
set(BUILD_IOS_GROUP_IDENTIFIER group.org.amnezia.AmneziaVPN CACHE STRING "iOS Group identifier")
endif()
if(NOT BUILD_VPN_DEVELOPMENT_TEAM)
set(BUILD_VPN_DEVELOPMENT_TEAM X7UJ388FXK CACHE STRING "Amnezia VPN Development Team")
endif()
+64
View File
@@ -0,0 +1,64 @@
if(NOT CLIENT_TARGET_NAME)
set(CLIENT_TARGET_NAME "AmneziaVPN" CACHE STRING "Client executable target name")
endif()
if(NOT CLIENT_APPLICATION_NAME)
set(CLIENT_APPLICATION_NAME "AmneziaVPN" CACHE STRING "Application display and executable name")
endif()
if(NOT CLIENT_SERVICE_NAME)
set(CLIENT_SERVICE_NAME "AmneziaVPN-service" CACHE STRING "Service executable name")
endif()
if(NOT CLIENT_ORGANIZATION_NAME)
set(CLIENT_ORGANIZATION_NAME "AmneziaVPN.ORG" CACHE STRING "QSettings organization name")
endif()
if(NOT CLIENT_APP_INSTANCE_NAME)
set(CLIENT_APP_INSTANCE_NAME "AmneziaVPNInstance" CACHE STRING "Single-instance local server name")
endif()
if(NOT CLIENT_KEYCHAIN_NAME)
set(CLIENT_KEYCHAIN_NAME "${CLIENT_APPLICATION_NAME}-Keychain" CACHE STRING "QtKeychain service name used for encrypted settings keys")
endif()
# -- QML entry points and UI resources
if(NOT CLIENT_QML_ENTRYPOINT)
set(CLIENT_QML_ENTRYPOINT "qrc:/ui/qml/main2.qml" CACHE STRING "Main QML entrypoint URL")
endif()
if(NOT CLIENT_QML_IMPORT_PATH)
set(CLIENT_QML_IMPORT_PATH "qrc:/ui/qml/Modules/" CACHE STRING "Additional QQmlEngine import path")
endif()
if(NOT CLIENT_QML_PAGES_PREFIX)
set(CLIENT_QML_PAGES_PREFIX "qrc:/ui/qml/Pages2/" CACHE STRING "PageController QML pages prefix")
endif()
if(NOT CLIENT_UI_FONT_RESOURCE)
set(CLIENT_UI_FONT_RESOURCE ":/fonts/pt-root-ui_vf.ttf" CACHE STRING "UI font resource path")
endif()
if(NOT CLIENT_QML_QRC_FILE)
set(CLIENT_QML_QRC_FILE "${_CLIENT_SRC_DIR}/ui/qml/qml.qrc" CACHE FILEPATH "QML resources manifest")
endif()
if(NOT CLIENT_EXTRA_QRC)
set(CLIENT_EXTRA_QRC "" CACHE STRING "Optional semicolon-separated extra QRC files")
endif()
# -- translations
if(NOT CLIENT_TS_PREFIX)
set(CLIENT_TS_PREFIX "amneziavpn" CACHE STRING "Translation filename prefix")
endif()
if(NOT CLIENT_TS_FILES)
set(CLIENT_TS_FILES "" CACHE STRING "Optional semicolon-separated absolute TS file paths")
endif()
# -- brand-provided headers
if(NOT CLIENT_BRANDING_INCLUDE_DIR)
set(CLIENT_BRANDING_INCLUDE_DIR "" CACHE PATH "Optional include root for non-generated branding headers (e.g. pageEnum.h)")
endif()
if(CLIENT_BRANDING_INCLUDE_DIR AND EXISTS "${CLIENT_BRANDING_INCLUDE_DIR}/ui/utils/pageEnum.h")
set(CLIENT_PAGE_ENUM_HEADER "${CLIENT_BRANDING_INCLUDE_DIR}/ui/utils/pageEnum.h")
else()
set(CLIENT_PAGE_ENUM_HEADER "${_CLIENT_SRC_DIR}/ui/utils/pageEnum.h")
endif()
if(NOT CLIENT_NOTIFICATION_IDENTIFIER)
set(CLIENT_NOTIFICATION_IDENTIFIER "${CLIENT_TS_PREFIX}" CACHE STRING "Local notification request identifier")
endif()
if(NOT DEFINED CLIENT_ENABLE_APP_UPDATES)
set(CLIENT_ENABLE_APP_UPDATES 1)
endif()
+45
View File
@@ -0,0 +1,45 @@
if(NOT CLIENT_IOS_NE_TARGET_NAME)
set(CLIENT_IOS_NE_TARGET_NAME "networkextension" CACHE STRING "iOS network extension build target name")
endif()
if(NOT CLIENT_IOS_NE_ENTITLEMENTS_FILE)
set(CLIENT_IOS_NE_ENTITLEMENTS_FILE "AmneziaVPNNetworkExtension.entitlements" CACHE STRING "iOS network extension entitlements filename")
endif()
if(NOT CLIENT_IOS_LAUNCHSCREEN_FILE)
set(CLIENT_IOS_LAUNCHSCREEN_FILE "AmneziaVPNLaunchScreen.storyboard" CACHE STRING "iOS launch screen storyboard filename")
endif()
get_filename_component(CLIENT_IOS_LAUNCHSCREEN_NAME "${CLIENT_IOS_LAUNCHSCREEN_FILE}" NAME_WE)
if(NOT CLIENT_IOS_APP_ENTITLEMENTS_PATH)
set(CLIENT_IOS_APP_ENTITLEMENTS_PATH "${_CLIENT_SRC_DIR}/ios/app/main.entitlements" CACHE FILEPATH "iOS app entitlements file path")
endif()
if(NOT CLIENT_IOS_NE_ENTITLEMENTS_PATH)
set(CLIENT_IOS_NE_ENTITLEMENTS_PATH "${_CLIENT_SRC_DIR}/ios/networkextension/${CLIENT_IOS_NE_ENTITLEMENTS_FILE}" CACHE FILEPATH "iOS network extension entitlements file path")
endif()
if(NOT CLIENT_IOS_LAUNCHSCREEN_PATH)
set(CLIENT_IOS_LAUNCHSCREEN_PATH "${_CLIENT_SRC_DIR}/ios/app/${CLIENT_IOS_LAUNCHSCREEN_FILE}" CACHE FILEPATH "iOS launch screen storyboard path")
endif()
if(NOT CLIENT_IOS_MEDIA_ASSETS_PATH)
set(CLIENT_IOS_MEDIA_ASSETS_PATH "${_CLIENT_SRC_DIR}/ios/app/Media.xcassets" CACHE FILEPATH "iOS app Media.xcassets path")
endif()
if(NOT CLIENT_IOS_BUNDLE_ID)
set(CLIENT_IOS_BUNDLE_ID "org.amnezia.AmneziaVPN" CACHE STRING "iOS bundle id for App Store version lookup")
endif()
if(NOT CLIENT_IOS_STORE_URL_FALLBACK)
set(CLIENT_IOS_STORE_URL_FALLBACK "itms-apps://itunes.apple.com/app/id1600529900" CACHE STRING "Fallback App Store URL when lookup omits trackViewUrl")
endif()
# -- signing
if(NOT CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER)
set(CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER "distr ios.org.amnezia.AmneziaVPN" CACHE STRING "iOS app provisioning profile specifier for release")
endif()
if(NOT CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG)
set(CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG "dev ios.org.amnezia.AmneziaVPN" CACHE STRING "iOS app provisioning profile specifier for debug")
endif()
if(NOT CLIENT_IOS_NE_PROVISIONING_PROFILE_SPECIFIER)
set(CLIENT_IOS_NE_PROVISIONING_PROFILE_SPECIFIER "distr ios.org.amnezia.AmneziaVPN" CACHE STRING "iOS network extension provisioning profile specifier for release")
endif()
if(NOT CLIENT_IOS_NE_PROVISIONING_PROFILE_SPECIFIER_DEBUG)
set(CLIENT_IOS_NE_PROVISIONING_PROFILE_SPECIFIER_DEBUG "dev ios.org.amnezia.AmneziaVPN" CACHE STRING "iOS network extension provisioning profile specifier for debug")
endif()
+37
View File
@@ -0,0 +1,37 @@
if(NOT CLIENT_MACOS_NE_TARGET_NAME)
set(CLIENT_MACOS_NE_TARGET_NAME "${CLIENT_NETWORK_EXTENSION_NAME}" CACHE STRING "macOS network extension build target name")
endif()
if(NOT CLIENT_MACOS_NE_ENTITLEMENTS_FILE)
set(CLIENT_MACOS_NE_ENTITLEMENTS_FILE "AmneziaVPNNetworkExtension.entitlements" CACHE STRING "macOS network extension entitlements filename")
endif()
if(NOT CLIENT_MACOS_APP_ENTITLEMENTS_PATH)
set(CLIENT_MACOS_APP_ENTITLEMENTS_PATH "${_CLIENT_SRC_DIR}/macos/app/app.entitlements" CACHE FILEPATH "macOS app entitlements file path")
endif()
if(NOT CLIENT_MACOS_NE_ENTITLEMENTS_PATH)
set(CLIENT_MACOS_NE_ENTITLEMENTS_PATH "${_CLIENT_SRC_DIR}/macos/networkextension/${CLIENT_MACOS_NE_ENTITLEMENTS_FILE}" CACHE FILEPATH "macOS network extension entitlements file path")
endif()
if(NOT CLIENT_MACOS_MEDIA_ASSETS_PATH)
set(CLIENT_MACOS_MEDIA_ASSETS_PATH "${_CLIENT_SRC_DIR}/macos/app/Images.xcassets" CACHE FILEPATH "macOS app Images.xcassets path")
endif()
if(NOT CLIENT_MACOS_APP_ICNS_PATH)
set(CLIENT_MACOS_APP_ICNS_PATH "${_CLIENT_SRC_DIR}/images/app.icns" CACHE FILEPATH "macOS app icon (.icns) used by non-App Store builds")
endif()
if(NOT CLIENT_MACOS_EXTRA_PLIST_ENTRIES)
set(CLIENT_MACOS_EXTRA_PLIST_ENTRIES "" CACHE STRING "Extra macOS Info.plist entries appended verbatim (e.g. appearance pinning)")
endif()
# -- signing
if(NOT CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER)
set(CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER "distr macos.org.amnezia.AmneziaVPN" CACHE STRING "macOS app provisioning profile specifier for release")
endif()
if(NOT CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG)
set(CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG "dev macos.org.amnezia.AmneziaVPN" CACHE STRING "macOS app provisioning profile specifier for debug")
endif()
if(NOT CLIENT_MACOS_NE_PROVISIONING_PROFILE_SPECIFIER)
set(CLIENT_MACOS_NE_PROVISIONING_PROFILE_SPECIFIER "distr macos.org.amnezia.amneziaVPN.NE" CACHE STRING "macOS network extension provisioning profile specifier for release")
endif()
if(NOT CLIENT_MACOS_NE_PROVISIONING_PROFILE_SPECIFIER_DEBUG)
set(CLIENT_MACOS_NE_PROVISIONING_PROFILE_SPECIFIER_DEBUG "dev macos.org.amnezia.amneziaVPN.NE" CACHE STRING "macOS network extension provisioning profile specifier for debug")
endif()
@@ -0,0 +1,9 @@
get_filename_component(_CLIENT_SRC_DIR "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
include(${CMAKE_CURRENT_LIST_DIR}/branding/common.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/branding/apple.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/branding/ios.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/branding/macos.cmake)
include(${CMAKE_CURRENT_LIST_DIR}/branding/android.cmake)
unset(_CLIENT_SRC_DIR)
+12 -1
View File
@@ -24,6 +24,17 @@ set(IOS_TARGET_COMPILED_TYPES
EXECUTABLE
)
## Simulator architecture: honor CMAKE_OSX_ARCHITECTURES when set, otherwise
## match the host — arm64 Macs run arm64 simulators (Xcode 26 has no Rosetta
## simulator destinations), Intel Macs need x86_64.
if(CMAKE_OSX_ARCHITECTURES)
string(REPLACE ";" " " IOS_SIMULATOR_ARCHS "${CMAKE_OSX_ARCHITECTURES}")
elseif(CMAKE_HOST_SYSTEM_PROCESSOR STREQUAL "arm64")
set(IOS_SIMULATOR_ARCHS "arm64")
else()
set(IOS_SIMULATOR_ARCHS "x86_64")
endif()
## Inspect all the targets, and add extra properties if necessary.
while(IOS_TARGETS)
list(POP_FRONT IOS_TARGETS TARGET_NAME)
@@ -38,6 +49,6 @@ while(IOS_TARGETS)
message("Patching architectures for ${TARGET_NAME}")
set_target_properties(${TARGET_NAME} PROPERTIES
XCODE_ATTRIBUTE_ARCHS[sdk=iphoneos*] "arm64"
XCODE_ATTRIBUTE_ARCHS[sdk=iphonesimulator*] "x86_64"
XCODE_ATTRIBUTE_ARCHS[sdk=iphonesimulator*] "${IOS_SIMULATOR_ARCHS}"
)
endwhile()
+25 -19
View File
@@ -31,8 +31,8 @@ set(HEADERS ${HEADERS}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ioscontextmenu.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h
)
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h PROPERTIES OBJECTIVE_CPP_HEADER TRUE)
@@ -42,13 +42,19 @@ set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ioscontextmenu.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/AmneziaSceneDelegateHooks.mm
)
# The context menu helper uses ARC-only constructs (weak references); the
# rest of the Objective-C++ sources build with manual reference counting.
set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ioscontextmenu.mm
PROPERTIES COMPILE_OPTIONS "-fobjc-arc"
)
target_include_directories(${PROJECT} PRIVATE ${Qt6Gui_PRIVATE_INCLUDE_DIRS})
@@ -56,35 +62,34 @@ target_include_directories(${PROJECT} PRIVATE ${Qt6Gui_PRIVATE_INCLUDE_DIRS})
set_target_properties(${PROJECT} PROPERTIES
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/ios/app/Info.plist.in
MACOSX_BUNDLE_ICON_FILE "AppIcon"
MACOSX_BUNDLE_INFO_STRING "AmneziaVPN"
MACOSX_BUNDLE_BUNDLE_NAME "AmneziaVPN"
MACOSX_BUNDLE_INFO_STRING "${CLIENT_APPLICATION_NAME}"
MACOSX_BUNDLE_BUNDLE_NAME "${CLIENT_APPLICATION_NAME}"
MACOSX_BUNDLE_GUI_IDENTIFIER "${BUILD_IOS_APP_IDENTIFIER}"
MACOSX_BUNDLE_BUNDLE_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
MACOSX_BUNDLE_LONG_VERSION_STRING "${APPLE_PROJECT_VERSION}-${CMAKE_PROJECT_VERSION_TWEAK}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${APPLE_PROJECT_VERSION}"
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${BUILD_IOS_APP_IDENTIFIER}"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/ios/app/main.entitlements"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CLIENT_IOS_APP_ENTITLEMENTS_PATH}"
XCODE_ATTRIBUTE_MARKETING_VERSION "${APPLE_PROJECT_VERSION}"
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPN"
XCODE_ATTRIBUTE_BUNDLE_INFO_STRING "AmneziaVPN"
XCODE_ATTRIBUTE_PRODUCT_NAME "${CLIENT_APPLICATION_NAME}"
XCODE_ATTRIBUTE_BUNDLE_INFO_STRING "${CLIENT_APPLICATION_NAME}"
XCODE_GENERATE_SCHEME TRUE
XCODE_ATTRIBUTE_ENABLE_BITCODE "NO"
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2"
XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY ON
XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION
XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/Frameworks"
XCODE_EMBED_APP_EXTENSIONS networkextension
XCODE_EMBED_APP_EXTENSIONS ${CLIENT_IOS_NE_TARGET_NAME}
)
if(DEFINED DEPLOY)
if(DEPLOY)
set_target_properties(${PROJECT} PROPERTIES
XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Distribution"
XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY[variant=Debug] "Apple Development"
XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER "distr ios.org.amnezia.AmneziaVPN"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER[variant=Debug] "dev ios.org.amnezia.AmneziaVPN"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER "${CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER}"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER[variant=Debug] "${CLIENT_IOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG}"
)
else()
set_target_properties(${PROJECT} PROPERTIES
@@ -96,11 +101,11 @@ set_target_properties(${PROJECT} PROPERTIES
XCODE_ATTRIBUTE_SWIFT_VERSION "5.0"
XCODE_ATTRIBUTE_CLANG_ENABLE_MODULES "YES"
XCODE_ATTRIBUTE_SWIFT_PRECOMPILE_BRIDGING_HEADER "NO"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTERFACE_HEADER_NAME "AmneziaVPN-Swift.h"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTERFACE_HEADER_NAME "${CLIENT_SWIFT_OBJC_HEADER_NAME}"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTEROP_MODE "objcxx"
)
set_target_properties(${PROJECT} PROPERTIES
XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "X7UJ388FXK"
XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${BUILD_VPN_DEVELOPMENT_TEAM}"
)
target_include_directories(${PROJECT} PRIVATE ${CMAKE_CURRENT_LIST_DIR})
target_compile_options(${PROJECT} PRIVATE
@@ -115,6 +120,7 @@ target_sources(${PROJECT} PRIVATE
${WG_APPLE_SOURCE_DIR}/WireGuardKitC/x25519.c
${CLIENT_ROOT_DIR}/platforms/ios/LogController.swift
${CLIENT_ROOT_DIR}/platforms/ios/Log.swift
${CLIENT_GROUP_IDENTIFIER_SWIFT_FILE}
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
@@ -122,20 +128,20 @@ target_sources(${PROJECT} PRIVATE
)
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/Media.xcassets
${CLIENT_IOS_MEDIA_ASSETS_PATH}
PROPERTIES MACOSX_PACKAGE_LOCATION Resources
)
target_sources(${PROJECT} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/AmneziaVPNLaunchScreen.storyboard
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/Media.xcassets
${CLIENT_IOS_LAUNCHSCREEN_PATH}
${CLIENT_IOS_MEDIA_ASSETS_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/PrivacyInfo.xcprivacy
)
set_property(TARGET ${PROJECT} APPEND PROPERTY RESOURCE
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/AmneziaVPNLaunchScreen.storyboard
${CLIENT_IOS_LAUNCHSCREEN_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/PrivacyInfo.xcprivacy
)
add_subdirectory(ios/networkextension)
add_dependencies(${PROJECT} networkextension)
add_dependencies(${PROJECT} ${CLIENT_IOS_NE_TARGET_NAME})
+2 -2
View File
@@ -34,8 +34,8 @@ set(SOURCES ${SOURCES}
set(ICON_FILE ${CMAKE_CURRENT_SOURCE_DIR}/images/app.icns)
set(MACOSX_BUNDLE_ICON_FILE app.icns)
set(ICON_FILE ${CLIENT_MACOS_APP_ICNS_PATH})
get_filename_component(MACOSX_BUNDLE_ICON_FILE "${CLIENT_MACOS_APP_ICNS_PATH}" NAME)
set_source_files_properties(${ICON_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set(SOURCES ${SOURCES} ${ICON_FILE})
+21 -27
View File
@@ -5,6 +5,7 @@ set_target_properties(${PROJECT} PROPERTIES MACOSX_BUNDLE TRUE)
set(APPLE_PROJECT_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})
enable_language(OBJC)
enable_language(OBJCXX)
enable_language(Swift)
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Widgets)
@@ -33,8 +34,6 @@ set(LIBS ${LIBS}
set(HEADERS ${HEADERS}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate-C-Interface.h
)
@@ -44,19 +43,11 @@ set_source_files_properties(${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_contro
set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/ios_controller_wrapper.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosnotificationhandler.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/StoreKitController.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/iosglue.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QRCodeReaderBase.mm
${CMAKE_CURRENT_SOURCE_DIR}/platforms/ios/QtAppDelegate.mm
)
set(ICON_FILE ${CMAKE_CURRENT_SOURCE_DIR}/images/app.icns)
set(MACOSX_BUNDLE_ICON_FILE app.icns)
set_source_files_properties(${ICON_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set(SOURCES ${SOURCES} ${ICON_FILE})
target_include_directories(${PROJECT} PRIVATE
${Qt6Gui_PRIVATE_INCLUDE_DIRS}
${Qt6Widgets_PRIVATE_INCLUDE_DIRS}
@@ -67,28 +58,25 @@ set_target_properties(${PROJECT} PROPERTIES
XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION
MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/macos/app/Info.plist.in
MACOSX_BUNDLE_ICON_FILE "AppIcon"
MACOSX_BUNDLE_INFO_STRING "AmneziaVPN"
MACOSX_BUNDLE_BUNDLE_NAME "AmneziaVPN"
MACOSX_BUNDLE_INFO_STRING "${CLIENT_APPLICATION_NAME}"
MACOSX_BUNDLE_BUNDLE_NAME "${CLIENT_APPLICATION_NAME}"
MACOSX_BUNDLE_GUI_IDENTIFIER "${BUILD_IOS_APP_IDENTIFIER}"
MACOSX_BUNDLE_BUNDLE_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
MACOSX_BUNDLE_LONG_VERSION_STRING "${APPLE_PROJECT_VERSION}-${CMAKE_PROJECT_VERSION_TWEAK}"
MACOSX_BUNDLE_SHORT_VERSION_STRING "${APPLE_PROJECT_VERSION}"
XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${BUILD_IOS_APP_IDENTIFIER}"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/macos/app/app.entitlements"
XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CLIENT_MACOS_APP_ENTITLEMENTS_PATH}"
XCODE_ATTRIBUTE_MARKETING_VERSION "${APPLE_PROJECT_VERSION}"
XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION "${CMAKE_PROJECT_VERSION_TWEAK}"
XCODE_ATTRIBUTE_PRODUCT_NAME "AmneziaVPN"
XCODE_ATTRIBUTE_BUNDLE_INFO_STRING "AmneziaVPN"
XCODE_ATTRIBUTE_PRODUCT_NAME "${CLIENT_APPLICATION_NAME}"
XCODE_ATTRIBUTE_BUNDLE_INFO_STRING "${CLIENT_APPLICATION_NAME}"
XCODE_GENERATE_SCHEME TRUE
XCODE_ATTRIBUTE_ENABLE_BITCODE "NO"
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2"
XCODE_EMBED_FRAMEWORKS_CODE_SIGN_ON_COPY "NO"
XCODE_EMBED_FRAMEWORKS_REMOVE_HEADERS_ON_COPY "YES"
XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET "11.0"
XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION
XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/../Frameworks"
XCODE_EMBED_APP_EXTENSIONS AmneziaVPNNetworkExtension
XCODE_EMBED_APP_EXTENSIONS ${CLIENT_MACOS_NE_TARGET_NAME}
)
if(DEPLOY)
@@ -96,8 +84,8 @@ if(DEPLOY)
XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Distribution"
XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY[variant=Debug] "Apple Development"
XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER "distr macos.org.amnezia.AmneziaVPN"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER[variant=Debug] "dev macos.org.amnezia.AmneziaVPN"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER "${CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER}"
XCODE_ATTRIBUTE_PROVISIONING_PROFILE_SPECIFIER[variant=Debug] "${CLIENT_MACOS_PROVISIONING_PROFILE_SPECIFIER_DEBUG}"
)
else()
set_target_properties(${PROJECT} PROPERTIES
@@ -109,11 +97,11 @@ set_target_properties(${PROJECT} PROPERTIES
XCODE_ATTRIBUTE_SWIFT_VERSION "5.0"
XCODE_ATTRIBUTE_CLANG_ENABLE_MODULES "YES"
XCODE_ATTRIBUTE_SWIFT_PRECOMPILE_BRIDGING_HEADER "NO"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTERFACE_HEADER_NAME "AmneziaVPN-Swift.h"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTERFACE_HEADER_NAME "${CLIENT_SWIFT_OBJC_HEADER_NAME}"
XCODE_ATTRIBUTE_SWIFT_OBJC_INTEROP_MODE "objcxx"
)
set_target_properties(${PROJECT} PROPERTIES
XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "X7UJ388FXK"
XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${BUILD_VPN_DEVELOPMENT_TEAM}"
)
target_include_directories(${PROJECT} PRIVATE ${CMAKE_CURRENT_LIST_DIR})
target_compile_options(${PROJECT} PRIVATE
@@ -127,14 +115,20 @@ target_sources(${PROJECT} PRIVATE
${WG_APPLE_SOURCE_DIR}/WireGuardKitC/x25519.c
${CLIENT_ROOT_DIR}/platforms/ios/LogController.swift
${CLIENT_ROOT_DIR}/platforms/ios/Log.swift
${CLIENT_GROUP_IDENTIFIER_SWIFT_FILE}
${CLIENT_ROOT_DIR}/platforms/ios/LogRecord.swift
${CLIENT_ROOT_DIR}/platforms/ios/ScreenProtection.swift
${CLIENT_ROOT_DIR}/platforms/ios/VPNCController.swift
${CLIENT_ROOT_DIR}/platforms/ios/StoreKit2Helper.swift
)
set_source_files_properties(
${CLIENT_MACOS_MEDIA_ASSETS_PATH}
PROPERTIES MACOSX_PACKAGE_LOCATION Resources
)
target_sources(${PROJECT} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/macos/app/Images.xcassets
${CLIENT_MACOS_MEDIA_ASSETS_PATH}
${CMAKE_CURRENT_SOURCE_DIR}/ios/app/PrivacyInfo.xcprivacy
)
@@ -143,7 +137,7 @@ set_property(TARGET ${PROJECT} APPEND PROPERTY RESOURCE
)
add_subdirectory(macos/networkextension)
add_dependencies(${PROJECT} AmneziaVPNNetworkExtension)
add_dependencies(${PROJECT} ${CLIENT_MACOS_NE_TARGET_NAME})
get_target_property(QtCore_location Qt6::Core LOCATION)
message("QtCore_location")
@@ -152,5 +146,5 @@ message(${QtCore_location})
get_filename_component(QT_BIN_DIR_DETECTED "${QtCore_location}/../../../../../bin" ABSOLUTE)
add_custom_command(TARGET ${PROJECT} POST_BUILD
COMMAND ${QT_BIN_DIR_DETECTED}/macdeployqt $<TARGET_BUNDLE_DIR:AmneziaVPN> -appstore-compliant -qmldir=${CMAKE_CURRENT_SOURCE_DIR} -no-codesign
COMMAND ${QT_BIN_DIR_DETECTED}/macdeployqt $<TARGET_BUNDLE_DIR:${PROJECT}> -appstore-compliant -qmldir=${CMAKE_CURRENT_SOURCE_DIR} -no-codesign
)
+12 -2
View File
@@ -37,13 +37,16 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/core/installers/socks5Installer.h
${CLIENT_ROOT_DIR}/core/installers/mtProxyInstaller.h
${CLIENT_ROOT_DIR}/core/installers/telemtInstaller.h
${CLIENT_ROOT_DIR}/core/installers/tProxyInstaller.h
${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.h
${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.h
${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.h
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/exportController.h
${CLIENT_ROOT_DIR}/core/controllers/connectionController.h
${CLIENT_ROOT_DIR}/core/controllers/settingsController.h
${CLIENT_ROOT_DIR}/core/controllers/logsController.h
${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.h
${CLIENT_ROOT_DIR}/core/controllers/api/storePurchaseController.h
${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.h
${CLIENT_ROOT_DIR}/core/controllers/api/newsController.h
${CLIENT_ROOT_DIR}/core/controllers/updateController.h
@@ -51,6 +54,7 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/core/repositories/secureAppSettingsRepository.h
${CLIENT_ROOT_DIR}/core/protocols/qmlRegisterProtocols.h
${CLIENT_ROOT_DIR}/ui/utils/pages.h
${CLIENT_PAGE_ENUM_HEADER}
${CLIENT_ROOT_DIR}/ui/utils/qAutoStart.h
${CLIENT_ROOT_DIR}/ui/utils/mtProxyPublicHostInput.h
${CLIENT_ROOT_DIR}/core/protocols/vpnProtocol.h
@@ -62,9 +66,11 @@ set(HEADERS ${HEADERS}
${CLIENT_ROOT_DIR}/core/utils/serialization/transfer.h
${CLIENT_ROOT_DIR}/../common/logger/logger.h
${CLIENT_ROOT_DIR}/../common/crypto/cryptoUtils.h
${CLIENT_ROOT_DIR}/ui/utils/qmlUtils.h
${CLIENT_ROOT_DIR}/core/utils/api/apiUtils.h
${CLIENT_ROOT_DIR}/core/utils/api/countryCatalog.h
${CLIENT_ROOT_DIR}/core/utils/api/gatewayPayloadBuilder.h
${CLIENT_ROOT_DIR}/core/utils/osSignalHandler.h
${CLIENT_ROOT_DIR}/core/utils/swiftBridge.h
${CLIENT_ROOT_DIR}/core/utils/utilities.h
${CLIENT_ROOT_DIR}/core/utils/managementServer.h
${CLIENT_ROOT_DIR}/core/utils/constants.h
@@ -117,13 +123,16 @@ set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/core/installers/socks5Installer.cpp
${CLIENT_ROOT_DIR}/core/installers/mtProxyInstaller.cpp
${CLIENT_ROOT_DIR}/core/installers/telemtInstaller.cpp
${CLIENT_ROOT_DIR}/core/installers/tProxyInstaller.cpp
${CLIENT_ROOT_DIR}/core/controllers/appSplitTunnelingController.cpp
${CLIENT_ROOT_DIR}/core/controllers/ipSplitTunnelingController.cpp
${CLIENT_ROOT_DIR}/core/controllers/allowedDnsController.cpp
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/exportController.cpp
${CLIENT_ROOT_DIR}/core/controllers/connectionController.cpp
${CLIENT_ROOT_DIR}/core/controllers/settingsController.cpp
${CLIENT_ROOT_DIR}/core/controllers/logsController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/storePurchaseController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.cpp
${CLIENT_ROOT_DIR}/core/controllers/api/newsController.cpp
${CLIENT_ROOT_DIR}/core/controllers/updateController.cpp
@@ -145,8 +154,9 @@ set(SOURCES ${SOURCES}
${CLIENT_ROOT_DIR}/core/utils/serialization/vmess_new.cpp
${CLIENT_ROOT_DIR}/../common/logger/logger.cpp
${CLIENT_ROOT_DIR}/../common/crypto/cryptoUtils.cpp
${CLIENT_ROOT_DIR}/ui/utils/qmlUtils.cpp
${CLIENT_ROOT_DIR}/core/utils/api/apiUtils.cpp
${CLIENT_ROOT_DIR}/core/utils/api/countryCatalog.cpp
${CLIENT_ROOT_DIR}/core/utils/api/gatewayPayloadBuilder.cpp
${CLIENT_ROOT_DIR}/core/utils/serverConfigUtils.cpp
${CLIENT_ROOT_DIR}/core/utils/osSignalHandler.cpp
${CLIENT_ROOT_DIR}/core/utils/utilities.cpp
+13 -7
View File
@@ -96,13 +96,19 @@ ProtocolConfig AwgConfigurator::createConfig(const ServerCredentials &credential
newClientConfig.specialJunk4 = configMap.value(configKey::specialJunk4);
newClientConfig.specialJunk5 = configMap.value(configKey::specialJunk5);
if (container == DockerContainer::Awg2) {
newClientConfig.cookieReplyPacketJunkSize = configMap.value(configKey::cookieReplyPacketJunkSize);
newClientConfig.transportPacketJunkSize = configMap.value(configKey::transportPacketJunkSize);
}
newClientConfig.isObfuscationEnabled = false;
newClientConfig.cookieReplyPacketJunkSize = configMap.value(configKey::cookieReplyPacketJunkSize);
newClientConfig.transportPacketJunkSize = configMap.value(configKey::transportPacketJunkSize);
newClientConfig.headerProtectionKey = configMap.value(configKey::headerProtectionKey);
newClientConfig.contentPaddingAddition = configMap.value(configKey::contentPaddingAddition);
newClientConfig.rekeyAfterTime = configMap.value(configKey::rekeyAfterTime);
newClientConfig.rekeyTimeout = configMap.value(configKey::rekeyTimeout);
newClientConfig.rejectAfterTime = configMap.value(configKey::rejectAfterTime);
newClientConfig.keepaliveTimeout = configMap.value(configKey::keepaliveTimeout);
newClientConfig.maxHandshakeAttempts = configMap.value(configKey::maxHandshakeAttempts);
newClientConfig.randomTrailers = configMap.value(configKey::randomTrailers);
newClientConfig.disableCookies = configMap.value(configKey::disableCookies);
protocolConfig.setClientConfig(newClientConfig);
return protocolConfig;
@@ -233,6 +233,13 @@ ProtocolConfig WireguardConfigurator::createConfig(const ServerCredentials &cred
QString scriptData = amnezia::scriptData(m_configTemplate, container);
QString config = m_sshSession->replaceVars(scriptData, vars);
// The template lists every possible key, but each parameter is optional -
// drop the lines whose value came out empty
static const QRegularExpression emptyValueLine(R"(^\s*\S+\s*=\s*$)");
auto configTemplateLines = config.split("\n");
configTemplateLines.removeIf([](const QString &line) { return emptyValueLine.match(line).hasMatch(); });
config = configTemplateLines.join("\n");
ConnectionData connData = prepareWireguardConfig(credentials, container, wireguardServerConfig, awgServerConfig, dnsSettings, errorCode);
if (errorCode != ErrorCode::NoError) {
return WireGuardProtocolConfig{};
@@ -266,7 +273,9 @@ ProtocolConfig WireguardConfigurator::createConfig(const ServerCredentials &cred
clientConfig.presharedKey = connData.pskKey;
clientConfig.clientId = connData.clientPubKey;
clientConfig.allowedIps = QStringList { "0.0.0.0/0", "::/0" };
clientConfig.persistentKeepAlive = "25";
const bool useKeepAliveRange = awgServerConfig && awgServerConfig->hasAwg3Params();
clientConfig.persistentKeepAlive = useKeepAliveRange ? protocols::awg::defaultPersistentKeepAlive
: protocols::wireguard::defaultPersistentKeepAlive;
clientConfig.mtu = mtu;
clientConfig.isObfuscationEnabled = false;
+440 -419
View File
@@ -4,6 +4,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDateTime>
#include <QThread>
#include <QUuid>
#include "logger.h"
@@ -23,105 +24,49 @@
namespace {
Logger logger("XrayConfigurator");
QString normalizeXhttpMode(const QString &m) {
const QString t = m.trimmed();
if (t.isEmpty() || t.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0) {
return QStringLiteral("auto");
}
if (t.compare(QLatin1String("Packet-up"), Qt::CaseInsensitive) == 0)
return QStringLiteral("packet-up");
if (t.compare(QLatin1String("Stream-up"), Qt::CaseInsensitive) == 0)
return QStringLiteral("stream-up");
if (t.compare(QLatin1String("Stream-one"), Qt::CaseInsensitive) == 0)
return QStringLiteral("stream-one");
return t.toLower();
QString effectiveClientFlow(const amnezia::XrayServerConfig &srv)
{
return amnezia::xrayEffective::clientFlow(srv);
}
// Xray-core: empty → path; "None" in UI → omit (core default path)
QString normalizeSessionSeqPlacement(const QString &p)
QString effectiveSecurity(const amnezia::XrayServerConfig &srv)
{
if (p.isEmpty() || p.compare(QLatin1String("None"), Qt::CaseInsensitive) == 0)
return {};
return p.toLower();
}
QString normalizeUplinkDataPlacement(const QString &p)
{
if (p.isEmpty() || p.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0)
return QStringLiteral("body");
if (p.compare(QLatin1String("Auto"), Qt::CaseInsensitive) == 0)
return QStringLiteral("auto");
if (p.compare(QLatin1String("Query"), Qt::CaseInsensitive) == 0)
// "Query" is not valid for uplink payload in splithttp; closest documented mode
return QStringLiteral("header");
return p.toLower();
}
// splithttp: cookie | header | query | queryInHeader (not "body")
QString normalizeXPaddingPlacement(const QString &p)
{
QString t = p.trimmed();
if (t.isEmpty())
return QString::fromLatin1(amnezia::protocols::xray::defaultXPaddingPlacement).toLower();
if (t.compare(QLatin1String("Body"), Qt::CaseInsensitive) == 0)
return QStringLiteral("queryInHeader");
if (t.contains(QLatin1String("queryInHeader"), Qt::CaseInsensitive)
|| t.compare(QLatin1String("Query in header"), Qt::CaseInsensitive) == 0)
return QStringLiteral("queryInHeader");
return t.toLower();
}
// splithttp: repeat-x | tokenish
QString normalizeXPaddingMethod(const QString &m)
{
QString t = m.trimmed();
if (t.isEmpty() || t.compare(QLatin1String("Repeat-x"), Qt::CaseInsensitive) == 0)
return QStringLiteral("repeat-x");
if (t.compare(QLatin1String("Tokenish"), Qt::CaseInsensitive) == 0)
return QStringLiteral("tokenish");
if (t.compare(QLatin1String("Random"), Qt::CaseInsensitive) == 0
|| t.compare(QLatin1String("Zero"), Qt::CaseInsensitive) == 0)
return QStringLiteral("repeat-x");
return t.toLower();
}
void putIntRangeIfAny(QJsonObject &obj, const char *key, QString minV, QString maxV, const char *fallbackMin,
const char *fallbackMax)
{
if (minV.isEmpty() && maxV.isEmpty())
return;
if (minV.isEmpty())
minV = QString::fromLatin1(fallbackMin);
if (maxV.isEmpty())
maxV = QString::fromLatin1(fallbackMax);
QJsonObject r;
r[QStringLiteral("from")] = minV.toInt();
r[QStringLiteral("to")] = maxV.toInt();
obj[QString::fromUtf8(key)] = r;
return amnezia::xrayEffective::security(srv);
}
// Desktop applies this in XrayProtocol::start(); iOS/Android pass JSON straight to libxray — same fixes here.
void sanitizeXrayNativeConfig(amnezia::ProtocolConfig &pc)
{
QString c = pc.nativeConfig();
if (c.isEmpty()) {
return;
const QString before = pc.nativeConfig();
const QString after = amnezia::sanitizeNativeConfig(before);
if (after != before) {
pc.setNativeConfig(after);
}
bool changed = false;
if (c.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) {
c.replace(QLatin1String("Mozilla/5.0"), QString::fromLatin1(amnezia::protocols::xray::defaultFingerprint),
Qt::CaseInsensitive);
changed = true;
}
const QString legacyListen = QString::fromLatin1(amnezia::protocols::xray::defaultLocalAddr);
const QString listenOk = QString::fromLatin1(amnezia::protocols::xray::defaultLocalListenAddr);
if (c.contains(legacyListen)) {
c.replace(legacyListen, listenOk);
changed = true;
}
if (changed) {
pc.setNativeConfig(c);
}
QString tlsFingerprintFromEnsureOutput(const QString &stdOut)
{
const QString prefix = QStringLiteral("amnezia_tls_fp=");
const auto lines = stdOut.split(QLatin1Char('\n'));
for (QString line : lines) {
line = line.trimmed();
if (!line.startsWith(prefix))
continue;
QString fp = line.mid(prefix.size());
fp.remove(QLatin1Char(':'));
fp.remove(QLatin1Char(' '));
fp = fp.toLower();
if (fp.size() != 64)
return {};
for (const QChar ch : fp) {
const ushort u = ch.unicode();
const bool hex = (u >= '0' && u <= '9') || (u >= 'a' && u <= 'f');
if (!hex)
return {};
}
return fp;
}
return {};
}
} // namespace
@@ -138,30 +83,6 @@ amnezia::ProtocolConfig XrayConfigurator::processConfigWithLocalSettings(const a
return protocolConfig;
}
ErrorCode XrayConfigurator::uploadServerConfigJson(const ServerCredentials &credentials, DockerContainer container,
const DnsSettings &dnsSettings, const QJsonObject &serverConfig) const
{
const QString updatedConfig = QJsonDocument(serverConfig).toJson();
ErrorCode errorCode = m_sshSession->uploadTextFileToContainer(
container, credentials, updatedConfig, amnezia::protocols::xray::serverConfigPath,
libssh::ScpOverwriteMode::ScpOverwriteExisting);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Failed to upload updated config";
return errorCode;
}
const QString restartScript = QStringLiteral("sudo docker restart $CONTAINER_NAME");
errorCode = m_sshSession->runScript(
credentials,
m_sshSession->replaceVars(restartScript,
amnezia::genBaseVars(credentials, container, dnsSettings.primaryDns,
dnsSettings.secondaryDns)));
if (errorCode != ErrorCode::NoError) {
logger.error() << "Failed to restart container";
}
return errorCode;
}
ErrorCode XrayConfigurator::readRealityKeyFiles(const DockerContainer container, const ServerCredentials &credentials,
QString &outPublicKey, QString &outShortId) const
{
@@ -177,12 +98,17 @@ ErrorCode XrayConfigurator::readRealityKeyFiles(const DockerContainer container,
if (fileError == ErrorCode::NoError && !out.isEmpty()) {
return ErrorCode::NoError;
}
if (fileError != ErrorCode::NoError) {
logger.error() << "Xray readRealityKeyFiles: ssh failed path=" << path
<< "error=" << static_cast<int>(fileError);
return fileError;
}
if (attempt < 2) {
QThread::msleep(500);
}
}
logger.error() << "Xray readRealityKeyFiles: failed path=" << path;
return ErrorCode::XrayRealityKeysReadFailed;
logger.error() << "Xray readRealityKeyFiles: key file empty path=" << path;
return ErrorCode::XrayKeysReadFailed;
};
ErrorCode errorCode = readKeyFile(QString::fromLatin1(amnezia::protocols::xray::PublicKeyPath), outPublicKey);
@@ -192,33 +118,6 @@ ErrorCode XrayConfigurator::readRealityKeyFiles(const DockerContainer container,
return readKeyFile(QString::fromLatin1(amnezia::protocols::xray::shortidPath), outShortId);
}
QJsonObject XrayConfigurator::mergeStreamSettingsForServerInbound(const XrayServerConfig &srv,
const QJsonObject &existingStreamSettings) const
{
QJsonObject streamSettings = buildStreamSettings(srv, QString());
if (srv.security != QLatin1String("reality")) {
return streamSettings;
}
const QJsonObject newRs = streamSettings[amnezia::protocols::xray::realitySettings].toObject();
QJsonObject oldRs = existingStreamSettings[amnezia::protocols::xray::realitySettings].toObject();
QJsonObject merged = oldRs.isEmpty() ? newRs : oldRs;
const QString siteEff = srv.site.isEmpty() ? QString::fromLatin1(amnezia::protocols::xray::defaultSite) : srv.site;
const QString sniEff = srv.sni.isEmpty() ? siteEff : srv.sni;
if (newRs.contains(amnezia::protocols::xray::fingerprint)) {
merged[amnezia::protocols::xray::fingerprint] = newRs[amnezia::protocols::xray::fingerprint];
}
merged[amnezia::protocols::xray::serverNames] = QJsonArray { sniEff };
if (!merged.contains(QStringLiteral("dest"))) {
merged[QStringLiteral("dest")] = siteEff + QStringLiteral(":443");
}
streamSettings[amnezia::protocols::xray::realitySettings] = merged;
return streamSettings;
}
ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials &credentials, DockerContainer container,
ContainerConfig &containerConfig, const DnsSettings &dnsSettings,
@@ -240,14 +139,10 @@ ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials
return ErrorCode::NoError;
}
logger.info() << "Xray applyServerSettings: start"
<< "container=" << static_cast<int>(container) << "host=" << credentials.hostName
<< "transport=" << srv.transport << "security=" << srv.security << "port=" << srv.port
<< "appendClient=" << appendNewClient;
const QString flowValue = srv.flow;
const QString flowValue = effectiveClientFlow(srv);
QString realityPublicKey;
QString realityShortId;
if (srv.security == QLatin1String("reality")) {
if (effectiveSecurity(srv) == QLatin1String("reality")) {
errorCode = readRealityKeyFiles(container, credentials, realityPublicKey, realityShortId);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Xray applyServerSettings: readRealityKeyFiles failed, error="
@@ -272,78 +167,59 @@ ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials
}
QJsonObject serverConfig = doc.object();
if (!serverConfig.contains(amnezia::protocols::xray::inbounds)) {
logger.error() << "Server config missing 'inbounds' field";
return ErrorCode::XrayServerConfigInvalid;
}
const QJsonObject originalConfig = serverConfig;
QJsonArray inbounds = serverConfig[amnezia::protocols::xray::inbounds].toArray();
if (inbounds.isEmpty()) {
logger.error() << "Server config has empty 'inbounds' array";
return ErrorCode::XrayServerConfigInvalid;
}
QJsonObject inbound = inbounds[0].toObject();
if (!inbound.contains(amnezia::protocols::xray::settings)) {
logger.error() << "Inbound missing 'settings' field";
return ErrorCode::XrayServerConfigInvalid;
}
const QJsonObject existingStream = inbound[amnezia::protocols::xray::streamSettings].toObject();
inbound[amnezia::protocols::xray::streamSettings] = mergeStreamSettingsForServerInbound(srv, existingStream);
if (!srv.port.isEmpty()) {
inbound[amnezia::protocols::xray::port] = srv.port.toInt();
}
QJsonObject settings = inbound[amnezia::protocols::xray::settings].toObject();
if (!settings.contains(amnezia::protocols::xray::clients)) {
settings[amnezia::protocols::xray::clients] = QJsonArray {};
}
QJsonArray clients = settings[amnezia::protocols::xray::clients].toArray();
QJsonArray clients = XrayServerConfig::clientsFromServerInboundJson(serverConfig);
QString clientId;
if (appendNewClient) {
clientId = QUuid::createUuid().toString(QUuid::WithoutBraces);
QJsonObject clientEntry;
clientEntry[amnezia::protocols::xray::id] = clientId;
if (!flowValue.isEmpty()) {
clientEntry[amnezia::protocols::xray::flow] = flowValue;
const bool adoptingOwnAccount = !(xrayCfg->hasClientConfig() && !xrayCfg->clientConfig->id.isEmpty());
QString ownClientId;
if (readContainerKeyFile(container, credentials, QString::fromLatin1(amnezia::protocols::xray::uuidPath),
ownClientId)
!= ErrorCode::NoError) {
ownClientId.clear();
}
const int existingIndex =
adoptingOwnAccount ? XrayServerConfig::indexOfClient(clients, ownClientId) : -1;
if (existingIndex >= 0) {
clientId = ownClientId;
clients[existingIndex] =
XrayServerConfig::applyFlowToClient(clients[existingIndex].toObject(), flowValue);
} else {
const bool takeOverOwnAccount = adoptingOwnAccount && !ownClientId.isEmpty();
clientId = takeOverOwnAccount ? ownClientId : QUuid::createUuid().toString(QUuid::WithoutBraces);
clients.append(XrayServerConfig::makeClientEntry(clientId, flowValue));
}
clients.append(clientEntry);
} else {
if (clients.isEmpty()) {
logger.error() << "Server config has no VLESS clients";
return ErrorCode::XrayServerNoVlessClients;
}
clientId = clients[0].toObject()[amnezia::protocols::xray::id].toString();
clientId = XrayServerConfig::firstClientId(clients);
if (clientId.isEmpty()) {
logger.error() << "Server config VLESS client has empty id";
return ErrorCode::XrayServerNoVlessClients;
}
QJsonArray updatedClients;
for (const QJsonValue &v : clients) {
QJsonObject c = v.toObject();
if (flowValue.isEmpty()) {
c.remove(amnezia::protocols::xray::flow);
} else {
c[amnezia::protocols::xray::flow] = flowValue;
}
updatedClients.append(c);
}
clients = updatedClients;
clients = XrayServerConfig::applyFlowToClients(clients, flowValue);
}
settings[amnezia::protocols::xray::clients] = clients;
inbound[amnezia::protocols::xray::settings] = settings;
inbounds[0] = inbound;
serverConfig[amnezia::protocols::xray::inbounds] = inbounds;
const XrayServerJsonStatus writeStatus =
XrayServerConfig::setClientsInServerInboundJson(serverConfig, clients);
if (writeStatus != XrayServerJsonStatus::Ok) {
return ErrorCode::XrayServerConfigInvalid;
}
errorCode = uploadServerConfigJson(credentials, container, dnsSettings, serverConfig);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Xray applyServerSettings: upload/restart failed, error=" << static_cast<int>(errorCode);
return errorCode;
const QString listenPort = srv.port.isEmpty() ? QString::fromLatin1(amnezia::protocols::xray::defaultPort)
: srv.port;
if (serverConfig != originalConfig) {
errorCode = uploadServerConfigAtomically(credentials, container, listenPort, serverConfig);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
}
logger.info() << "Xray applyServerSettings: server config uploaded and container restarted";
@@ -352,7 +228,8 @@ ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials
}
XrayProtocolConfig updated =
buildClientProtocolConfig(credentials, container, srv, clientId, errorCode, realityPublicKey, realityShortId);
buildClientProtocolConfig(credentials, container, srv, clientId, errorCode,
realityPublicKey, realityShortId);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Xray applyServerSettings: buildClientProtocolConfig failed, error="
<< static_cast<int>(errorCode);
@@ -363,6 +240,335 @@ ErrorCode XrayConfigurator::applyServerSettingsToRemote(const ServerCredentials
return ErrorCode::NoError;
}
ErrorCode XrayConfigurator::readContainerKeyFile(DockerContainer container, const ServerCredentials &credentials,
const QString &path, QString &out) const
{
out.clear();
for (int attempt = 0; attempt < 3; ++attempt) {
ErrorCode fileError = ErrorCode::NoError;
out = QString::fromUtf8(m_sshSession->getTextFileFromContainer(container, credentials, path, fileError));
out.replace(QLatin1Char('\n'), QString());
out.replace(QLatin1Char('\r'), QString());
if (fileError == ErrorCode::NoError && !out.isEmpty()) {
return ErrorCode::NoError;
}
if (fileError != ErrorCode::NoError) {
logger.error() << "Xray readContainerKeyFile: ssh failed path=" << path
<< "error=" << static_cast<int>(fileError);
return fileError;
}
if (attempt < 2) {
QThread::msleep(500);
}
}
logger.error() << "Xray readContainerKeyFile: key file empty path=" << path;
return ErrorCode::XrayKeysReadFailed;
}
ErrorCode XrayConfigurator::writeServerConfigForSetup(const ServerCredentials &credentials, DockerContainer container,
ContainerConfig &containerConfig, const DnsSettings &dnsSettings,
bool useAtomicApply)
{
Q_UNUSED(dnsSettings);
const auto *xrayCfg = containerConfig.protocolConfig.as<XrayProtocolConfig>();
if (!xrayCfg) {
logger.error() << "Xray writeServerConfigForSetup: missing XrayProtocolConfig";
return ErrorCode::InternalError;
}
const XrayServerConfig &srv = xrayCfg->serverConfig;
if (srv.isThirdPartyConfig) {
logger.info() << "Xray writeServerConfigForSetup: skipped (third-party/native profile)";
return ErrorCode::NoError;
}
ErrorCode errorCode = ErrorCode::NoError;
QString clientId;
errorCode = readContainerKeyFile(container, credentials, QString::fromLatin1(amnezia::protocols::xray::uuidPath), clientId);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
const QString securityEff = effectiveSecurity(srv);
QString tlsPin;
if (securityEff == QLatin1String("tls")) {
errorCode = ensureTlsCertificate(credentials, container, tlsPin);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
}
QString realityPrivateKey;
QString realityPublicKey;
QString realityShortId;
if (securityEff == QLatin1String("reality")) {
errorCode = readContainerKeyFile(container, credentials, QString::fromLatin1(amnezia::protocols::xray::PrivateKeyPath), realityPrivateKey);
if (errorCode != ErrorCode::NoError)
return errorCode;
errorCode = readContainerKeyFile(container, credentials, QString::fromLatin1(amnezia::protocols::xray::PublicKeyPath), realityPublicKey);
if (errorCode != ErrorCode::NoError)
return errorCode;
errorCode = readContainerKeyFile(container, credentials, QString::fromLatin1(amnezia::protocols::xray::shortidPath), realityShortId);
if (errorCode != ErrorCode::NoError)
return errorCode;
}
const QString flowValue = effectiveClientFlow(srv);
const QJsonArray clients = collectServerClients(credentials, container, flowValue, clientId, errorCode);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Xray writeServerConfigForSetup: upload failed, error=" << static_cast<int>(errorCode);
return errorCode;
}
const QString portEff = srv.port.isEmpty() ? QString::fromLatin1(amnezia::protocols::xray::defaultPort) : srv.port;
XrayServerInboundInputs inputs;
inputs.clients = clients;
inputs.realityPrivateKey = realityPrivateKey;
inputs.realityShortId = realityShortId;
const QJsonObject inboundJson = srv.toServerInboundJson(inputs);
if (useAtomicApply) {
errorCode = uploadServerConfigAtomically(credentials, container, portEff, inboundJson);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
} else {
const QString json = QString::fromUtf8(QJsonDocument(inboundJson).toJson());
errorCode = m_sshSession->uploadTextFileToContainer(container, credentials, json,
QString::fromLatin1(amnezia::protocols::xray::serverConfigPath),
libssh::ScpOverwriteMode::ScpOverwriteExisting);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
}
XrayProtocolConfig updated =
buildClientProtocolConfig(credentials, container, srv, clientId, errorCode,
realityPublicKey, realityShortId, tlsPin);
if (errorCode != ErrorCode::NoError) {
logger.error() << "Xray writeServerConfigForSetup: buildClientProtocolConfig failed, error="
<< static_cast<int>(errorCode);
return errorCode;
}
containerConfig.protocolConfig = updated;
logger.info() << "Xray writeServerConfigForSetup: done, clientId=" << clientId;
return ErrorCode::NoError;
}
QJsonArray XrayConfigurator::collectServerClients(const ServerCredentials &credentials, DockerContainer container,
const QString &flowValue, const QString &fallbackClientId,
ErrorCode &outError) const
{
outError = ErrorCode::NoError;
const QString configPath = QString::fromLatin1(amnezia::protocols::xray::serverConfigPath);
QString currentConfig;
for (int attempt = 0; attempt < 3; ++attempt) {
ErrorCode readError = ErrorCode::NoError;
currentConfig = m_sshSession->getTextFileFromContainer(container, credentials, configPath, readError);
if (readError == ErrorCode::NoError && !currentConfig.trimmed().isEmpty()) {
break;
}
currentConfig.clear();
if (attempt < 2) {
QThread::msleep(500);
}
}
QJsonArray existing;
if (currentConfig.trimmed().isEmpty()) {
QString probeOut;
auto collect = [&probeOut](const QString &data, libssh::Client &) {
probeOut += data + "\n";
return ErrorCode::NoError;
};
const QString probe =
QStringLiteral("if [ -s %1 ]; then echo amnezia_config=present; else echo amnezia_config=absent; fi")
.arg(configPath);
const ErrorCode probeError = m_sshSession->runContainerScript(credentials, container, probe, collect, collect);
if (probeError != ErrorCode::NoError || !probeOut.contains(QLatin1String("amnezia_config="))) {
outError = ErrorCode::XrayServerConfigInvalid;
return {};
}
if (probeOut.contains(QLatin1String("amnezia_config=present"))) {
outError = ErrorCode::XrayServerConfigInvalid;
return {};
}
} else {
const QJsonDocument doc = QJsonDocument::fromJson(currentConfig.toUtf8());
if (!doc.isObject()) {
outError = ErrorCode::XrayServerConfigInvalid;
return {};
}
existing = XrayServerConfig::clientsFromServerInboundJson(doc.object());
}
QJsonArray clients =
XrayServerConfig::applyFlowToClients(existing, flowValue, XrayClientListFilter::DropWithoutId);
if (!fallbackClientId.isEmpty()
&& XrayServerConfig::indexOfClient(clients, fallbackClientId) < 0) {
clients.append(XrayServerConfig::makeClientEntry(fallbackClientId, flowValue));
}
return clients;
}
ErrorCode XrayConfigurator::ensureTlsCertificate(const ServerCredentials &credentials, DockerContainer container,
QString &outFingerprint) const
{
outFingerprint.clear();
QString stdOut;
auto collect = [&stdOut](const QString &data, libssh::Client &) {
stdOut += data + QLatin1Char('\n');
return ErrorCode::NoError;
};
const QString certPath = QString::fromLatin1(amnezia::protocols::xray::tlsCertPath);
const QString keyPath = QString::fromLatin1(amnezia::protocols::xray::tlsKeyPath);
const QString script = QStringLiteral(
"has_key() { [ -f \"$1\" ] && [ -n \"$(tr -d '[:space:]' < \"$1\" 2>/dev/null)\" ]; }\n"
"CERT=%1\n"
"KEY=%2\n"
"if has_key \"$CERT\" && has_key \"$KEY\"; then echo amnezia_tls=present;\n"
"else\n"
" openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj \"/CN=amnezia-xray\" "
"-keyout \"$KEY\" -out \"$CERT\" || true\n"
" if has_key \"$CERT\" && has_key \"$KEY\"; then echo amnezia_tls=created;\n"
" else echo amnezia_tls=failed; exit 1; fi\n"
"fi\n"
"FP=$(openssl x509 -noout -fingerprint -sha256 -in \"$CERT\" 2>/dev/null "
"| sed 's/.*Fingerprint=//' | tr -d ':' | tr 'A-F' 'a-f')\n"
"if [ -z \"$FP\" ]; then echo amnezia_tls=failed; exit 1; fi\n"
"echo amnezia_tls_fp=$FP\n"
"exit 0\n")
.arg(certPath, keyPath);
const ErrorCode scriptError = m_sshSession->runContainerScript(credentials, container, script, collect, collect);
const bool present = stdOut.contains(QLatin1String("amnezia_tls=present"));
const bool created = stdOut.contains(QLatin1String("amnezia_tls=created"));
outFingerprint = tlsFingerprintFromEnsureOutput(stdOut);
if (scriptError != ErrorCode::NoError || (!present && !created) || outFingerprint.isEmpty()) {
outFingerprint.clear();
return ErrorCode::XrayTlsNotSupported;
}
return ErrorCode::NoError;
}
ErrorCode XrayConfigurator::uploadServerConfigAtomically(const ServerCredentials &credentials,
DockerContainer container, const QString &listenPort,
const QJsonObject &serverConfig) const
{
const QString livePath = QString::fromLatin1(amnezia::protocols::xray::serverConfigPath);
const QString stagedPath = QStringLiteral("/opt/amnezia/xray/server.new.json");
const QString json = QString::fromUtf8(QJsonDocument(serverConfig).toJson());
ErrorCode errorCode = m_sshSession->uploadTextFileToContainer(container, credentials, json, stagedPath,
libssh::ScpOverwriteMode::ScpOverwriteExisting);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
const QString swapScript = QStringLiteral(
"LIVE=%1\n"
"STAGED=%2\n"
"BACKUP=\"$LIVE.bak\"\n"
"if [ ! -s \"$STAGED\" ]; then echo \"amnezia_apply=missing\"; exit 0; fi\n"
"if ! xray -test -format json -config \"$STAGED\" > /tmp/xray_test.log 2>&1; then\n"
" echo \"amnezia_apply=invalid\"\n"
" rm -f \"$STAGED\"\n"
" exit 0\n"
"fi\n"
"if [ -f \"$LIVE\" ]; then cp \"$LIVE\" \"$BACKUP\"; fi\n"
"mv \"$STAGED\" \"$LIVE\"\n"
"echo \"amnezia_apply=swapped\"\n")
.arg(livePath, stagedPath);
QString swapOut;
auto collectSwap = [&swapOut](const QString &data, libssh::Client &) {
swapOut += data + "\n";
return ErrorCode::NoError;
};
errorCode = m_sshSession->runContainerScript(credentials, container, swapScript, collectSwap, collectSwap);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
if (swapOut.contains(QLatin1String("amnezia_apply=missing"))) {
return ErrorCode::XrayServerConfigRejected;
}
if (swapOut.contains(QLatin1String("amnezia_apply=invalid"))) {
return ErrorCode::XrayServerConfigRejected;
}
if (!swapOut.contains(QLatin1String("amnezia_apply=swapped"))) {
return ErrorCode::XrayServerConfigInvalid;
}
if (restartXrayContainer(credentials, container) && xrayProcessIsUp(credentials, container)) {
m_sshSession->runContainerScript(credentials, container,
QStringLiteral("rm -f %1.bak").arg(livePath));
return ErrorCode::NoError;
}
const QString restoreScript = QStringLiteral(
"LIVE=%1\n"
"BACKUP=\"$LIVE.bak\"\n"
"if [ -f \"$BACKUP\" ]; then cp \"$BACKUP\" \"$LIVE\"; echo amnezia_restore=done; "
"else echo amnezia_restore=nobackup; fi\n")
.arg(livePath);
QString restoreOut;
auto collectRestore = [&restoreOut](const QString &data, libssh::Client &) {
restoreOut += data + "\n";
return ErrorCode::NoError;
};
m_sshSession->runContainerScript(credentials, container, restoreScript, collectRestore, collectRestore);
if (!restoreOut.contains(QLatin1String("amnezia_restore=done"))) {
return ErrorCode::XrayServerNotServing;
}
if (restartXrayContainer(credentials, container) && xrayProcessIsUp(credentials, container)) {
return ErrorCode::XrayServerConfigRolledBack;
}
return ErrorCode::XrayServerNotServing;
}
bool XrayConfigurator::restartXrayContainer(const ServerCredentials &credentials, DockerContainer container) const
{
const ErrorCode errorCode = m_sshSession->runScript(
credentials,
SshSession::replaceVars(QStringLiteral("sudo docker restart $CONTAINER_NAME"),
amnezia::genBaseVars(credentials, container, QString(), QString())));
if (errorCode != ErrorCode::NoError) {
return false;
}
return true;
}
bool XrayConfigurator::xrayProcessIsUp(const ServerCredentials &credentials, DockerContainer container) const
{
const QString script = QStringLiteral(
"for i in 1 2 3 4 5 6; do\n"
" if pgrep xray >/dev/null 2>&1 || ps 2>/dev/null | grep -v grep | grep -q \"[x]ray\"; then\n"
" echo amnezia_serving=yes\n"
" exit 0\n"
" fi\n"
" sleep 1\n"
"done\n"
"echo amnezia_serving=no\n");
QString out;
auto collect = [&out](const QString &data, libssh::Client &) {
out += data + "\n";
return ErrorCode::NoError;
};
const ErrorCode errorCode = m_sshSession->runContainerScript(credentials, container, script, collect, collect);
if (errorCode != ErrorCode::NoError) {
return false;
}
return out.contains(QLatin1String("amnezia_serving=yes"));
}
QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentials, DockerContainer container,
const ContainerConfig &containerConfig,
const DnsSettings &dnsSettings,
@@ -381,15 +587,21 @@ QString XrayConfigurator::prepareServerConfig(const ServerCredentials &credentia
XrayProtocolConfig XrayConfigurator::buildClientProtocolConfig(const ServerCredentials &credentials,
DockerContainer container,
const XrayServerConfig &srv, const QString &clientId,
const XrayServerConfig &srv,
const QString &clientId,
ErrorCode &errorCode,
const QString &prefetchedRealityPublicKey,
const QString &prefetchedRealityShortId) const
const QString &prefetchedRealityShortId,
const QString &prefetchedTlsPin) const
{
QString xrayPublicKey = prefetchedRealityPublicKey;
QString xrayShortId = prefetchedRealityShortId;
QString tlsPin = prefetchedTlsPin;
if (srv.security == QLatin1String("reality")) {
const QString securityEff = effectiveSecurity(srv);
if (securityEff == QLatin1String(amnezia::protocols::xray::securityReality)) {
if (xrayPublicKey.isEmpty() || xrayShortId.isEmpty()) {
errorCode = readRealityKeyFiles(container, credentials, xrayPublicKey, xrayShortId);
if (errorCode != ErrorCode::NoError) {
@@ -398,231 +610,40 @@ XrayProtocolConfig XrayConfigurator::buildClientProtocolConfig(const ServerCrede
}
}
QJsonObject userObj;
userObj[amnezia::protocols::xray::id] = clientId;
userObj[amnezia::protocols::xray::encryption] = QStringLiteral("none");
if (!srv.flow.isEmpty()) {
userObj[amnezia::protocols::xray::flow] = srv.flow;
if (securityEff == QLatin1String(amnezia::protocols::xray::securityTls) && !srv.isThirdPartyConfig) {
if (tlsPin.isEmpty()) {
errorCode = ensureTlsCertificate(credentials, container, tlsPin);
if (errorCode != ErrorCode::NoError) {
return {};
}
}
if (tlsPin.isEmpty()) {
errorCode = ErrorCode::XrayTlsNotSupported;
return {};
}
}
QJsonObject vnextEntry;
vnextEntry[amnezia::protocols::xray::address] = credentials.hostName;
vnextEntry[amnezia::protocols::xray::port] =
srv.port.isEmpty() ? QString(amnezia::protocols::xray::defaultPort).toInt() : srv.port.toInt();
vnextEntry[amnezia::protocols::xray::users] = QJsonArray { userObj };
QJsonObject outboundSettings;
outboundSettings[amnezia::protocols::xray::vnext] = QJsonArray { vnextEntry };
QJsonObject outbound;
outbound[QStringLiteral("protocol")] = QStringLiteral("vless");
outbound[amnezia::protocols::xray::settings] = outboundSettings;
QJsonObject streamObj = buildStreamSettings(srv, clientId);
if (srv.security == QLatin1String("reality")) {
QJsonObject rs = streamObj[amnezia::protocols::xray::realitySettings].toObject();
rs[amnezia::protocols::xray::publicKey] = xrayPublicKey;
rs[amnezia::protocols::xray::shortId] = xrayShortId;
rs[amnezia::protocols::xray::spiderX] = QString();
streamObj[amnezia::protocols::xray::realitySettings] = rs;
}
outbound[amnezia::protocols::xray::streamSettings] = streamObj;
QJsonObject inboundObj;
inboundObj[QStringLiteral("listen")] = amnezia::protocols::xray::defaultLocalListenAddr;
inboundObj[amnezia::protocols::xray::port] = amnezia::protocols::xray::defaultLocalProxyPort;
inboundObj[QStringLiteral("protocol")] = QStringLiteral("socks");
inboundObj[amnezia::protocols::xray::settings] = QJsonObject { { QStringLiteral("udp"), true } };
QJsonObject clientJson;
clientJson[QStringLiteral("log")] = QJsonObject { { QStringLiteral("loglevel"), QStringLiteral("error") } };
clientJson[amnezia::protocols::xray::inbounds] = QJsonArray { inboundObj };
clientJson[amnezia::protocols::xray::outbounds] = QJsonArray { outbound };
const QString config = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact));
XrayProtocolConfig protocolConfig;
protocolConfig.serverConfig = srv;
XrayClientOutboundInputs inputs;
inputs.serverAddress = credentials.hostName;
inputs.clientId = clientId;
inputs.realityPublicKey = xrayPublicKey;
inputs.realityShortId = xrayShortId;
inputs.tlsPinnedPeerCertSha256 = tlsPin;
XrayClientConfig clientConfig;
clientConfig.nativeConfig = config;
clientConfig.localPort = QString(amnezia::protocols::xray::defaultLocalProxyPort);
const QJsonObject clientOutbound = protocolConfig.toClientOutboundJson(inputs);
clientConfig.nativeConfig = QString::fromUtf8(QJsonDocument(clientOutbound).toJson(QJsonDocument::Compact));
clientConfig.localPort = QString::fromLatin1(amnezia::protocols::xray::defaultLocalProxyPort);
clientConfig.id = clientId;
protocolConfig.setClientConfig(clientConfig);
return protocolConfig;
}
QJsonObject XrayConfigurator::buildStreamSettings(const XrayServerConfig &srv, const QString &clientId) const
{
QJsonObject streamSettings;
const auto &xhttp = srv.xhttp;
const auto &mkcp = srv.mkcp;
namespace px = amnezia::protocols::xray;
QString networkValue = QStringLiteral("tcp");
if (srv.transport == QLatin1String("xhttp"))
networkValue = QStringLiteral("xhttp");
else if (srv.transport == QLatin1String("mkcp"))
networkValue = QStringLiteral("kcp");
streamSettings[px::network] = networkValue;
streamSettings[px::security] = srv.security;
if (srv.security == QLatin1String("tls")) {
QJsonObject tlsSettings;
const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni;
tlsSettings[px::serverName] = sniEff;
const QString alpnEff = srv.alpn.isEmpty() ? QString::fromLatin1(px::defaultAlpn) : srv.alpn;
QJsonArray alpnArray;
for (const QString &a : alpnEff.split(QLatin1Char(','))) {
const QString t = a.trimmed();
if (!t.isEmpty())
alpnArray.append(t);
}
if (!alpnArray.isEmpty())
tlsSettings[QStringLiteral("alpn")] = alpnArray;
const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint;
tlsSettings[px::fingerprint] = fpEff;
streamSettings[QStringLiteral("tlsSettings")] = tlsSettings;
}
if (srv.security == QLatin1String("reality")) {
QJsonObject realSettings;
const QString fpEff = srv.fingerprint.isEmpty() ? QString::fromLatin1(px::defaultFingerprint) : srv.fingerprint;
realSettings[px::fingerprint] = fpEff;
const QString sniEff = srv.sni.isEmpty() ? QString::fromLatin1(px::defaultSni) : srv.sni;
realSettings[px::serverName] = sniEff;
streamSettings[px::realitySettings] = realSettings;
}
// XHTTP — JSON must match Xray-core SplitHTTPConfig (flat xPadding fields, see transport_internet.go)
if (srv.transport == QLatin1String("xhttp")) {
QJsonObject xo;
const QString hostEff = xhttp.host.isEmpty() ? QString::fromLatin1(px::defaultXhttpHost) : xhttp.host;
xo[QStringLiteral("host")] = hostEff;
if (!xhttp.path.isEmpty())
xo[QStringLiteral("path")] = xhttp.path;
xo[QStringLiteral("mode")] = normalizeXhttpMode(xhttp.mode);
if (xhttp.headersTemplate.compare(QLatin1String("HTTP"), Qt::CaseInsensitive) == 0) {
QJsonObject headers;
headers[QStringLiteral("Host")] = hostEff;
xo[QStringLiteral("headers")] = headers;
}
const QString methodEff =
xhttp.uplinkMethod.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkMethod) : xhttp.uplinkMethod;
xo[QStringLiteral("uplinkHTTPMethod")] = methodEff.toUpper();
xo[QStringLiteral("noGRPCHeader")] = xhttp.disableGrpc;
xo[QStringLiteral("noSSEHeader")] = xhttp.disableSse;
const QString sessPl = normalizeSessionSeqPlacement(xhttp.sessionPlacement);
if (!sessPl.isEmpty())
xo[QStringLiteral("sessionPlacement")] = sessPl;
const QString seqPl = normalizeSessionSeqPlacement(xhttp.seqPlacement);
if (!seqPl.isEmpty())
xo[QStringLiteral("seqPlacement")] = seqPl;
if (!xhttp.sessionKey.isEmpty())
xo[QStringLiteral("sessionKey")] = xhttp.sessionKey;
if (!xhttp.seqKey.isEmpty())
xo[QStringLiteral("seqKey")] = xhttp.seqKey;
xo[QStringLiteral("uplinkDataPlacement")] = normalizeUplinkDataPlacement(xhttp.uplinkDataPlacement);
if (!xhttp.uplinkDataKey.isEmpty())
xo[QStringLiteral("uplinkDataKey")] = xhttp.uplinkDataKey;
const QString ucs = xhttp.uplinkChunkSize.isEmpty() ? QString::fromLatin1(px::defaultXhttpUplinkChunkSize)
: xhttp.uplinkChunkSize;
if (!ucs.isEmpty() && ucs != QLatin1String("0")) {
const int v = ucs.toInt();
QJsonObject chunkR;
chunkR[QStringLiteral("from")] = v;
chunkR[QStringLiteral("to")] = v;
xo[QStringLiteral("uplinkChunkSize")] = chunkR;
}
if (!xhttp.scMaxBufferedPosts.isEmpty())
xo[QStringLiteral("scMaxBufferedPosts")] = xhttp.scMaxBufferedPosts.toLongLong();
putIntRangeIfAny(xo, "scMaxEachPostBytes", xhttp.scMaxEachPostBytesMin, xhttp.scMaxEachPostBytesMax,
px::defaultXhttpScMaxEachPostBytesMin, px::defaultXhttpScMaxEachPostBytesMax);
putIntRangeIfAny(xo, "scMinPostsIntervalMs", xhttp.scMinPostsIntervalMsMin, xhttp.scMinPostsIntervalMsMax,
px::defaultXhttpScMinPostsIntervalMsMin, px::defaultXhttpScMinPostsIntervalMsMax);
putIntRangeIfAny(xo, "scStreamUpServerSecs", xhttp.scStreamUpServerSecsMin, xhttp.scStreamUpServerSecsMax,
px::defaultXhttpScStreamUpServerSecsMin, px::defaultXhttpScStreamUpServerSecsMax);
const auto &pad = xhttp.xPadding;
xo[QStringLiteral("xPaddingObfsMode")] = pad.obfsMode;
if (pad.obfsMode) {
if (!pad.bytesMin.isEmpty() || !pad.bytesMax.isEmpty()) {
QJsonObject br;
const int fromV = pad.bytesMin.isEmpty() ? 1 : pad.bytesMin.toInt();
int toV = pad.bytesMax.isEmpty() ? 256 : pad.bytesMax.toInt();
if (toV < fromV)
toV = fromV;
br[QStringLiteral("from")] = fromV;
br[QStringLiteral("to")] = toV;
xo[QStringLiteral("xPaddingBytes")] = br;
}
xo[QStringLiteral("xPaddingKey")] = pad.key.isEmpty() ? QStringLiteral("x_padding") : pad.key;
xo[QStringLiteral("xPaddingHeader")] = pad.header.isEmpty() ? QStringLiteral("X-Padding") : pad.header;
xo[QStringLiteral("xPaddingPlacement")] = normalizeXPaddingPlacement(
pad.placement.isEmpty() ? QString::fromLatin1(px::defaultXPaddingPlacement) : pad.placement);
xo[QStringLiteral("xPaddingMethod")] = normalizeXPaddingMethod(
pad.method.isEmpty() ? QString::fromLatin1(px::defaultXPaddingMethod) : pad.method);
}
// xmux: Xray has no "enabled" flag; omit object when UI disables multiplex tuning.
if (xhttp.xmux.enabled) {
QJsonObject mux;
auto addMuxRange = [&](const char *key, const QString &a, const QString &b) {
if (a.isEmpty() && b.isEmpty())
return;
QJsonObject r;
r[QStringLiteral("from")] = a.isEmpty() ? 0 : a.toInt();
r[QStringLiteral("to")] = b.isEmpty() ? 0 : b.toInt();
mux[QString::fromUtf8(key)] = r;
};
addMuxRange("maxConcurrency", xhttp.xmux.maxConcurrencyMin, xhttp.xmux.maxConcurrencyMax);
addMuxRange("maxConnections", xhttp.xmux.maxConnectionsMin, xhttp.xmux.maxConnectionsMax);
addMuxRange("cMaxReuseTimes", xhttp.xmux.cMaxReuseTimesMin, xhttp.xmux.cMaxReuseTimesMax);
addMuxRange("hMaxRequestTimes", xhttp.xmux.hMaxRequestTimesMin, xhttp.xmux.hMaxRequestTimesMax);
addMuxRange("hMaxReusableSecs", xhttp.xmux.hMaxReusableSecsMin, xhttp.xmux.hMaxReusableSecsMax);
if (!xhttp.xmux.hKeepAlivePeriod.isEmpty())
mux[QStringLiteral("hKeepAlivePeriod")] = xhttp.xmux.hKeepAlivePeriod.toLongLong();
if (!mux.isEmpty())
xo[QStringLiteral("xmux")] = mux;
}
streamSettings[QStringLiteral("xhttpSettings")] = xo;
}
if (srv.transport == QLatin1String("mkcp")) {
QJsonObject kcpObj;
const QString ttiEff = mkcp.tti.isEmpty() ? QString::fromLatin1(px::defaultMkcpTti) : mkcp.tti;
const QString upEff = mkcp.uplinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpUplinkCapacity)
: mkcp.uplinkCapacity;
const QString downEff = mkcp.downlinkCapacity.isEmpty() ? QString::fromLatin1(px::defaultMkcpDownlinkCapacity)
: mkcp.downlinkCapacity;
const QString rbufEff = mkcp.readBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpReadBufferSize)
: mkcp.readBufferSize;
const QString wbufEff = mkcp.writeBufferSize.isEmpty() ? QString::fromLatin1(px::defaultMkcpWriteBufferSize)
: mkcp.writeBufferSize;
kcpObj[QStringLiteral("tti")] = ttiEff.toInt();
kcpObj[QStringLiteral("uplinkCapacity")] = upEff.toInt();
kcpObj[QStringLiteral("downlinkCapacity")] = downEff.toInt();
kcpObj[QStringLiteral("readBufferSize")] = rbufEff.toInt();
kcpObj[QStringLiteral("writeBufferSize")] = wbufEff.toInt();
kcpObj[QStringLiteral("congestion")] = mkcp.congestion;
streamSettings[QStringLiteral("kcpSettings")] = kcpObj;
}
return streamSettings;
}
ProtocolConfig XrayConfigurator::createConfig(const ServerCredentials &credentials, DockerContainer container,
const ContainerConfig &containerConfig,
const DnsSettings &dnsSettings,
+30 -9
View File
@@ -2,6 +2,7 @@
#define XRAY_CONFIGURATOR_H
#include <QObject>
#include <QJsonArray>
#include <QJsonObject>
#include "configuratorBase.h"
@@ -30,32 +31,52 @@ public:
bool appendNewClient,
QString *outClientId = nullptr);
amnezia::ErrorCode writeServerConfigForSetup(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container,
amnezia::ContainerConfig &containerConfig,
const amnezia::DnsSettings &dnsSettings,
bool useAtomicApply = false);
private:
QJsonArray collectServerClients(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container, const QString &flowValue,
const QString &fallbackClientId, amnezia::ErrorCode &outError) const;
amnezia::ErrorCode uploadServerConfigAtomically(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container, const QString &listenPort,
const QJsonObject &serverConfig) const;
bool restartXrayContainer(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container) const;
bool xrayProcessIsUp(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container) const;
amnezia::ErrorCode readContainerKeyFile(amnezia::DockerContainer container,
const amnezia::ServerCredentials &credentials,
const QString &path, QString &out) const;
QString prepareServerConfig(const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, const amnezia::ContainerConfig &containerConfig,
const amnezia::DnsSettings &dnsSettings,
amnezia::ErrorCode &errorCode);
amnezia::ErrorCode uploadServerConfigJson(const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container,
const amnezia::DnsSettings &dnsSettings, const QJsonObject &serverConfig) const;
amnezia::XrayProtocolConfig buildClientProtocolConfig(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container,
const amnezia::XrayServerConfig &srv,
const QString &clientId,
amnezia::ErrorCode &errorCode,
const QString &prefetchedRealityPublicKey = {},
const QString &prefetchedRealityShortId = {}) const;
const QString &prefetchedRealityShortId = {},
const QString &prefetchedTlsPin = {}) const;
amnezia::ErrorCode readRealityKeyFiles(amnezia::DockerContainer container,
const amnezia::ServerCredentials &credentials,
QString &outPublicKey,
QString &outShortId) const;
QJsonObject mergeStreamSettingsForServerInbound(const amnezia::XrayServerConfig &srv,
const QJsonObject &existingStreamSettings) const;
QJsonObject buildStreamSettings(const amnezia::XrayServerConfig &srv,
const QString &clientId) const;
amnezia::ErrorCode ensureTlsCertificate(const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container,
QString &outFingerprint) const;
};
#endif // XRAY_CONFIGURATOR_H
+15 -15
View File
@@ -2,6 +2,8 @@
#include "core/controllers/gatewayController.h"
#include "core/repositories/secureServersRepository.h"
#include "core/utils/api/apiUtils.h"
#include "core/utils/api/gatewayPayloadBuilder.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/constants/apiConstants.h"
#include <QtConcurrent/QtConcurrent>
@@ -74,22 +76,20 @@ QFuture<QPair<ErrorCode, QJsonArray>> NewsController::fetchNews()
return QtFuture::makeReadyFuture(qMakePair(ErrorCode::NoError, QJsonArray()));
}
auto gatewayController = QSharedPointer<GatewayController>::create(
m_appSettingsRepository->getGatewayEndpoint(),
m_appSettingsRepository->isDevGatewayEnv(),
apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled(),
m_appSettingsRepository);
QSharedPointer<GatewayController> gatewayController(
new GatewayController(m_appSettingsRepository->getGatewayEndpoint(),
m_appSettingsRepository->isDevGatewayEnv(),
apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled(),
m_appSettingsRepository),
&QObject::deleteLater);
QJsonObject payload;
payload.insert("locale", m_appSettingsRepository->getAppLanguage().name().split("_").first());
if (services.contains(apiDefs::key::userCountryCode)) {
payload.insert(apiDefs::key::userCountryCode, services.value(apiDefs::key::userCountryCode));
}
if (services.contains(apiDefs::key::serviceType)) {
payload.insert(apiDefs::key::serviceType, services.value(apiDefs::key::serviceType));
}
// both country codes and service types are arrays here, one entry per gateway stack the user has
const QJsonObject payload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::locale, apiUtils::getAppLanguageCode(m_appSettingsRepository))
.addField(apiDefs::key::userCountryCode, services.value(apiDefs::key::userCountryCode))
.addField(apiDefs::key::serviceType, services.value(apiDefs::key::serviceType))
.build();
auto future = gatewayController->postAsync(QString("%1v1/news"), payload);
return future.then([gatewayController](QPair<ErrorCode, QByteArray> result) -> QPair<ErrorCode, QJsonArray> {
@@ -1,7 +1,6 @@
#include "servicesCatalogController.h"
#include <QJsonDocument>
#include <QSysInfo>
#include <QJsonArray>
#include <QEventLoop>
#include <QDebug>
@@ -11,6 +10,7 @@
#include <limits>
#include "core/controllers/gatewayController.h"
#include "core/utils/api/gatewayPayloadBuilder.h"
#include "core/utils/serverConfigUtils.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/constants/apiConstants.h"
@@ -18,6 +18,9 @@
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "platforms/ios/ios_controller.h"
#elif defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#include "platforms/android/android_utils.h"
#endif
namespace
@@ -28,9 +31,12 @@ namespace
constexpr char subscriptionPlans[] = "subscription_plans";
constexpr char storeProductId[] = "store_product_id";
constexpr char priceLabel[] = "price_label";
constexpr char priceAmount[] = "price_amount";
constexpr char subtitle[] = "subtitle";
constexpr char isTrial[] = "is_trial";
constexpr char minPriceLabel[] = "min_price_label";
constexpr char hasFreeTrial[] = "has_free_trial";
constexpr char trialDays[] = "trial_days";
}
namespace serviceType
@@ -38,12 +44,14 @@ namespace
constexpr char amneziaPremium[] = "amnezia-premium";
}
#if defined(Q_OS_IOS) || defined(MACOS_NE)
struct StoreKitPlanQuote {
#if defined(Q_OS_IOS) || defined(MACOS_NE) || defined(Q_OS_ANDROID)
struct SubscriptionPlanQuote {
QString displayPrice;
double priceAmount = 0.0;
double subscriptionBillingMonths = 0.0;
QString displayPricePerMonth;
bool hasFreeTrial = false;
int trialDays = 0;
};
constexpr double oneMonthThreshold = 1.0 + 1e-6;
@@ -76,48 +84,9 @@ namespace
return productIds;
}
QHash<QString, StoreKitPlanQuote> buildStoreKitQuoteMap(const QList<QVariantMap> &fetchedProducts)
#if defined(Q_OS_IOS) || defined(MACOS_NE)
QHash<QString, SubscriptionPlanQuote> buildStoreKitQuoteMap(const QStringList &productIds)
{
QHash<QString, StoreKitPlanQuote> quotesByProductId;
quotesByProductId.reserve(fetchedProducts.size());
for (const QVariantMap &productInfo : fetchedProducts) {
const QString productId = productInfo.value(QStringLiteral("productId")).toString();
if (productId.isEmpty()) {
continue;
}
QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString();
if (displayPrice.isEmpty()) {
const QString price = productInfo.value(QStringLiteral("price")).toString();
const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString();
displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode);
}
StoreKitPlanQuote quote;
quote.displayPrice = displayPrice;
quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble();
quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble();
quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString();
quotesByProductId.insert(productId, quote);
}
return quotesByProductId;
}
void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{
QJsonArray services = data.value(apiDefs::key::services).toArray();
if (services.isEmpty()) {
return;
}
const QStringList productIds = collectPremiumStoreProductIds(services);
if (productIds.isEmpty()) {
qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload";
return;
}
QList<QVariantMap> fetchedProducts;
QEventLoop loop;
IosController::Instance()->fetchProducts(productIds,
@@ -134,7 +103,117 @@ namespace
});
loop.exec();
const QHash<QString, StoreKitPlanQuote> quotesByProductId = buildStoreKitQuoteMap(fetchedProducts);
QHash<QString, SubscriptionPlanQuote> quotesByProductId;
quotesByProductId.reserve(fetchedProducts.size());
for (const QVariantMap &productInfo : fetchedProducts) {
const QString productId = productInfo.value(QStringLiteral("productId")).toString();
if (productId.isEmpty()) {
continue;
}
QString displayPrice = productInfo.value(QStringLiteral("displayPrice")).toString();
if (displayPrice.isEmpty()) {
const QString price = productInfo.value(QStringLiteral("price")).toString();
const QString currencyCode = productInfo.value(QStringLiteral("currencyCode")).toString();
displayPrice = currencyCode.isEmpty() ? price : (price + QLatin1Char(' ') + currencyCode);
}
SubscriptionPlanQuote quote;
quote.priceAmount = productInfo.value(QStringLiteral("priceAmount")).toDouble();
quote.subscriptionBillingMonths = productInfo.value(QStringLiteral("subscriptionBillingMonths")).toDouble();
quote.displayPricePerMonth = productInfo.value(QStringLiteral("displayPricePerMonth")).toString();
quote.hasFreeTrial = productInfo.value(QStringLiteral("hasFreeTrial")).toBool();
quote.trialDays = productInfo.value(QStringLiteral("trialDays")).toInt();
// If the account is eligible for a paid introductory discount (not a free trial, which
// stays invisible here and just applies silently at purchase time), show that price instead.
const QString introOfferDisplayPrice = productInfo.value(QStringLiteral("introOfferDisplayPrice")).toString();
if (introOfferDisplayPrice.isEmpty()) {
quote.displayPrice = displayPrice;
} else {
quote.displayPrice = introOfferDisplayPrice;
qInfo().noquote() << "[IAP] Applying" << productInfo.value(QStringLiteral("introOfferPaymentMode")).toString()
<< "intro offer price for" << productId << ":" << introOfferDisplayPrice;
}
quotesByProductId.insert(productId, quote);
}
return quotesByProductId;
}
#elif defined(Q_OS_ANDROID)
QHash<QString, SubscriptionPlanQuote> buildGooglePlayQuoteMap(const QStringList &productIds)
{
Q_UNUSED(productIds); // Google Play returns every offer for the app's single product in one call
auto androidController = AndroidController::instance();
const QJsonObject plansResult = AndroidUtils::runOnWorkerThread([androidController]() {
return androidController->getSubscriptionPlans();
});
QHash<QString, SubscriptionPlanQuote> quotesByProductId;
if (plansResult.value("responseCode").toInt(-1) != 0) {
qWarning() << "[Billing] Failed to get subscription plans for price display, responseCode:"
<< plansResult.value("responseCode").toInt(-1);
return quotesByProductId;
}
const QJsonArray products = plansResult.value("products").toArray();
QHash<QString, int> trialDaysByBasePlanId;
for (const QJsonValue &productValue : products) {
const QJsonArray offers = productValue.toObject().value("offers").toArray();
for (const QJsonValue &offerValue : offers) {
const QJsonObject offer = offerValue.toObject();
const QString basePlanId = offer.value("basePlanId").toString();
if (basePlanId.isEmpty() || trialDaysByBasePlanId.contains(basePlanId)) {
continue;
}
if (offer.value("hasFreeTrial").toBool()) {
trialDaysByBasePlanId.insert(basePlanId, offer.value("trialDays").toInt());
}
}
}
for (const QJsonValue &productValue : products) {
const QJsonArray offers = productValue.toObject().value("offers").toArray();
for (const QJsonValue &offerValue : offers) {
const QJsonObject offer = offerValue.toObject();
const QString basePlanId = offer.value("basePlanId").toString();
if (basePlanId.isEmpty() || quotesByProductId.contains(basePlanId)) {
continue;
}
const QJsonArray pricingPhases = offer.value("pricingPhases").toArray();
if (pricingPhases.isEmpty()) {
continue;
}
// Last phase is always the ongoing recurring price, after any trial/intro phases
const QJsonObject regularPhase = pricingPhases.last().toObject();
SubscriptionPlanQuote quote;
quote.displayPrice = regularPhase.value("formattedPrice").toString();
if (quote.displayPrice.isEmpty()) {
continue;
}
quote.priceAmount = regularPhase.value("priceAmountMicros").toDouble() / 1000000.0;
quote.subscriptionBillingMonths = regularPhase.value("subscriptionBillingMonths").toDouble();
quote.displayPricePerMonth = regularPhase.value("displayPricePerMonth").toString();
quote.hasFreeTrial = trialDaysByBasePlanId.contains(basePlanId);
quote.trialDays = trialDaysByBasePlanId.value(basePlanId, 0);
quotesByProductId.insert(basePlanId, quote);
}
}
return quotesByProductId;
}
#endif
void mergeQuotesIntoPremiumPlans(QJsonObject &data, const QHash<QString, SubscriptionPlanQuote> &quotesByProductId)
{
QJsonArray services = data.value(apiDefs::key::services).toArray();
if (services.isEmpty() || quotesByProductId.isEmpty()) {
return;
}
for (int serviceIndex = 0; serviceIndex < services.size(); ++serviceIndex) {
QJsonObject serviceObject = services.at(serviceIndex).toObject();
@@ -166,8 +245,11 @@ namespace
}
const bool isTrialPlan = planObject.value(configKey::isTrial).toBool();
const StoreKitPlanQuote &quote = *quoteIterator;
const SubscriptionPlanQuote &quote = *quoteIterator;
planObject.insert(configKey::priceLabel, quote.displayPrice);
planObject.insert(configKey::priceAmount, quote.priceAmount);
planObject.insert(configKey::hasFreeTrial, quote.hasFreeTrial);
planObject.insert(configKey::trialDays, quote.trialDays);
const double months = quote.subscriptionBillingMonths;
if (!isTrialPlan && months > oneMonthThreshold && !quote.displayPricePerMonth.isEmpty()) {
@@ -202,7 +284,29 @@ namespace
}
data.insert(apiDefs::key::services, services);
}
#if defined(Q_OS_IOS) || defined(MACOS_NE)
void mergeStoreKitPricesIntoPremiumPlans(QJsonObject &data)
{
const QStringList productIds = collectPremiumStoreProductIds(data.value(apiDefs::key::services).toArray());
if (productIds.isEmpty()) {
qInfo().noquote() << "[IAP] No store_product_id in premium plans; skip StoreKit merge into services payload";
return;
}
mergeQuotesIntoPremiumPlans(data, buildStoreKitQuoteMap(productIds));
}
#elif defined(Q_OS_ANDROID)
void mergeGooglePlayPricesIntoPremiumPlans(QJsonObject &data)
{
const QStringList productIds = collectPremiumStoreProductIds(data.value(apiDefs::key::services).toArray());
if (productIds.isEmpty()) {
qInfo().noquote() << "[Billing] No store_product_id in premium plans; skip Google Play merge into services payload";
return;
}
mergeQuotesIntoPremiumPlans(data, buildGooglePlayQuoteMap(productIds));
}
#endif
#endif // Q_OS_IOS || MACOS_NE || Q_OS_ANDROID
}
ServicesCatalogController::ServicesCatalogController(SecureAppSettingsRepository* appSettingsRepository)
@@ -212,11 +316,7 @@ ServicesCatalogController::ServicesCatalogController(SecureAppSettingsRepository
ErrorCode ServicesCatalogController::fillAvailableServices(QJsonObject &servicesData)
{
QJsonObject apiPayload;
apiPayload[apiDefs::key::osVersion] = QSysInfo::productType();
apiPayload[apiDefs::key::appVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::cliName] = QString(APPLICATION_NAME);
apiPayload[apiDefs::key::appLanguage] = m_appSettingsRepository->getAppLanguage().name().split("_").first();
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository).build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/services"), apiPayload, responseBody);
@@ -234,6 +334,8 @@ ErrorCode ServicesCatalogController::fillAvailableServices(QJsonObject &services
#if defined(Q_OS_IOS) || defined(MACOS_NE)
mergeStoreKitPricesIntoPremiumPlans(servicesData);
#elif defined(Q_OS_ANDROID)
mergeGooglePlayPricesIntoPremiumPlans(servicesData);
#endif
return ErrorCode::NoError;
@@ -0,0 +1,781 @@
#include "storePurchaseController.h"
#include <QDebug>
#include <QEventLoop>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QVariantMap>
#include "core/controllers/gatewayController.h"
#include "core/models/api/apiConfig.h"
#include "core/utils/api/gatewayPayloadBuilder.h"
#include "core/utils/constants/apiConstants.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/constants/configKeys.h"
#include "core/utils/serverConfigUtils.h"
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "platforms/ios/ios_controller.h"
#elif defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#include "platforms/android/android_utils.h"
#endif
using namespace amnezia;
#if defined(Q_OS_IOS) || defined(MACOS_NE)
namespace
{
bool isTestPurchaseEnvironment(const QString &storeEnvironment)
{
if (storeEnvironment.isEmpty()) {
return IosController::Instance()->isTestFlight();
}
return storeEnvironment.compare(QStringLiteral("production"), Qt::CaseInsensitive) != 0;
}
}
#endif
#if defined(Q_OS_ANDROID)
namespace
{
constexpr int purchaseStatePurchased = 1;
constexpr int purchaseStatePending = 2;
ErrorCode billingErrorFromResponse(int responseCode)
{
if (responseCode >= ErrorCode::BillingCanceled && responseCode <= ErrorCode::BillingNetworkError) {
return static_cast<ErrorCode>(responseCode);
}
return ErrorCode::ApiPurchaseError;
}
struct PlayPurchaseOutcome
{
ErrorCode errorCode = ErrorCode::NoError;
QString purchaseToken;
bool isUpgrade = false;
bool isAcknowledged = false;
};
void acknowledgePlayPurchase(const QString &purchaseToken, bool isAcknowledged)
{
if (isAcknowledged) {
return;
}
auto androidController = AndroidController::instance();
const QJsonObject ackResult = AndroidUtils::runOnWorkerThread([androidController, purchaseToken]() {
return androidController->acknowledgePurchase(purchaseToken);
});
if (ackResult.value("responseCode").toInt(-1) != 0) {
qWarning() << "[Billing] Acknowledge failed, responseCode:" << ackResult.value("responseCode").toInt(-1);
} else {
qInfo() << "[Billing] Purchase acknowledged successfully";
}
}
QJsonArray queryPlayPurchases()
{
auto androidController = AndroidController::instance();
const QJsonObject purchasesResult = AndroidUtils::runOnWorkerThread([androidController]() {
return androidController->queryPurchases();
});
if (purchasesResult.value("responseCode").toInt(-1) != 0) {
qWarning().noquote() << "[Billing] queryPurchases failed, responseCode ="
<< purchasesResult.value("responseCode").toInt(-1);
return {};
}
return purchasesResult.value("purchases").toArray();
}
bool isPaidButUnacknowledged(const QJsonObject &purchaseObj)
{
return purchaseObj.value("purchaseState").toInt(-1) == purchaseStatePurchased
&& !purchaseObj.value("isAcknowledged").toBool(true)
&& !purchaseObj.value("purchaseToken").toString().isEmpty();
}
}
#endif
StorePurchaseController::StorePurchaseController(SecureServersRepository *serversRepository,
SecureAppSettingsRepository *appSettingsRepository)
: m_serversRepository(serversRepository), m_appSettingsRepository(appSettingsRepository)
{
}
ErrorCode StorePurchaseController::executeRequest(const QString &endpoint, const QJsonObject &apiPayload,
QByteArray &responseBody, bool isTestPurchase)
{
GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), apiDefs::requestTimeoutMsecs,
m_appSettingsRepository->isStrictKillSwitchEnabled(), m_appSettingsRepository);
return gatewayController.post(endpoint, apiPayload, responseBody);
}
ErrorCode StorePurchaseController::getSubscriptionInfo(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &purchaseToken,
QByteArray &responseBody)
{
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, userCountryCode)
.addField(apiDefs::key::serviceType, serviceType)
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::transactionId, purchaseToken)
.build();
return executeRequest(QString("%1v1/get_subscription_info"), apiPayload, responseBody, false);
}
ErrorCode StorePurchaseController::importServiceFromMarket(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol,
const SubscriptionController::ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex, const QString &endpoint)
{
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, userCountryCode)
.addField(apiDefs::key::serviceType, serviceType)
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, SubscriptionController::publicKeyForProtocol(serviceProtocol, protocolData))
.addField(apiDefs::key::transactionId, transactionId)
.build();
QByteArray responseBody;
qInfo() << "[Billing][importServiceFromMarket] endpoint:" << endpoint << "isTestPurchase:" << isTestPurchase;
ErrorCode errorCode = executeRequest(QString("%1") + endpoint, apiPayload, responseBody, isTestPurchase);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
// Parse the subscription response
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
QString key = responseObject.value(QStringLiteral("key")).toString();
if (key.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
return ErrorCode::ApiPurchaseError;
}
QString normalizedKey = key;
normalizedKey.replace(QStringLiteral("vpn://"), QString());
// Check if server with this VPN key already exists
for (int i = 0; i < m_serversRepository->serversCount(); ++i) {
const auto apiV2 = m_serversRepository->apiV2Config(m_serversRepository->serverIdAt(i));
QString existingVpnKey = apiV2.has_value() ? apiV2->vpnKey() : QString();
existingVpnKey.replace(QStringLiteral("vpn://"), QString());
if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) {
if (duplicateServerIndex) {
*duplicateServerIndex = i;
}
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
return ErrorCode::ApiConfigAlreadyAdded;
}
}
QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configString);
if (!configUncompressed.isEmpty()) {
configString = configUncompressed;
}
if (configString.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
return ErrorCode::ApiPurchaseError;
}
QJsonObject configObject = QJsonDocument::fromJson(configString).object();
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
if (!serverConfigUtils::isConfigFormatVersionSupported(configObject)) {
return ErrorCode::ConfigFormatVersionNotSupportedError;
}
if (configObject.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
ApiV2ServerConfig apiV2ServerConfig = ApiV2ServerConfig::fromJson(configObject);
apiV2ServerConfig.apiConfig.vpnKey = normalizedKey;
apiV2ServerConfig.apiConfig.subscriptionExpiredByServer = false;
apiV2ServerConfig.crc = crc;
m_serversRepository->addServer(QString(), apiV2ServerConfig.toJson(),
serverConfigUtils::configTypeFromJson(apiV2ServerConfig.toJson()));
return ErrorCode::NoError;
}
ErrorCode StorePurchaseController::processAppStorePurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex, bool *wasUpgrade)
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
const bool hadActivePlanBeforePurchase = !resolveActiveStoreProductIds().isEmpty();
if (wasUpgrade) {
*wasUpgrade = hadActivePlanBeforePurchase;
}
bool purchaseOk = false;
QString originalTransactionId;
QString storeTransactionId;
QString storeProductId;
QString storeEnvironment;
QString purchaseError;
IosController::StorePurchaseFailure failureReason = IosController::StorePurchaseFailure::Other;
QEventLoop waitPurchase;
IosController::Instance()->purchaseProduct(productId,
[&](bool success, const QString &txId, const QString &purchasedProductId,
const QString &originalTxId, const QString &environment,
const QString &errorString,
IosController::StorePurchaseFailure reason) {
purchaseOk = success;
originalTransactionId = originalTxId;
storeTransactionId = txId;
storeProductId = purchasedProductId;
storeEnvironment = environment;
purchaseError = errorString;
failureReason = reason;
waitPurchase.quit();
});
waitPurchase.exec();
if (!purchaseOk || originalTransactionId.isEmpty()) {
qDebug() << "IAP purchase failed:" << purchaseError;
switch (failureReason) {
case IosController::StorePurchaseFailure::Cancelled: return ErrorCode::BillingCanceled;
case IosController::StorePurchaseFailure::Pending: return ErrorCode::ApiPurchasePendingError;
default: return ErrorCode::ApiPurchaseError;
}
}
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId;
bool isTestPurchase = isTestPurchaseEnvironment(storeEnvironment);
SubscriptionController::ProtocolData protocolData = SubscriptionController::generateProtocolData(serviceProtocol);
ErrorCode importError = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase, duplicateServerIndex,
QStringLiteral("v1/subscriptions"));
// Finish the StoreKit transaction only after the gateway has validated the purchase;
// otherwise it stays in the unfinished queue and is retried via Transaction.updates
if (importError == ErrorCode::NoError || importError == ErrorCode::ApiConfigAlreadyAdded) {
IosController::Instance()->finishStoreTransaction(storeTransactionId);
}
return importError;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
Q_UNUSED(productId);
Q_UNUSED(duplicateServerIndex);
Q_UNUSED(wasUpgrade);
return ErrorCode::ApiPurchaseError;
#endif
}
ErrorCode StorePurchaseController::processPlayMarketPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex, bool *wasUpgrade)
{
#if defined(Q_OS_ANDROID)
auto androidController = AndroidController::instance();
const PlayPurchaseOutcome outcome = AndroidUtils::runOnWorkerThread([androidController, productId]() {
PlayPurchaseOutcome outcome;
QString oldPurchaseToken;
QJsonObject existingPurchasesResult = androidController->queryPurchases();
if (existingPurchasesResult.value("responseCode").toInt(-1) == 0) {
const QJsonArray existingPurchases = existingPurchasesResult.value("purchases").toArray();
for (const QJsonValue &purchaseValue : existingPurchases) {
const QJsonObject existingPurchase = purchaseValue.toObject();
if (existingPurchase.value("purchaseState").toInt(-1) == purchaseStatePurchased) {
oldPurchaseToken = existingPurchase.value("purchaseToken").toString();
qInfo().noquote() << "[Billing] Found existing active subscription, will upgrade instead of purchasing a new one";
break;
}
}
}
outcome.isUpgrade = !oldPurchaseToken.isEmpty();
QJsonObject plansResult = androidController->getSubscriptionPlans();
int responseCode = plansResult.value("responseCode").toInt(-1);
if (responseCode != 0) {
qWarning() << "[Billing] Failed to get subscription plans, responseCode:" << responseCode;
outcome.errorCode = billingErrorFromResponse(responseCode);
return outcome;
}
QJsonArray products = plansResult.value("products").toArray();
QString offerToken;
QString fallbackOfferToken;
for (const QJsonValue &productValue : products) {
QJsonObject product = productValue.toObject();
QJsonArray offers = product.value("offers").toArray();
for (const QJsonValue &offerValue : offers) {
QJsonObject offer = offerValue.toObject();
if (offer.value("basePlanId").toString() != productId) continue;
const QString token = offer.value("offerToken").toString();
QJsonArray pricingPhases = offer.value("pricingPhases").toArray();
const bool hasFreeTrial = !pricingPhases.isEmpty()
&& pricingPhases.first().toObject().value("priceAmountMicros").toDouble() == 0;
if (hasFreeTrial && !oldPurchaseToken.isEmpty()) continue;
if (fallbackOfferToken.isEmpty()) fallbackOfferToken = token;
if (hasFreeTrial) {
offerToken = token;
qInfo() << "[Billing] Found free trial offer for basePlanId:" << productId;
break;
}
}
if (!offerToken.isEmpty()) break;
}
if (offerToken.isEmpty()) offerToken = fallbackOfferToken;
if (offerToken.isEmpty()) {
qWarning() << "[Billing] No offer token found for basePlanId:" << productId;
outcome.errorCode = ErrorCode::SubscriptionUnavailable;
return outcome;
}
QJsonObject purchaseResult = oldPurchaseToken.isEmpty()
? androidController->purchaseSubscription(offerToken)
: androidController->upgradeSubscription(offerToken, oldPurchaseToken);
responseCode = purchaseResult.value("responseCode").toInt(-1);
if (responseCode != 0) {
qWarning() << "[Billing] Purchase failed, responseCode:" << responseCode;
outcome.errorCode = billingErrorFromResponse(responseCode);
return outcome;
}
QJsonArray purchases = purchaseResult.value("purchases").toArray();
if (purchases.isEmpty()) {
qWarning() << "[Billing] Purchase succeeded but no purchases returned";
outcome.errorCode = ErrorCode::ApiPurchaseError;
return outcome;
}
QJsonObject purchase = purchases.at(0).toObject();
outcome.purchaseToken = purchase.value("purchaseToken").toString();
outcome.isAcknowledged = purchase.value("isAcknowledged").toBool();
int purchaseState = purchase.value("purchaseState").toInt(-1);
qInfo().noquote() << "[Billing] Purchase success. purchaseToken:" << outcome.purchaseToken
<< "isAcknowledged:" << outcome.isAcknowledged << "purchaseState:" << purchaseState;
// purchaseState: 1 = PURCHASED, 2 = PENDING (user must confirm payment in Google Play), 0 = UNSPECIFIED
if (purchaseState == purchaseStatePending) {
qWarning() << "[Billing] Purchase is in PENDING state, waiting for user to confirm payment";
outcome.errorCode = ErrorCode::ApiPurchasePendingError;
return outcome;
}
if (purchaseState != purchaseStatePurchased || outcome.purchaseToken.isEmpty()) {
outcome.errorCode = ErrorCode::ApiPurchaseError;
return outcome;
}
return outcome;
});
if (outcome.errorCode != ErrorCode::NoError) {
return outcome.errorCode;
}
if (wasUpgrade) {
*wasUpgrade = outcome.isUpgrade;
}
if (outcome.purchaseToken != m_lastPlayPurchaseToken) {
m_lastPlayPurchaseToken = outcome.purchaseToken;
m_lastPlayBasePlanId = productId;
}
return finalizePlayPurchase(userCountryCode, serviceType, serviceProtocol, outcome.purchaseToken,
outcome.isAcknowledged, duplicateServerIndex, QStringLiteral("v1/subscriptions"));
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
Q_UNUSED(productId);
Q_UNUSED(duplicateServerIndex);
Q_UNUSED(wasUpgrade);
return ErrorCode::ApiPurchaseError;
#endif
}
StorePurchaseController::StoreRestoreResult StorePurchaseController::processAppStoreRestore(const QString &userCountryCode,
const QString &serviceType,
const QString &serviceProtocol)
{
StoreRestoreResult result;
#if defined(Q_OS_IOS) || defined(MACOS_NE)
bool restoreSuccess = false;
QList<QVariantMap> restoredTransactions;
QString restoreError;
QEventLoop waitRestore;
IosController::Instance()->restorePurchases([&](bool success, const QList<QVariantMap> &transactions, const QString &errorString) {
restoreSuccess = success;
restoredTransactions = transactions;
restoreError = errorString;
waitRestore.quit();
});
waitRestore.exec();
qInfo().noquote() << "[IAP][processAppStoreRestore] restorePurchases result: success=" << restoreSuccess
<< "transactions count=" << restoredTransactions.size()
<< "error=" << restoreError;
if (!restoreSuccess) {
qWarning().noquote() << "[IAP] Restore failed:" << restoreError;
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
}
if (restoredTransactions.isEmpty()) {
qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned";
result.errorCode = ErrorCode::ApiNoPurchasesToRestore;
return result;
}
QSet<QString> processedTransactions;
for (const QVariantMap &transaction : restoredTransactions) {
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
const QString transactionProductId = transaction.value(QStringLiteral("productId")).toString();
const bool isTestPurchase = isTestPurchaseEnvironment(transaction.value(QStringLiteral("environment")).toString());
qInfo().noquote() << "[IAP][processAppStoreRestore] Processing transaction: transactionId=" << transactionId
<< "originalTransactionId=" << originalTransactionId << "productId=" << transactionProductId;
if (originalTransactionId.isEmpty()) {
qWarning().noquote() << "[IAP] Skipping restored transaction without originalTransactionId" << transactionId;
continue;
}
if (processedTransactions.contains(originalTransactionId)) {
result.duplicateCount++;
continue;
}
processedTransactions.insert(originalTransactionId);
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << transactionProductId;
SubscriptionController::ProtocolData protocolData = SubscriptionController::generateProtocolData(serviceProtocol);
int currentDuplicateServerIndex = -1;
ErrorCode errorCode = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase,
&currentDuplicateServerIndex,
QStringLiteral("v1/restore_subscription"));
qInfo().noquote() << "[IAP][processAppStoreRestore] importServiceFromMarket errorCode=" << static_cast<int>(errorCode)
<< "for originalTransactionId=" << originalTransactionId;
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
result.duplicateConfigAlreadyPresent = true;
if (result.duplicateServerIndex < 0) {
result.duplicateServerIndex = currentDuplicateServerIndex;
}
qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId
<< "because subscription config with the same vpn_key already exists";
} else if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId
<< "errorCode=" << static_cast<int>(errorCode);
result.errorCode = errorCode;
} else {
result.hasInstalledConfig = true;
}
}
if (!result.hasInstalledConfig && result.errorCode == ErrorCode::NoError) {
result.errorCode = result.duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
}
qInfo().noquote() << "[IAP][processAppStoreRestore] Done. hasInstalledConfig=" << result.hasInstalledConfig
<< "duplicateConfigAlreadyPresent=" << result.duplicateConfigAlreadyPresent
<< "duplicateCount=" << result.duplicateCount
<< "errorCode=" << static_cast<int>(result.errorCode);
return result;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
#endif
}
ErrorCode StorePurchaseController::processAppStoreTransactionUpdate(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &originalTransactionId,
const QString &transactionId, const QString &storeEnvironment,
int *duplicateServerIndex)
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
if (originalTransactionId.isEmpty()) {
return ErrorCode::ApiPurchaseError;
}
qInfo().noquote() << "[IAP] Processing transaction update. transactionId =" << transactionId
<< "originalTransactionId =" << originalTransactionId << "environment =" << storeEnvironment;
bool isTestPurchase = isTestPurchaseEnvironment(storeEnvironment);
SubscriptionController::ProtocolData protocolData = SubscriptionController::generateProtocolData(serviceProtocol);
ErrorCode errorCode = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase, duplicateServerIndex,
QStringLiteral("v1/restore_subscription"));
// On validation failure the transaction is left unfinished so it is redelivered
// by the Transaction.updates listener on the next app launch
if (errorCode == ErrorCode::NoError || errorCode == ErrorCode::ApiConfigAlreadyAdded) {
IosController::Instance()->finishStoreTransaction(transactionId);
}
return errorCode;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
Q_UNUSED(originalTransactionId);
Q_UNUSED(transactionId);
Q_UNUSED(storeEnvironment);
Q_UNUSED(duplicateServerIndex);
return ErrorCode::ApiPurchaseError;
#endif
}
StorePurchaseController::StoreRestoreResult StorePurchaseController::processPlayMarketRestore(const QString &userCountryCode,
const QString &serviceType,
const QString &serviceProtocol)
{
StoreRestoreResult result;
#if defined(Q_OS_ANDROID)
auto androidController = AndroidController::instance();
const QJsonObject purchasesResult = AndroidUtils::runOnWorkerThread([androidController]() {
return androidController->queryPurchases();
});
int responseCode = purchasesResult.value("responseCode").toInt(-1);
if (responseCode != 0) {
qWarning().noquote() << "[Billing] queryPurchases failed, responseCode =" << responseCode;
result.errorCode = billingErrorFromResponse(responseCode);
return result;
}
QJsonArray purchases = purchasesResult.value("purchases").toArray();
if (purchases.isEmpty()) {
qInfo().noquote() << "[Billing] Restore completed, but no purchases were found";
result.errorCode = ErrorCode::ApiNoPurchasesToRestore;
return result;
}
QSet<QString> processedTokens;
for (const QJsonValue &purchaseValue : std::as_const(purchases)) {
const QJsonObject purchaseObj = purchaseValue.toObject();
const QString purchaseToken = purchaseObj.value("purchaseToken").toString();
if (purchaseToken.isEmpty()) {
qWarning().noquote() << "[Billing] Skipping purchase without purchaseToken";
continue;
}
if (purchaseObj.value("purchaseState").toInt(-1) != purchaseStatePurchased) {
qInfo().noquote() << "[Billing] Skipping purchase in state" << purchaseObj.value("purchaseState").toInt(-1);
continue;
}
if (processedTokens.contains(purchaseToken)) {
result.duplicateCount++;
continue;
}
processedTokens.insert(purchaseToken);
qInfo().noquote() << "[Billing] Restoring subscription";
int currentDuplicateServerIndex = -1;
ErrorCode errorCode = finalizePlayPurchase(userCountryCode, serviceType, serviceProtocol,
purchaseToken, purchaseObj.value("isAcknowledged").toBool(),
&currentDuplicateServerIndex, QStringLiteral("v1/restore_subscription"));
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
result.duplicateConfigAlreadyPresent = true;
if (result.duplicateServerIndex < 0) {
result.duplicateServerIndex = currentDuplicateServerIndex;
}
qInfo().noquote() << "[Billing] Skipping purchase because subscription config with the same vpn_key already exists";
} else if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[Billing] Failed to process restored subscription, errorCode =" << static_cast<int>(errorCode);
result.errorCode = errorCode;
} else {
result.hasInstalledConfig = true;
}
}
if (!result.hasInstalledConfig && !result.duplicateConfigAlreadyPresent && result.errorCode == ErrorCode::NoError) {
result.errorCode = ErrorCode::ApiNoPurchasesToRestore;
} else if (!result.hasInstalledConfig && result.duplicateConfigAlreadyPresent && result.errorCode == ErrorCode::NoError) {
result.errorCode = ErrorCode::ApiConfigAlreadyAdded;
}
return result;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
#endif
}
#if defined(Q_OS_ANDROID)
ErrorCode StorePurchaseController::finalizePlayPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &purchaseToken,
bool isAcknowledged, int *duplicateServerIndex, const QString &endpoint)
{
QByteArray checkResponse;
ErrorCode checkError = getSubscriptionInfo(userCountryCode, serviceType, serviceProtocol, purchaseToken, checkResponse);
if (checkError != ErrorCode::NoError) {
qWarning().noquote() << "[Billing] Initial subscriptions check failed:" << static_cast<int>(checkError);
return checkError;
}
QJsonObject checkObject = QJsonDocument::fromJson(checkResponse).object();
bool isTestPurchase = checkObject.value(apiDefs::key::isTestPurchase).toBool(false);
qInfo().noquote() << "[Billing] Purchase isTestPurchase =" << isTestPurchase;
SubscriptionController::ProtocolData protocolData = SubscriptionController::generateProtocolData(serviceProtocol);
ErrorCode errorCode = importServiceFromMarket(userCountryCode, serviceType, serviceProtocol, protocolData,
purchaseToken, isTestPurchase, duplicateServerIndex, endpoint);
if (errorCode == ErrorCode::NoError || errorCode == ErrorCode::ApiConfigAlreadyAdded) {
acknowledgePlayPurchase(purchaseToken, isAcknowledged);
}
return errorCode;
}
#endif
QJsonArray StorePurchaseController::findUnacknowledgedPlayPurchases()
{
#if defined(Q_OS_ANDROID)
QJsonArray unacknowledged;
for (const QJsonValue &purchaseValue : queryPlayPurchases()) {
if (isPaidButUnacknowledged(purchaseValue.toObject())) {
unacknowledged.append(purchaseValue);
}
}
return unacknowledged;
#else
return {};
#endif
}
bool StorePurchaseController::processUnacknowledgedPlayPurchases(const QJsonArray &purchases, const QString &userCountryCode,
const QString &serviceType, const QString &serviceProtocol)
{
#if defined(Q_OS_ANDROID)
bool installedNewConfig = false;
QSet<QString> processedTokens;
for (const QJsonValue &purchaseValue : purchases) {
const QJsonObject purchaseObj = purchaseValue.toObject();
const QString purchaseToken = purchaseObj.value("purchaseToken").toString();
if (processedTokens.contains(purchaseToken)) {
continue;
}
processedTokens.insert(purchaseToken);
qInfo().noquote() << "[Billing] Found paid but unacknowledged purchase, validating";
ErrorCode errorCode = finalizePlayPurchase(userCountryCode, serviceType, serviceProtocol,
purchaseToken, false, nullptr, QStringLiteral("v1/restore_subscription"));
if (errorCode == ErrorCode::NoError) {
installedNewConfig = true;
} else if (errorCode != ErrorCode::ApiConfigAlreadyAdded) {
qWarning().noquote() << "[Billing] Purchase validation failed, errorCode =" << static_cast<int>(errorCode)
<< "- will retry on next launch";
}
}
return installedNewConfig;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
return false;
#endif
}
QStringList StorePurchaseController::resolveActiveStoreProductIds()
{
QStringList activeProductIds;
#if defined(Q_OS_ANDROID)
auto androidController = AndroidController::instance();
const QJsonObject purchasesResult = AndroidUtils::runOnWorkerThread([androidController]() {
return androidController->queryPurchases();
});
if (purchasesResult.value("responseCode").toInt(-1) != 0) {
qWarning().noquote() << "[Billing][resolveActiveStoreProductIds] queryPurchases failed";
return activeProductIds;
}
const QJsonArray purchases = purchasesResult.value("purchases").toArray();
for (const QJsonValue &purchaseValue : purchases) {
const QJsonObject purchaseObj = purchaseValue.toObject();
const QString purchaseToken = purchaseObj.value("purchaseToken").toString();
qInfo().noquote() << "[Billing][resolveActiveStoreProductIds] purchase found."
<< "purchaseState:" << purchaseObj.value("purchaseState").toInt()
<< "productIds:" << purchaseObj.value("productIds").toArray();
if (purchaseObj.value("purchaseState").toInt() != purchaseStatePurchased) {
continue;
}
if (!m_lastPlayBasePlanId.isEmpty() && !purchaseToken.isEmpty() && purchaseToken == m_lastPlayPurchaseToken) {
if (!activeProductIds.contains(m_lastPlayBasePlanId)) {
activeProductIds.append(m_lastPlayBasePlanId);
}
continue;
}
const QJsonArray productIds = purchaseObj.value("productIds").toArray();
for (const QJsonValue &productIdValue : productIds) {
const QString productId = productIdValue.toString();
if (!productId.isEmpty() && !activeProductIds.contains(productId)) {
activeProductIds.append(productId);
}
}
}
#elif defined(Q_OS_IOS) || defined(MACOS_NE)
bool restoreSuccess = false;
QList<QVariantMap> transactions;
QString restoreError;
QEventLoop waitRestore;
IosController::Instance()->fetchLocalEntitlements([&](bool success, const QList<QVariantMap> &localTransactions, const QString &errorString) {
restoreSuccess = success;
transactions = localTransactions;
restoreError = errorString;
waitRestore.quit();
});
waitRestore.exec();
if (!restoreSuccess) {
qWarning().noquote() << "[Billing][resolveActiveStoreProductIds] fetchLocalEntitlements failed:" << restoreError;
return activeProductIds;
}
for (const QVariantMap &transaction : std::as_const(transactions)) {
const QString productId = transaction.value("productId").toString();
if (!productId.isEmpty() && !activeProductIds.contains(productId)) {
activeProductIds.append(productId);
}
}
#endif
return activeProductIds;
}
@@ -0,0 +1,89 @@
#ifndef STOREPURCHASECONTROLLER_H
#define STOREPURCHASECONTROLLER_H
#include <QByteArray>
#include <QJsonArray>
#include <QString>
#include <QStringList>
#include "core/controllers/api/subscriptionController.h"
#include "core/repositories/secureAppSettingsRepository.h"
#include "core/repositories/secureServersRepository.h"
#include "core/utils/errorCodes.h"
// In-app purchase flows for App Store and Play Market: purchase, restore,
// gateway validation and store transaction lifecycle (finish / acknowledge)
class StorePurchaseController
{
public:
struct StoreRestoreResult
{
bool hasInstalledConfig = false;
bool duplicateConfigAlreadyPresent = false;
int duplicateCount = 0;
int duplicateServerIndex = -1;
ErrorCode errorCode = ErrorCode::NoError;
};
explicit StorePurchaseController(SecureServersRepository *serversRepository,
SecureAppSettingsRepository *appSettingsRepository);
ErrorCode processAppStorePurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex = nullptr, bool *wasUpgrade = nullptr);
ErrorCode processPlayMarketPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex = nullptr, bool *wasUpgrade = nullptr);
StoreRestoreResult processAppStoreRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol);
StoreRestoreResult processPlayMarketRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol);
// Validates a transaction delivered by the StoreKit Transaction.updates listener
// (Ask to Buy approval, interrupted purchase, renewal) and finishes it on success
ErrorCode processAppStoreTransactionUpdate(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &originalTransactionId,
const QString &transactionId, const QString &storeEnvironment,
int *duplicateServerIndex = nullptr);
QStringList resolveActiveStoreProductIds();
// Android counterpart of the iOS Transaction.updates listener: purchases that were
// paid but never acknowledged (validation failed earlier, or a PENDING purchase was
// completed outside the app). Query once with findUnacknowledgedPlayPurchases(), then
// pass the result to processUnacknowledgedPlayPurchases() to validate each purchase on
// the gateway and acknowledge it on success.
QJsonArray findUnacknowledgedPlayPurchases();
bool processUnacknowledgedPlayPurchases(const QJsonArray &purchases, const QString &userCountryCode,
const QString &serviceType, const QString &serviceProtocol);
private:
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody,
bool isTestPurchase = false);
ErrorCode getSubscriptionInfo(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &purchaseToken,
QByteArray &responseBody);
ErrorCode importServiceFromMarket(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol,
const SubscriptionController::ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex, const QString &endpoint);
// Defined for Android builds only
ErrorCode finalizePlayPurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &purchaseToken,
bool isAcknowledged, int *duplicateServerIndex, const QString &endpoint);
SecureServersRepository *m_serversRepository;
SecureAppSettingsRepository *m_appSettingsRepository;
QString m_lastPlayPurchaseToken;
QString m_lastPlayBasePlanId;
};
#endif // STOREPURCHASECONTROLLER_H
@@ -2,15 +2,11 @@
#include <QDebug>
#include <QDateTime>
#include <QEventLoop>
#include <QFutureWatcher>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPromise>
#include <QSet>
#include <QSysInfo>
#include <QUuid>
#include <QVariantMap>
#include "core/configurators/openVpnConfigurator.h"
#include "core/configurators/wireguardConfigurator.h"
@@ -21,6 +17,7 @@
#include "core/utils/constants/apiKeys.h"
#include "core/utils/constants/apiConstants.h"
#include "core/utils/api/apiUtils.h"
#include "core/utils/api/gatewayPayloadBuilder.h"
#include "core/controllers/gatewayController.h"
#include "core/utils/protocolEnum.h"
#include "core/protocols/protocolUtils.h"
@@ -31,8 +28,7 @@
#include "core/models/api/apiConfig.h"
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "platforms/ios/ios_controller.h"
#include <AmneziaVPN-Swift.h>
#include "core/utils/swiftBridge.h"
#endif
using namespace amnezia;
@@ -97,39 +93,6 @@ SubscriptionController::SubscriptionController(SecureServersRepository* serversR
{
}
QJsonObject SubscriptionController::GatewayRequestData::toJsonObject() const
{
QJsonObject obj;
if (!osVersion.isEmpty()) {
obj[apiDefs::key::osVersion] = osVersion;
}
if (!appVersion.isEmpty()) {
obj[apiDefs::key::appVersion] = appVersion;
}
if (!appLanguage.isEmpty()) {
obj[apiDefs::key::appLanguage] = appLanguage;
}
if (!installationUuid.isEmpty()) {
obj[apiDefs::key::uuid] = installationUuid;
}
if (!userCountryCode.isEmpty()) {
obj[apiDefs::key::userCountryCode] = userCountryCode;
}
if (!serverCountryCode.isEmpty()) {
obj[apiDefs::key::serverCountryCode] = serverCountryCode;
}
if (!serviceType.isEmpty()) {
obj[apiDefs::key::serviceType] = serviceType;
}
if (!serviceProtocol.isEmpty()) {
obj[apiDefs::key::serviceProtocol] = serviceProtocol;
}
if (!authData.isEmpty()) {
obj[apiDefs::key::authData] = authData;
}
return obj;
}
SubscriptionController::ProtocolData SubscriptionController::generateProtocolData(const QString &protocol)
{
ProtocolData protocolData;
@@ -144,16 +107,18 @@ SubscriptionController::ProtocolData SubscriptionController::generateProtocolDat
return protocolData;
}
void SubscriptionController::appendProtocolDataToApiPayload(const QString &protocol, const ProtocolData &protocolData, QJsonObject &apiPayload)
QString SubscriptionController::publicKeyForProtocol(const QString &protocol, const ProtocolData &protocolData)
{
if (protocol == configKey::awg) {
apiPayload[apiDefs::key::publicKey] = protocolData.wireGuardClientPubKey;
} else if (protocol == configKey::vless) {
apiPayload[apiDefs::key::publicKey] = protocolData.xrayUuid;
return protocolData.wireGuardClientPubKey;
}
if (protocol == configKey::vless) {
return protocolData.xrayUuid;
}
return {};
}
ErrorCode SubscriptionController::extractServerConfigJsonFromResponse(const QByteArray &apiResponseBody, const QString &protocol,
ErrorCode SubscriptionController::extractServerConfigJsonFromResponse(const QByteArray &apiResponseBody, const QString &protocol,
const ProtocolData &protocolData, QJsonObject &serverConfigJson)
{
QString data = QJsonDocument::fromJson(apiResponseBody).object().value(configKey::config).toString();
@@ -188,23 +153,14 @@ ErrorCode SubscriptionController::extractServerConfigJsonFromResponse(const QByt
// TODO looks like this block can be removed after v1 configs EOL
serverProtocolConfig[configKey::junkPacketCount] = clientProtocolConfig.value(configKey::junkPacketCount);
serverProtocolConfig[configKey::junkPacketMinSize] = clientProtocolConfig.value(configKey::junkPacketMinSize);
serverProtocolConfig[configKey::junkPacketMaxSize] = clientProtocolConfig.value(configKey::junkPacketMaxSize);
serverProtocolConfig[configKey::initPacketJunkSize] = clientProtocolConfig.value(configKey::initPacketJunkSize);
serverProtocolConfig[configKey::responsePacketJunkSize] = clientProtocolConfig.value(configKey::responsePacketJunkSize);
serverProtocolConfig[configKey::initPacketMagicHeader] = clientProtocolConfig.value(configKey::initPacketMagicHeader);
serverProtocolConfig[configKey::responsePacketMagicHeader] = clientProtocolConfig.value(configKey::responsePacketMagicHeader);
serverProtocolConfig[configKey::underloadPacketMagicHeader] = clientProtocolConfig.value(configKey::underloadPacketMagicHeader);
serverProtocolConfig[configKey::transportPacketMagicHeader] = clientProtocolConfig.value(configKey::transportPacketMagicHeader);
const QStringList awgProtocolKeys = configKey::awgProtocolKeys();
serverProtocolConfig[configKey::cookieReplyPacketJunkSize] = clientProtocolConfig.value(configKey::cookieReplyPacketJunkSize);
serverProtocolConfig[configKey::transportPacketJunkSize] = clientProtocolConfig.value(configKey::transportPacketJunkSize);
serverProtocolConfig[configKey::specialJunk1] = clientProtocolConfig.value(configKey::specialJunk1);
serverProtocolConfig[configKey::specialJunk2] = clientProtocolConfig.value(configKey::specialJunk2);
serverProtocolConfig[configKey::specialJunk3] = clientProtocolConfig.value(configKey::specialJunk3);
serverProtocolConfig[configKey::specialJunk4] = clientProtocolConfig.value(configKey::specialJunk4);
serverProtocolConfig[configKey::specialJunk5] = clientProtocolConfig.value(configKey::specialJunk5);
for (const QString &key : awgProtocolKeys) {
const QJsonValue value = clientProtocolConfig.value(key);
if (value.isString() && !value.toString().isEmpty()) {
serverProtocolConfig[key] = value;
}
}
//
@@ -218,23 +174,23 @@ ErrorCode SubscriptionController::extractServerConfigJsonFromResponse(const QByt
return ErrorCode::NoError;
}
void SubscriptionController::updateApiConfigInJson(QJsonObject &serverConfigJson, const QString &serviceType,
void SubscriptionController::updateApiConfigInJson(QJsonObject &serverConfigJson, const QString &serviceType,
const QString &serviceProtocol, const QString &userCountryCode,
const QByteArray &apiResponseBody)
{
QJsonObject apiConfig = serverConfigJson.value(apiDefs::key::apiConfig).toObject();
apiConfig[apiDefs::key::serviceType] = serviceType;
apiConfig[apiDefs::key::serviceProtocol] = serviceProtocol;
apiConfig[apiDefs::key::userCountryCode] = userCountryCode;
if (serverConfigJson.value(configKey::configVersion).toInt() == serverConfigUtils::ConfigSource::AmneziaGateway) {
QJsonObject responseObj = QJsonDocument::fromJson(apiResponseBody).object();
if (responseObj.contains(apiDefs::key::serviceInfo)) {
apiConfig.insert(apiDefs::key::serviceInfo, responseObj.value(apiDefs::key::serviceInfo).toObject());
}
}
serverConfigJson[apiDefs::key::apiConfig] = apiConfig;
}
@@ -249,18 +205,12 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo
const QString &serviceProtocol, const ProtocolData &protocolData,
CaptchaInfo &captchaInfo)
{
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject() };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, userCountryCode)
.addField(apiDefs::key::serviceType, serviceType)
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(serviceProtocol, protocolData))
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody);
@@ -274,14 +224,24 @@ ErrorCode SubscriptionController::importServiceFromGateway(const QString &userCo
return errorCode;
}
return applyImportedServiceConfig(userCountryCode, serviceType, serviceProtocol, protocolData, responseBody);
}
ErrorCode SubscriptionController::applyImportedServiceConfig(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QByteArray &responseBody)
{
QJsonObject serverConfigJson;
errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson);
ErrorCode errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody);
if (!serverConfigUtils::isConfigFormatVersionSupported(serverConfigJson)) {
return ErrorCode::ConfigFormatVersionNotSupportedError;
}
if (serverConfigJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
@@ -300,20 +260,15 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
return ErrorCode::ApiConfigEmptyError;
}
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject() };
ProtocolData protocolData = generateProtocolData(serviceProtocol);
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
apiPayload.insert(apiDefs::key::email, trimmedEmail);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, userCountryCode)
.addField(apiDefs::key::serviceType, serviceType)
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(serviceProtocol, protocolData))
.addField(apiDefs::key::email, trimmedEmail)
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/trial"), apiPayload, responseBody);
@@ -339,6 +294,9 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
}
QJsonObject configObject = QJsonDocument::fromJson(configBytes).object();
if (!serverConfigUtils::isConfigFormatVersionSupported(configObject)) {
return ErrorCode::ConfigFormatVersionNotSupportedError;
}
if (configObject.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
@@ -349,89 +307,6 @@ ErrorCode SubscriptionController::importTrialFromGateway(const QString &userCoun
return ErrorCode::NoError;
}
ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex)
{
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject() };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
apiPayload[apiDefs::key::transactionId] = transactionId;
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
// Parse the subscription response
QJsonObject responseObject = QJsonDocument::fromJson(responseBody).object();
QString key = responseObject.value(QStringLiteral("key")).toString();
if (key.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response does not contain a key field";
return ErrorCode::ApiPurchaseError;
}
QString normalizedKey = key;
normalizedKey.replace(QStringLiteral("vpn://"), QString());
// Check if server with this VPN key already exists
for (int i = 0; i < m_serversRepository->serversCount(); ++i) {
const auto apiV2 = m_serversRepository->apiV2Config(m_serversRepository->serverIdAt(i));
QString existingVpnKey = apiV2.has_value() ? apiV2->vpnKey() : QString();
existingVpnKey.replace(QStringLiteral("vpn://"), QString());
if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) {
if (duplicateServerIndex) {
*duplicateServerIndex = i;
}
qInfo().noquote() << "[IAP] Subscription config with the same vpn_key already exists";
return ErrorCode::ApiConfigAlreadyAdded;
}
}
QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
QByteArray configUncompressed = qUncompress(configString);
if (!configUncompressed.isEmpty()) {
configString = configUncompressed;
}
if (configString.isEmpty()) {
qWarning().noquote() << "[IAP] Subscription response config payload is empty";
return ErrorCode::ApiPurchaseError;
}
QJsonObject configObject = QJsonDocument::fromJson(configString).object();
quint16 crc = qChecksum(QJsonDocument(configObject).toJson());
if (configObject.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
ApiV2ServerConfig apiV2ServerConfig = ApiV2ServerConfig::fromJson(configObject);
ApiV2ServerConfig* apiV2 = &apiV2ServerConfig;
apiV2->apiConfig.vpnKey = normalizedKey;
apiV2->apiConfig.isTestPurchase = isTestPurchase;
apiV2->apiConfig.isInAppPurchase = true;
apiV2->apiConfig.subscriptionExpiredByServer = false;
apiV2->crc = crc;
m_serversRepository->addServer(QString(), apiV2ServerConfig.toJson(),
serverConfigUtils::configTypeFromJson(apiV2ServerConfig.toJson()));
return ErrorCode::NoError;
}
ErrorCode SubscriptionController::updateServiceFromGateway(const QString &serverId, const QString &newCountryCode, bool isConnectEvent,
CaptchaInfo *captchaInfoOut, ProtocolData *usedProtocolDataOut)
{
@@ -460,23 +335,15 @@ ErrorCode SubscriptionController::updateServiceFromGateway(const QString &server
ProtocolData protocolData = generateProtocolData(serviceProtocol);
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
newCountryCode,
apiV2->serviceType(),
serviceProtocol,
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
if (isConnectEvent) {
apiPayload[apiDefs::key::isConnectEvent] = true;
}
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, newCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(serviceProtocol, protocolData))
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.addField(apiDefs::key::isConnectEvent, isConnectEvent ? QJsonValue(true) : QJsonValue())
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase);
@@ -487,10 +354,7 @@ ErrorCode SubscriptionController::updateServiceFromGateway(const QString &server
}
}
if (errorCode == ErrorCode::ApiSubscriptionExpiredError && !apiV2->apiConfig.isInAppPurchase) {
ApiV2ServerConfig expiredApiV2 = *apiV2;
expiredApiV2.apiConfig.subscriptionExpiredByServer = true;
m_serversRepository->editServer(serverId, expiredApiV2.toJson(),
serverConfigUtils::configTypeFromJson(expiredApiV2.toJson()));
markSubscriptionExpiredByServer(serverId, *apiV2);
}
return errorCode;
}
@@ -498,6 +362,14 @@ ErrorCode SubscriptionController::updateServiceFromGateway(const QString &server
return applyUpdatedServiceConfig(serverId, serviceProtocol, protocolData, responseBody);
}
void SubscriptionController::markSubscriptionExpiredByServer(const QString &serverId, const ApiV2ServerConfig &apiV2)
{
ApiV2ServerConfig expiredApiV2 = apiV2;
expiredApiV2.apiConfig.subscriptionExpiredByServer = true;
m_serversRepository->editServer(serverId, expiredApiV2.toJson(),
serverConfigUtils::configTypeFromJson(expiredApiV2.toJson()));
}
ErrorCode SubscriptionController::applyUpdatedServiceConfig(const QString &serverId, const QString &serviceProtocol,
const ProtocolData &protocolData, const QByteArray &responseBody)
{
@@ -514,25 +386,25 @@ ErrorCode SubscriptionController::applyUpdatedServiceConfig(const QString &serve
updateApiConfigInJson(serverConfigJson, apiV2->apiConfig.serviceType, serviceProtocol, apiV2->apiConfig.userCountryCode, responseBody);
if (!serverConfigUtils::isConfigFormatVersionSupported(serverConfigJson)) {
return ErrorCode::ConfigFormatVersionNotSupportedError;
}
if (serverConfigJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
ApiV2ServerConfig newApiV2Config = ApiV2ServerConfig::fromJson(serverConfigJson);
ApiV2ServerConfig* newApiV2 = &newApiV2Config;
newApiV2->apiConfig.vpnKey = apiV2->apiConfig.vpnKey;
newApiV2->apiConfig.isTestPurchase = apiV2->apiConfig.isTestPurchase;
newApiV2->apiConfig.isInAppPurchase = apiV2->apiConfig.isInAppPurchase;
newApiV2->apiConfig.subscriptionExpiredByServer = false;
newApiV2Config.apiConfig.vpnKey = apiV2->apiConfig.vpnKey;
newApiV2Config.apiConfig.subscriptionExpiredByServer = false;
newApiV2->authData = apiV2->authData;
newApiV2->crc = apiV2->crc;
newApiV2Config.authData = apiV2->authData;
newApiV2Config.crc = apiV2->crc;
if (apiV2->nameOverriddenByUser) {
newApiV2->name = apiV2->name;
newApiV2->displayName = apiV2->displayName;
newApiV2->nameOverriddenByUser = true;
newApiV2Config.name = apiV2->name;
newApiV2Config.displayName = apiV2->displayName;
newApiV2Config.nameOverriddenByUser = true;
}
m_serversRepository->editServer(serverId, newApiV2Config.toJson(),
@@ -552,26 +424,17 @@ ErrorCode SubscriptionController::resolveUpdateServiceCaptcha(const QString &ser
const bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QString serviceProtocol = apiV2->serviceProtocol();
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
newCountryCode,
apiV2->serviceType(),
serviceProtocol,
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
if (isConnectEvent) {
apiPayload[apiDefs::key::isConnectEvent] = true;
}
apiPayload["captcha_id"] = captchaId;
apiPayload["captcha_solution"] = normalizeCaptchaSolution(captchaSolution);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, newCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(serviceProtocol, protocolData))
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.addField(apiDefs::key::captchaId, captchaId)
.addField(apiDefs::key::captchaSolution, normalizeCaptchaSolution(captchaSolution))
.addField(apiDefs::key::isConnectEvent, isConnectEvent ? QJsonValue(true) : QJsonValue())
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase);
@@ -582,10 +445,7 @@ ErrorCode SubscriptionController::resolveUpdateServiceCaptcha(const QString &ser
fillCaptchaInfoFromResponse(responseBody, *retryCaptchaOut);
}
if (errorCode == ErrorCode::ApiSubscriptionExpiredError && !apiV2->apiConfig.isInAppPurchase) {
ApiV2ServerConfig expiredApiV2 = *apiV2;
expiredApiV2.apiConfig.subscriptionExpiredByServer = true;
m_serversRepository->editServer(serverId, expiredApiV2.toJson(),
serverConfigUtils::configTypeFromJson(expiredApiV2.toJson()));
markSubscriptionExpiredByServer(serverId, *apiV2);
}
return errorCode;
}
@@ -599,23 +459,17 @@ ErrorCode SubscriptionController::deactivateDevice(const QString &serverId)
if (!apiV2.has_value()) {
return ErrorCode::NoError;
}
if (!apiV2->isPremium() && !apiV2->isExternalPremium()) {
return ErrorCode::NoError;
}
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
apiV2->apiConfig.serverCountryCode,
apiV2->serviceType(),
"",
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, apiV2->apiConfig.serverCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.build();
const bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QByteArray responseBody;
@@ -636,23 +490,18 @@ ErrorCode SubscriptionController::deactivateExternalDevice(const QString &server
if (!apiV2.has_value()) {
return ErrorCode::NoError;
}
if (!apiV2->isPremium() && !apiV2->isExternalPremium()) {
return ErrorCode::NoError;
}
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
uuid,
apiV2->apiConfig.userCountryCode,
serverCountryCode,
apiV2->serviceType(),
"",
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::installationUuid, uuid)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, serverCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.build();
const bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QByteArray responseBody;
@@ -680,19 +529,14 @@ ErrorCode SubscriptionController::exportNativeConfig(const QString &serverId, co
QString protocol = configKey::awg;
ProtocolData protocolData = generateProtocolData(protocol);
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
serverCountryCode,
apiV2->serviceType(),
protocol,
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(protocol, protocolData, apiPayload);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, serverCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::serviceProtocol, protocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(protocol, protocolData))
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/native_config"), apiPayload, responseBody, isTestPurchase);
@@ -715,18 +559,13 @@ ErrorCode SubscriptionController::revokeNativeConfig(const QString &serverId, co
const bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QString protocol = configKey::awg;
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
serverCountryCode,
apiV2->serviceType(),
protocol,
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serverCountryCode, serverCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::serviceProtocol, protocol)
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/revoke_native_config"), apiPayload, responseBody, isTestPurchase);
@@ -791,7 +630,7 @@ void SubscriptionController::removeApiConfig(const QString &serverId)
.arg(hostName)
.arg("");
AmneziaVPN::removeVPNC(vpncName.toStdString());
SWIFT_BRIDGE_NAMESPACE::removeVPNC(vpncName.toStdString());
#endif
apiV2->dns1.clear();
@@ -833,7 +672,7 @@ bool SubscriptionController::isApiKeyExpired(const QString &serverId) const
return false;
}
const QString expiresAt = apiV2->apiConfig.publicKey.expiresAt;
if (expiresAt.isEmpty()) {
return false;
}
@@ -842,7 +681,7 @@ bool SubscriptionController::isApiKeyExpired(const QString &serverId) const
if (expiresAtDateTime < QDateTime::currentDateTimeUtc()) {
return true;
}
return false;
}
@@ -892,161 +731,23 @@ QStringList SubscriptionController::availableProtocols(const QString &serverId)
return protocols;
}
ErrorCode SubscriptionController::processAppStorePurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex)
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
bool purchaseOk = false;
QString originalTransactionId;
QString storeTransactionId;
QString storeProductId;
QString purchaseError;
QEventLoop waitPurchase;
IosController::Instance()->purchaseProduct(productId,
[&](bool success, const QString &txId, const QString &purchasedProductId,
const QString &originalTxId, const QString &errorString) {
purchaseOk = success;
originalTransactionId = originalTxId;
storeTransactionId = txId;
storeProductId = purchasedProductId;
purchaseError = errorString;
waitPurchase.quit();
});
waitPurchase.exec();
if (!purchaseOk || originalTransactionId.isEmpty()) {
qDebug() << "IAP purchase failed:" << purchaseError;
return ErrorCode::ApiPurchaseError;
}
qInfo().noquote() << "[IAP] Purchase success. transactionId =" << storeTransactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << storeProductId;
bool isTestPurchase = IosController::Instance()->isTestFlight();
ProtocolData protocolData = generateProtocolData(serviceProtocol);
return importServiceFromAppStore(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase, duplicateServerIndex);
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
Q_UNUSED(productId);
return ErrorCode::ApiPurchaseError;
#endif
}
SubscriptionController::AppStoreRestoreResult SubscriptionController::processAppStoreRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol)
{
AppStoreRestoreResult result;
#if defined(Q_OS_IOS) || defined(MACOS_NE)
bool restoreSuccess = false;
QList<QVariantMap> restoredTransactions;
QString restoreError;
QEventLoop waitRestore;
IosController::Instance()->restorePurchases([&](bool success, const QList<QVariantMap> &transactions, const QString &errorString) {
restoreSuccess = success;
restoredTransactions = transactions;
restoreError = errorString;
waitRestore.quit();
});
waitRestore.exec();
if (!restoreSuccess) {
qWarning().noquote() << "[IAP] Restore failed:" << restoreError;
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
}
if (restoredTransactions.isEmpty()) {
qInfo().noquote() << "[IAP] Restore completed, but no transactions were returned";
result.errorCode = ErrorCode::ApiNoPurchasedSubscriptionsError;
return result;
}
bool isTestPurchase = IosController::Instance()->isTestFlight();
QSet<QString> processedTransactions;
for (const QVariantMap &transaction : restoredTransactions) {
const QString originalTransactionId = transaction.value(QStringLiteral("originalTransactionId")).toString();
const QString transactionId = transaction.value(QStringLiteral("transactionId")).toString();
const QString transactionProductId = transaction.value(QStringLiteral("productId")).toString();
if (originalTransactionId.isEmpty()) {
qWarning().noquote() << "[IAP] Skipping restored transaction without originalTransactionId" << transactionId;
continue;
}
if (processedTransactions.contains(originalTransactionId)) {
result.duplicateCount++;
continue;
}
processedTransactions.insert(originalTransactionId);
qInfo().noquote() << "[IAP] Restoring subscription. transactionId =" << transactionId
<< "originalTransactionId =" << originalTransactionId << "productId =" << transactionProductId;
ProtocolData protocolData = generateProtocolData(serviceProtocol);
int currentDuplicateServerIndex = -1;
ErrorCode errorCode = importServiceFromAppStore(userCountryCode, serviceType, serviceProtocol, protocolData,
originalTransactionId, isTestPurchase,
&currentDuplicateServerIndex);
if (errorCode == ErrorCode::ApiConfigAlreadyAdded) {
result.duplicateConfigAlreadyPresent = true;
if (result.duplicateServerIndex < 0) {
result.duplicateServerIndex = currentDuplicateServerIndex;
}
qInfo().noquote() << "[IAP] Skipping restored transaction" << originalTransactionId
<< "because subscription config with the same vpn_key already exists";
} else if (errorCode != ErrorCode::NoError) {
qWarning().noquote() << "[IAP] Failed to process restored subscription response for transaction" << originalTransactionId;
result.errorCode = errorCode;
} else {
result.hasInstalledConfig = true;
}
}
if (!result.hasInstalledConfig) {
result.errorCode = result.duplicateConfigAlreadyPresent ? ErrorCode::ApiConfigAlreadyAdded : ErrorCode::ApiPurchaseError;
}
return result;
#else
Q_UNUSED(userCountryCode);
Q_UNUSED(serviceType);
Q_UNUSED(serviceProtocol);
result.errorCode = ErrorCode::ApiPurchaseError;
return result;
#endif
}
ErrorCode SubscriptionController::getAccountInfo(const QString &serverId, QJsonObject &accountInfo)
{
auto apiV2 = m_serversRepository->apiV2Config(serverId);
if (!apiV2.has_value()) {
auto apiV2Opt = m_serversRepository->apiV2Config(serverId);
if (!apiV2Opt.has_value()) {
return ErrorCode::InternalError;
}
bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
"",
apiV2->serviceType(),
"",
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(apiV2->apiConfig);
const ApiV2ServerConfig* apiV2 = &apiV2Opt.value();
bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.addField(apiDefs::key::cliVersion, QString(APP_VERSION))
.addField(apiDefs::key::subscriptionStatus, getSubscriptionStatusForRenewal(apiV2->apiConfig))
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/account_info"), apiPayload, responseBody, isTestPurchase);
@@ -1054,7 +755,8 @@ ErrorCode SubscriptionController::getAccountInfo(const QString &serverId, QJsonO
return errorCode;
}
accountInfo = QJsonDocument::fromJson(responseBody).object();
accountInfo = QJsonDocument::fromJson(responseBody).object();
return ErrorCode::NoError;
}
@@ -1071,20 +773,13 @@ QFuture<QPair<ErrorCode, QString>> SubscriptionController::getRenewalLink(const
}
bool isTestPurchase = apiV2->apiConfig.isTestPurchase;
QJsonObject authDataJson = apiV2->authData.toJson();
GatewayRequestData gatewayRequestData { QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
apiV2->apiConfig.userCountryCode,
"",
apiV2->serviceType(),
"",
authDataJson };
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(apiV2->apiConfig);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, apiV2->apiConfig.userCountryCode)
.addField(apiDefs::key::serviceType, apiV2->serviceType())
.addField(apiDefs::key::authData, apiV2->authData.toJson())
.addField(apiDefs::key::cliVersion, QString(APP_VERSION))
.addField(apiDefs::key::subscriptionStatus, getSubscriptionStatusForRenewal(apiV2->apiConfig))
.build();
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase),
m_appSettingsRepository->isDevGatewayEnv(isTestPurchase),
@@ -1120,21 +815,14 @@ ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &use
const QString &captchaSolution,
CaptchaInfo *retryCaptchaOut)
{
GatewayRequestData gatewayRequestData{QSysInfo::productType(),
QString(APP_VERSION),
m_appSettingsRepository->getAppLanguage().name().split("_").first(),
m_appSettingsRepository->getInstallationUuid(true),
userCountryCode,
"",
serviceType,
serviceProtocol,
QJsonObject()};
QJsonObject apiPayload = gatewayRequestData.toJsonObject();
appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload);
apiPayload["captcha_id"] = captchaId;
apiPayload["captcha_solution"] = normalizeCaptchaSolution(captchaSolution);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository)
.addField(apiDefs::key::userCountryCode, userCountryCode)
.addField(apiDefs::key::serviceType, serviceType)
.addField(apiDefs::key::serviceProtocol, serviceProtocol)
.addField(apiDefs::key::publicKey, publicKeyForProtocol(serviceProtocol, protocolData))
.addField(apiDefs::key::captchaId, captchaId)
.addField(apiDefs::key::captchaSolution, normalizeCaptchaSolution(captchaSolution))
.build();
QByteArray responseBody;
ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody);
@@ -1147,20 +835,5 @@ ErrorCode SubscriptionController::resolveImportServiceCaptcha(const QString &use
return errorCode;
}
QJsonObject serverConfigJson;
errorCode = extractServerConfigJsonFromResponse(responseBody, serviceProtocol, protocolData, serverConfigJson);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
updateApiConfigInJson(serverConfigJson, serviceType, serviceProtocol, userCountryCode, responseBody);
if (serverConfigJson.value(configKey::configVersion).toInt() != serverConfigUtils::ConfigSource::AmneziaGateway) {
return ErrorCode::InternalError;
}
ApiV2ServerConfig apiV2ServerConfig = ApiV2ServerConfig::fromJson(serverConfigJson);
m_serversRepository->addServer(QString(), apiV2ServerConfig.toJson(),
serverConfigUtils::configTypeFromJson(apiV2ServerConfig.toJson()));
return ErrorCode::NoError;
return applyImportedServiceConfig(userCountryCode, serviceType, serviceProtocol, protocolData, responseBody);
}
@@ -4,8 +4,6 @@
#include <QJsonObject>
#include <QByteArray>
#include <QFuture>
#include <QList>
#include <QVariantMap>
#include "core/utils/errorCodes.h"
#include "core/utils/routeModes.h"
@@ -27,21 +25,6 @@ public:
QString xrayUuid;
};
struct GatewayRequestData
{
QString osVersion;
QString appVersion;
QString appLanguage;
QString installationUuid;
QString userCountryCode;
QString serverCountryCode;
QString serviceType;
QString serviceProtocol;
QJsonObject authData;
QJsonObject toJsonObject() const;
};
struct CaptchaInfo {
QString captchaId;
QString captchaImageBase64;
@@ -52,8 +35,9 @@ public:
explicit SubscriptionController(SecureServersRepository* serversRepository,
SecureAppSettingsRepository* appSettingsRepository);
ProtocolData generateProtocolData(const QString &protocol);
void appendProtocolDataToApiPayload(const QString &protocol, const ProtocolData &protocolData, QJsonObject &apiPayload);
static ProtocolData generateProtocolData(const QString &protocol);
static QString publicKeyForProtocol(const QString &protocol, const ProtocolData &protocolData);
ErrorCode importServiceFromGateway(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
@@ -61,11 +45,6 @@ public:
ErrorCode importTrialFromGateway(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &email);
ErrorCode importServiceFromAppStore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &transactionId, bool isTestPurchase,
int *duplicateServerIndex = nullptr);
ErrorCode updateServiceFromGateway(const QString &serverId, const QString &newCountryCode, bool isConnectEvent,
CaptchaInfo *captchaInfoOut = nullptr, ProtocolData *usedProtocolDataOut = nullptr);
@@ -98,27 +77,26 @@ public:
ErrorCode getAccountInfo(const QString &serverId, QJsonObject &accountInfo);
QFuture<QPair<ErrorCode, QString>> getRenewalLink(const QString &serverId);
struct AppStoreRestoreResult
{
bool hasInstalledConfig = false;
bool duplicateConfigAlreadyPresent = false;
int duplicateCount = 0;
int duplicateServerIndex = -1;
ErrorCode errorCode = ErrorCode::NoError;
};
ErrorCode processAppStorePurchase(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const QString &productId,
int *duplicateServerIndex = nullptr);
AppStoreRestoreResult processAppStoreRestore(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol);
ErrorCode resolveImportServiceCaptcha(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QString &captchaId, const QString &captchaSolution,
CaptchaInfo *retryCaptchaOut = nullptr);
#ifdef AMNEZIA_BUILD_TESTS
// Exposes the gateway response parsing path without performing a network request.
ErrorCode applyImportedServiceConfigForTest(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QByteArray &responseBody)
{
return applyImportedServiceConfig(userCountryCode, serviceType, serviceProtocol, protocolData, responseBody);
}
ErrorCode applyUpdatedServiceConfigForTest(const QString &serverId, const QString &serviceProtocol,
const ProtocolData &protocolData, const QByteArray &responseBody)
{
return applyUpdatedServiceConfig(serverId, serviceProtocol, protocolData, responseBody);
}
#endif
private:
ErrorCode executeRequest(const QString &endpoint, const QJsonObject &apiPayload, QByteArray &responseBody, bool isTestPurchase = false);
bool isApiKeyExpired(const QString &serverId) const;
@@ -127,7 +105,11 @@ private:
const ProtocolData &protocolData, QJsonObject &serverConfigJson);
ErrorCode applyUpdatedServiceConfig(const QString &serverId, const QString &serviceProtocol,
const ProtocolData &protocolData, const QByteArray &responseBody);
void updateApiConfigInJson(QJsonObject &serverConfigJson, const QString &serviceType,
ErrorCode applyImportedServiceConfig(const QString &userCountryCode, const QString &serviceType,
const QString &serviceProtocol, const ProtocolData &protocolData,
const QByteArray &responseBody);
void markSubscriptionExpiredByServer(const QString &serverId, const ApiV2ServerConfig &apiV2);
void updateApiConfigInJson(QJsonObject &serverConfigJson, const QString &serviceType,
const QString &serviceProtocol, const QString &userCountryCode,
const QByteArray &apiResponseBody);
@@ -31,7 +31,6 @@ ConnectionController::ConnectionController(SecureServersRepository* serversRepos
connect(m_vpnConnection, &VpnConnection::connectionStateChanged, this, &ConnectionController::connectionStateChanged);
connect(this, &ConnectionController::openConnectionRequested, m_vpnConnection, &VpnConnection::connectToVpn, Qt::QueuedConnection);
connect(this, &ConnectionController::closeConnectionRequested, m_vpnConnection, &VpnConnection::disconnectFromVpn, Qt::QueuedConnection);
connect(this, &ConnectionController::setConnectionStateRequested, m_vpnConnection, &VpnConnection::setConnectionState, Qt::QueuedConnection);
connect(this, &ConnectionController::killSwitchModeChangedRequested, m_vpnConnection, &VpnConnection::onKillSwitchModeChanged, Qt::QueuedConnection);
#ifdef Q_OS_ANDROID
connect(this, &ConnectionController::restoreConnectionRequested, m_vpnConnection, &VpnConnection::restoreConnection, Qt::QueuedConnection);
@@ -45,9 +44,7 @@ bool ConnectionController::isConnected() const
void ConnectionController::setConnectionState(Vpn::ConnectionState state)
{
if (m_vpnConnection) {
emit setConnectionStateRequested(state);
}
emit connectionStateChanged(state);
}
ErrorCode ConnectionController::defaultContainerForServer(const QString &serverId, DockerContainer &container) const
@@ -67,7 +67,6 @@ signals:
void connectionStateChanged(Vpn::ConnectionState state);
void openConnectionRequested(const QString &serverId, DockerContainer container, const QJsonObject &vpnConfiguration);
void closeConnectionRequested();
void setConnectionStateRequested(Vpn::ConnectionState state);
void killSwitchModeChangedRequested(bool enabled);
#ifdef Q_OS_ANDROID
+41 -8
View File
@@ -1,5 +1,6 @@
#include "coreController.h"
#include <QCoreApplication>
#include <QDirIterator>
#include <QTranslator>
#include <QTimer>
@@ -10,6 +11,7 @@
#include "core/controllers/coreSignalHandlers.h"
#include "logger.h"
#include "secureQSettings.h"
#include "core/utils/appUiConfig.h"
#if defined(Q_OS_ANDROID)
#include "core/utils/installedAppsImageProvider.h"
@@ -18,7 +20,10 @@
#if defined(Q_OS_IOS)
#include "platforms/ios/ios_controller.h"
#include <AmneziaVPN-Swift.h>
#endif
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "core/utils/swiftBridge.h"
#endif
CoreController::CoreController(const QSharedPointer<VpnConnection> &vpnConnection, SecureQSettings* settings,
@@ -112,6 +117,9 @@ void CoreController::initModels()
m_telemtConfigModel = new TelemtConfigModel(this);
setQmlContextProperty("TelemtConfigModel", m_telemtConfigModel);
m_tProxyConfigModel = new TProxyConfigModel(this);
setQmlContextProperty("TProxyConfigModel", m_tProxyConfigModel);
m_clientManagementModel = new ClientManagementModel(this);
setQmlContextProperty("ClientManagementModel", m_clientManagementModel);
@@ -121,6 +129,12 @@ void CoreController::initModels()
m_apiCountryModel = new ApiCountryModel(this);
setQmlContextProperty("ApiCountryModel", m_apiCountryModel);
m_apiCountryListModel = new ApiCountryListModel(m_apiCountryModel, "picker", this);
setQmlContextProperty("ApiCountryListModel", m_apiCountryListModel);
m_apiConfigsCountryListModel = new ApiCountryListModel(m_apiCountryModel, "nativeConfigs", this);
setQmlContextProperty("ApiConfigsCountryListModel", m_apiConfigsCountryListModel);
m_apiSubscriptionPlansModel = new ApiSubscriptionPlansModel(this);
setQmlContextProperty("ApiSubscriptionPlansModel", m_apiSubscriptionPlansModel);
@@ -156,6 +170,7 @@ void CoreController::initCoreControllers()
m_allowedDnsController = new AllowedDnsController(m_appSettingsRepository);
m_servicesCatalogController = new ServicesCatalogController(m_appSettingsRepository);
m_subscriptionController = new SubscriptionController(m_serversRepository, m_appSettingsRepository);
m_storePurchaseController = new StorePurchaseController(m_serversRepository, m_appSettingsRepository);
m_newsController = new NewsController(m_appSettingsRepository, m_serversRepository);
m_updateController = new UpdateController(m_appSettingsRepository, this);
@@ -173,7 +188,6 @@ void CoreController::initControllers()
if (m_engine) {
m_focusController = new FocusController(m_engine, this);
setQmlContextProperty("FocusController", m_focusController);
}
m_installUiController = new InstallUiController(m_installController, m_serversController, m_settingsController, m_protocolsModel, m_usersController,
@@ -182,6 +196,7 @@ void CoreController::initControllers()
m_ikev2ConfigModel,
#endif
m_sftpConfigModel, m_socks5ConfigModel, m_mtProxyConfigModel, m_telemtConfigModel,
m_tProxyConfigModel,
m_connectionController, this);
setQmlContextProperty("InstallController", m_installUiController);
@@ -197,6 +212,9 @@ void CoreController::initControllers()
m_settingsUiController = new SettingsUiController(m_settingsController, m_serversController, this);
setQmlContextProperty("SettingsController", m_settingsUiController);
m_logsUiController = new LogsUiController(m_settingsController, this);
setQmlContextProperty("LogsController", m_logsUiController);
m_pageController = new PageController(m_serversController, m_settingsController, this);
setQmlContextProperty("PageController", m_pageController);
@@ -223,6 +241,7 @@ void CoreController::initControllers()
setQmlContextProperty("ServicesCatalogUiController", m_servicesCatalogUiController);
m_subscriptionUiController = new SubscriptionUiController(m_serversController, m_apiServicesModel, m_servicesCatalogController, m_subscriptionController,
m_storePurchaseController,
m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_apiAccountInfoModel,
m_apiCountryModel, m_apiDevicesModel, m_settingsController,
m_connectionController, this);
@@ -258,13 +277,13 @@ void CoreController::initAppleController()
{
#ifdef Q_OS_IOS
IosController::Instance()->initialize();
QTimer::singleShot(0, this, [this]() { AmneziaVPN::toggleScreenshots(m_appSettingsRepository->isScreenshotsEnabled()); });
QTimer::singleShot(0, this, [this]() { SWIFT_BRIDGE_NAMESPACE::toggleScreenshots(m_appSettingsRepository->isScreenshotsEnabled()); });
#endif
}
void CoreController::initLogging()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
#ifndef Q_OS_ANDROID
bool enabled = m_appSettingsRepository->isSaveLogs();
if (enabled) {
if (!Logger::init(false)) {
@@ -273,6 +292,11 @@ void CoreController::initLogging()
}
Logger::setServiceLogsEnabled(enabled);
#endif
#if defined(Q_OS_IOS) || defined(MACOS_NE)
if (m_appSettingsRepository->isSaveLogs()) {
SWIFT_BRIDGE_NAMESPACE::toggleLogging(true);
}
#endif
}
void CoreController::initSignalHandlers()
@@ -285,6 +309,15 @@ void CoreController::initSignalHandlers()
if (m_serversUiController->hasServersFromGatewayApi()) {
m_apiNewsUiController->fetchNews(false);
}
}
void CoreController::checkForAppUpdates()
{
if (!m_appSettingsRepository->isAutoUpdateCheckEnabled()) {
return;
}
m_updateController->checkForUpdates();
}
void CoreController::updateTranslator(const QLocale &locale)
@@ -294,15 +327,15 @@ void CoreController::updateTranslator(const QLocale &locale)
}
QStringList availableTranslations;
QDirIterator it(":/translations", QStringList("amneziavpn_*.qm"), QDir::Files);
QDirIterator it(":/translations", QStringList(APP_TS_PREFIX "_*.qm"), QDir::Files);
while (it.hasNext()) {
availableTranslations << it.next();
}
// This code allow to load translation for the language only, without country code
const QString lang = locale.name().split("_").first();
const QString translationFilePrefix = QString(":/translations/amneziavpn_") + lang;
QString strFileName = QString(":/translations/amneziavpn_%1.qm").arg(locale.name());
const QString translationFilePrefix = QString(":/translations/" APP_TS_PREFIX "_") + lang;
QString strFileName = QString(":/translations/" APP_TS_PREFIX "_%1.qm").arg(locale.name());
for (const QString &translation : availableTranslations) {
if (translation.contains(translationFilePrefix)) {
strFileName = translation;
@@ -313,7 +346,7 @@ void CoreController::updateTranslator(const QLocale &locale)
if (m_translator->load(strFileName)) {
QCoreApplication::installTranslator(m_translator);
} else {
if (m_translator->load(QString(":/translations/amneziavpn_en.qm"))) {
if (m_translator->load(QString(":/translations/" APP_TS_PREFIX "_en.qm"))) {
QCoreApplication::installTranslator(m_translator);
}
}
+13
View File
@@ -3,7 +3,9 @@
#include <QObject>
#include <QQmlContext>
#include <QSharedPointer>
#include <QThread>
#include <QTranslator>
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
#include "ui/utils/systemTrayNotificationHandler.h"
@@ -22,6 +24,7 @@
#include "ui/controllers/selfhosted/installUiController.h"
#include "ui/controllers/qml/pageController.h"
#include "ui/controllers/settingsUiController.h"
#include "ui/controllers/logsUiController.h"
#include "ui/controllers/serversUiController.h"
#include "ui/controllers/ipSplitTunnelingUiController.h"
#include "ui/controllers/systemController.h"
@@ -36,6 +39,7 @@
#include "core/controllers/ipSplitTunnelingController.h"
#include "core/controllers/allowedDnsController.h"
#include "core/controllers/api/servicesCatalogController.h"
#include "core/controllers/api/storePurchaseController.h"
#include "core/controllers/api/subscriptionController.h"
#include "core/controllers/api/newsController.h"
#include "core/controllers/selfhosted/installController.h"
@@ -56,6 +60,7 @@
#include "ui/models/api/apiAccountInfoModel.h"
#include "ui/models/api/apiBenefitsModel.h"
#include "ui/models/api/apiCountryModel.h"
#include "ui/models/api/apiCountryListModel.h"
#include "ui/models/api/apiDevicesModel.h"
#include "ui/models/api/apiServicesModel.h"
#include "ui/models/api/apiSubscriptionPlansModel.h"
@@ -73,6 +78,7 @@
#include "ui/models/services/socks5ProxyConfigModel.h"
#include "ui/models/services/mtProxyConfigModel.h"
#include "ui/models/services/telemtConfigModel.h"
#include "ui/models/services/tProxyConfigModel.h"
#include "ui/models/ipSplitTunnelingModel.h"
#include "ui/models/newsModel.h"
@@ -95,6 +101,7 @@ public:
PageController* pageController() const;
void setQmlRoot();
void checkForAppUpdates();
void openConnectionByIndex(int serverIndex);
void importConfigFromData(const QString &data);
@@ -133,6 +140,7 @@ protected:
ServersUiController* serversUiControllerProtected() const { return m_serversUiController; }
ServicesCatalogUiController* servicesCatalogUiControllerProtected() const { return m_servicesCatalogUiController; }
ApiNewsUiController* apiNewsUiControllerProtected() const { return m_apiNewsUiController; }
SubscriptionController* subscriptionControllerProtected() const { return m_subscriptionController; }
private:
void initRepositories();
@@ -167,6 +175,7 @@ private:
ImportController* m_importCoreController;
ExportUiController* m_exportUiController;
SettingsUiController* m_settingsUiController;
LogsUiController* m_logsUiController;
ServersUiController* m_serversUiController;
IpSplitTunnelingUiController* m_ipSplitTunnelingUiController;
SystemController* m_systemController;
@@ -188,6 +197,7 @@ private:
AllowedDnsController* m_allowedDnsController;
ServicesCatalogController* m_servicesCatalogController;
SubscriptionController* m_subscriptionController;
StorePurchaseController* m_storePurchaseController;
NewsController* m_newsController;
UpdateController* m_updateController;
InstallController* m_installController;
@@ -210,6 +220,8 @@ private:
ApiSubscriptionPlansModel* m_apiSubscriptionPlansModel;
ApiBenefitsModel* m_apiBenefitsModel;
ApiCountryModel* m_apiCountryModel;
ApiCountryListModel* m_apiCountryListModel;
ApiCountryListModel* m_apiConfigsCountryListModel;
ApiAccountInfoModel* m_apiAccountInfoModel;
ApiDevicesModel* m_apiDevicesModel;
@@ -226,6 +238,7 @@ private:
Socks5ProxyConfigModel* m_socks5ConfigModel;
MtProxyConfigModel* m_mtProxyConfigModel;
TelemtConfigModel* m_telemtConfigModel;
TProxyConfigModel* m_tProxyConfigModel;
CoreSignalHandlers* m_signalHandlers;
};
+54 -13
View File
@@ -48,7 +48,7 @@
#ifdef Q_OS_IOS
#include "platforms/ios/ios_controller.h"
#include <AmneziaVPN-Swift.h>
#include "core/utils/swiftBridge.h"
#endif
CoreSignalHandlers::CoreSignalHandlers(CoreController* coreController, QObject* parent)
@@ -65,6 +65,8 @@ void CoreSignalHandlers::initAllHandlers()
initExportControllerHandler();
initImportControllerHandler();
initApiCountryModelUpdateHandler();
initCountryListSortModeHandler();
initCountryListStateHandler();
initSubscriptionRefreshHandler();
initAdminConfigRevokedHandler();
initPassphraseRequestHandler();
@@ -173,6 +175,41 @@ void CoreSignalHandlers::initImportControllerHandler()
});
}
void CoreSignalHandlers::initCountryListSortModeHandler()
{
SecureAppSettingsRepository *repository = m_coreController->m_appSettingsRepository;
const QVector<ApiCountryListModel *> models { m_coreController->m_apiCountryListModel,
m_coreController->m_apiConfigsCountryListModel };
for (ApiCountryListModel *model : models) {
model->setSortMode(repository->countryListSortMode(model->listId()));
connect(model, &ApiCountryListModel::sortModeChanged, this, [repository, model]() {
repository->setCountryListSortMode(model->listId(), model->sortMode());
});
}
}
void CoreSignalHandlers::initCountryListStateHandler()
{
SecureAppSettingsRepository *repository = m_coreController->m_appSettingsRepository;
const QVector<ApiCountryListModel *> models { m_coreController->m_apiCountryListModel,
m_coreController->m_apiConfigsCountryListModel };
for (ApiCountryListModel *model : models) {
model->setFavorites(repository->favoriteLocations());
connect(model, &ApiCountryListModel::favoritesChanged, this, [repository, models, model](const QStringList &codes) {
repository->setFavoriteLocations(codes);
for (ApiCountryListModel *other : models) {
if (other != model) {
other->setFavorites(codes);
}
}
});
}
}
void CoreSignalHandlers::initApiCountryModelUpdateHandler()
{
connect(m_coreController->m_serversUiController, &ServersUiController::updateApiCountryModel, this, [this]() {
@@ -187,7 +224,8 @@ void CoreSignalHandlers::initApiCountryModelUpdateHandler()
}
m_coreController->m_apiCountryModel->updateModel(apiV2->apiConfig.availableCountries,
apiV2->apiConfig.serverCountryCode);
apiV2->apiConfig.serverCountryCode,
apiV2->apiConfig.userCountryCode);
});
}
@@ -230,6 +268,18 @@ void CoreSignalHandlers::initTranslationsUpdatedHandler()
connect(m_coreController->m_languageUiController, &LanguageUiController::updateTranslations, m_coreController, &CoreController::updateTranslator);
connect(m_coreController, &CoreController::translationsUpdated, m_coreController->m_languageUiController, &LanguageUiController::translationsUpdated);
connect(m_coreController, &CoreController::translationsUpdated, m_coreController->m_connectionUiController, &ConnectionUiController::onTranslationsUpdated);
connect(m_coreController, &CoreController::translationsUpdated, m_coreController->m_logsUiController, &LogsUiController::refreshSizes);
const QVector<ApiCountryListModel *> countryListModels { m_coreController->m_apiCountryListModel,
m_coreController->m_apiConfigsCountryListModel };
auto applyUiLanguage = [this, countryListModels]() {
const QString languageCode = QLocale::languageToCode(m_coreController->m_settingsController->getAppLanguage().language());
for (ApiCountryListModel *model : countryListModels) {
model->setUiLanguage(languageCode);
}
};
applyUiLanguage();
connect(m_coreController, &CoreController::translationsUpdated, this, applyUiLanguage);
}
void CoreSignalHandlers::initLanguageHandler()
@@ -413,7 +463,7 @@ void CoreSignalHandlers::initIosImportHandler()
void CoreSignalHandlers::initIosSettingsHandler()
{
#ifdef Q_OS_IOS
connect(m_coreController->m_appSettingsRepository, &SecureAppSettingsRepository::screenshotsEnabledChanged, [](bool enabled) { AmneziaVPN::toggleScreenshots(enabled); });
connect(m_coreController->m_appSettingsRepository, &SecureAppSettingsRepository::screenshotsEnabledChanged, [](bool enabled) { SWIFT_BRIDGE_NAMESPACE::toggleScreenshots(enabled); });
#endif
}
@@ -439,17 +489,8 @@ void CoreSignalHandlers::initNotificationHandler()
void CoreSignalHandlers::initUpdateFoundHandler()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS)
connect(m_coreController->m_apiNewsUiController, &ApiNewsUiController::fetchNewsFinished, m_coreController->m_updateUiController,
&UpdateUiController::checkForUpdates);
connect(m_coreController->m_updateUiController, &UpdateUiController::updateFound, this, [this]() {
const QString version = m_coreController->m_updateUiController->getVersion();
const QString updateId = version.isEmpty() ? QStringLiteral("update") : QStringLiteral("update-%1").arg(version);
m_coreController->m_newsModel->setUpdateNotification(
updateId, m_coreController->m_updateUiController->getHeaderText(), m_coreController->m_updateUiController->getChangelogText());
emit m_coreController->m_pageController->showChangelogDrawer();
emit m_coreController->m_pageController->goToPage(PageLoader::PageEnum::PageUpdate);
});
#endif
}
@@ -20,6 +20,8 @@ private:
void initExportControllerHandler();
void initImportControllerHandler();
void initApiCountryModelUpdateHandler();
void initCountryListSortModeHandler();
void initCountryListStateHandler();
void initSubscriptionRefreshHandler();
void initAdminConfigRevokedHandler();
void initPassphraseRequestHandler();
+185 -662
View File
@@ -1,107 +1,69 @@
#include "gatewayController.h"
#include <algorithm>
#include <functional>
#include <random>
#include <QCryptographicHash>
#include <QDebug>
#include <QEventLoop>
#include <QFutureWatcher>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkReply>
#include <QPromise>
#include <QUrl>
#include <QMetaObject>
#include <QThread>
#include <QtConcurrent/QtConcurrentRun>
#include <openssl/rsa.h>
#include "amneziaApplication.h"
#include "core/repositories/secureAppSettingsRepository.h"
#include "core/utils/api/apiUtils.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/networkUtilities.h"
#include "cryptoUtils.h"
#include "core/utils/api/apiUtils.h"
#ifdef AMNEZIA_DESKTOP
#include "core/utils/ipcClient.h"
#include "core/utils/networkUtilities.h"
#endif
#ifdef Q_OS_IOS
#include "platforms/ios/ios_controller.h"
#endif
namespace
{
constexpr QLatin1String errorResponsePattern1("No active configuration found for");
constexpr QLatin1String errorResponsePattern2("No non-revoked public key found for");
constexpr QLatin1String errorResponsePattern3("Account not found.");
constexpr QLatin1String errorResponsePatternQrSessionNotFound("QR session not found");
constexpr QLatin1String errorResponsePatternSessionNotFound("Session not found");
constexpr QLatin1String updateRequestResponsePattern("client version update is required");
constexpr int httpStatusCodeNotFound = 404;
constexpr int httpStatusCodeConflict = 409;
constexpr int httpStatusCodeNotImplemented = 501;
constexpr int httpStatusCodePaymentRequired = 402;
constexpr int httpStatusCodeRequestTimeout = 408;
constexpr int httpStatusCodeUnprocessableEntity = 422;
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
constexpr int proxyStorageRequestTimeoutMsecs = 3000;
QStringList shuffledProxyUrls(const QStringList &proxyUrls)
namespace agwConfigKey
{
QStringList shuffled = proxyUrls;
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::shuffle(shuffled.begin(), shuffled.end(), generator);
return shuffled;
constexpr QLatin1String gatewayEndpoint("gateway_endpoint");
constexpr QLatin1String publicKeyPem("public_key_pem");
constexpr QLatin1String s3PrimaryEndpoints("s3_primary_endpoints");
constexpr QLatin1String s3FallbackEndpoints("s3_fallback_endpoints");
constexpr QLatin1String isDevEnvironment("is_dev_environment");
constexpr QLatin1String requestTimeoutMsecs("request_timeout_msecs");
}
QString getProxyUrlsCacheKey(const QString &serviceType, const QString &userCountryCode)
namespace agwOptionsKey
{
return QStringLiteral("service_%1_country_%2").arg(serviceType, userCountryCode);
constexpr QLatin1String serviceType("service_type");
constexpr QLatin1String userCountryCode("user_country_code");
}
bool decryptProxyUrlsPayload(const QByteArray &encryptedPayload, bool isDevEnvironment, QByteArray &decryptedPayload)
constexpr QLatin1String agwStateCacheKey("agw_state");
constexpr QLatin1String agwTracePrefix("agw trace:");
constexpr QLatin1String agwTraceFailoverMarker("suspicious");
constexpr QLatin1String endpointListSeparator(", ");
constexpr QLatin1String legacyEndpointPlaceholder("%1");
QStringList splitEndpoints(const char *raw)
{
QByteArray key = isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
if (!isDevEnvironment) {
QCryptographicHash hash(QCryptographicHash::Sha512);
hash.addData(key);
QByteArray h = hash.result().toHex();
return QString::fromUtf8(raw).split(endpointListSeparator, Qt::SkipEmptyParts);
}
QByteArray decKey = QByteArray::fromHex(h.left(64));
QByteArray iv = QByteArray::fromHex(h.mid(64, 32));
QByteArray ba = QByteArray::fromBase64(encryptedPayload);
decryptedPayload = CryptoUtils::decryptAes256Cbc(ba, decKey, iv);
if (decryptedPayload.isEmpty()) {
return false;
}
void logAgwTrace(const QString &path, const int code, const QString &trace)
{
if (trace.isEmpty()) {
return;
}
const QString text = QStringLiteral("%1 %2\n%3").arg(agwTracePrefix, path, trace).replace(QLatin1Char('\n'), QStringLiteral("\n "));
if (code != AGW_OK) {
qWarning().noquote() << text;
} else if (trace.contains(agwTraceFailoverMarker)) {
qInfo().noquote() << text;
} else {
decryptedPayload = encryptedPayload;
qDebug().noquote() << text;
}
return true;
}
QStringList readCachedProxyUrls(const QByteArray &cachedProxyUrlsEncrypted, bool isDevEnvironment)
{
if (cachedProxyUrlsEncrypted.isEmpty()) {
return {};
}
QByteArray cachedProxyUrlsDecrypted;
if (!decryptProxyUrlsPayload(cachedProxyUrlsEncrypted, isDevEnvironment, cachedProxyUrlsDecrypted)) {
qCritical() << "error decrypting cached proxy urls payload";
return {};
}
QJsonArray endpointsArray = QJsonDocument::fromJson(cachedProxyUrlsDecrypted).array();
QStringList endpoints;
endpoints.reserve(endpointsArray.size());
for (const QJsonValue &endpoint : endpointsArray) {
endpoints.push_back(endpoint.toString());
}
return endpoints;
}
}
@@ -109,616 +71,177 @@ GatewayController::GatewayController(const QString &gatewayEndpoint, const bool
const bool isStrictKillSwitchEnabled, SecureAppSettingsRepository *appSettingsRepository,
QObject *parent)
: QObject(parent),
m_gatewayEndpoint(gatewayEndpoint),
m_isDevEnvironment(isDevEnvironment),
m_requestTimeoutMsecs(requestTimeoutMsecs),
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled),
m_appSettingsRepository(appSettingsRepository)
{
const QByteArray publicKey = isDevEnvironment ? QByteArray(DEV_AGW_PUBLIC_KEY) : QByteArray(PROD_AGW_PUBLIC_KEY);
m_publicKeyMissing = publicKey.isEmpty();
QStringList primaryEndpoints;
QStringList fallbackEndpoints;
if (isDevEnvironment) {
primaryEndpoints = splitEndpoints(DEV_S3_ENDPOINT);
} else {
primaryEndpoints = splitEndpoints(PROD_S3_ENDPOINT);
fallbackEndpoints = splitEndpoints(FALLBACK_S3_ENDPOINT);
}
QJsonObject config;
config[agwConfigKey::gatewayEndpoint] = gatewayEndpoint;
config[agwConfigKey::publicKeyPem] = QString::fromUtf8(publicKey);
config[agwConfigKey::s3PrimaryEndpoints] = QJsonArray::fromStringList(primaryEndpoints);
config[agwConfigKey::s3FallbackEndpoints] = QJsonArray::fromStringList(fallbackEndpoints);
config[agwConfigKey::isDevEnvironment] = isDevEnvironment;
config[agwConfigKey::requestTimeoutMsecs] = requestTimeoutMsecs;
agw_callbacks callbacks {};
callbacks.struct_size = sizeof(agw_callbacks);
callbacks.on_before_request = &GatewayController::onBeforeRequest;
callbacks.on_before_request_user_data = this;
m_client = agw_client_create(QJsonDocument(config).toJson(QJsonDocument::Compact).constData(), &callbacks);
if (m_client == 0) {
qCritical() << "GatewayController: failed to create gateway client (missing key or endpoint?)";
return;
}
if (m_appSettingsRepository != nullptr) {
const QByteArray state = m_appSettingsRepository->readGatewayProxyUrls(agwStateCacheKey);
if (!state.isEmpty() && agw_import_state(m_client, state.constData()) == AGW_OK) {
m_lastPersistedState = state;
}
}
}
GatewayController::EncryptedRequestData GatewayController::prepareRequest(const QString &endpoint, const QJsonObject &apiPayload)
GatewayController::~GatewayController()
{
EncryptedRequestData encRequestData;
encRequestData.errorCode = ErrorCode::NoError;
agw_client_destroy(m_client);
}
amnezia::ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody)
{
QFuture<QPair<amnezia::ErrorCode, QByteArray>> future = postAsync(endpoint, apiPayload);
QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>> watcher;
QEventLoop wait;
connect(&watcher, &QFutureWatcherBase::finished, &wait, &QEventLoop::quit);
watcher.setFuture(future);
if (!future.isFinished()) {
wait.exec(QEventLoop::ExcludeUserInputEvents);
}
const QPair<amnezia::ErrorCode, QByteArray> result = future.result();
responseBody = result.second;
return result.first;
}
QFuture<QPair<amnezia::ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload)
{
return QtConcurrent::run(
[this, endpoint, apiPayload]() -> QPair<amnezia::ErrorCode, QByteArray> { return executePost(endpoint, apiPayload); });
}
QPair<amnezia::ErrorCode, QByteArray> GatewayController::executePost(const QString &endpoint, const QJsonObject &apiPayload)
{
if (m_client == 0) {
return qMakePair(m_publicKeyMissing ? amnezia::ErrorCode::ApiMissingAgwPublicKey : amnezia::ErrorCode::ApiConfigDownloadError,
QByteArray());
}
QString path = endpoint;
path.remove(legacyEndpointPlaceholder);
QJsonObject options;
options[agwOptionsKey::serviceType] = apiPayload.value(apiDefs::key::serviceType).toString();
options[agwOptionsKey::userCountryCode] = apiPayload.value(apiDefs::key::userCountryCode).toString();
const QByteArray payload = QJsonDocument(apiPayload).toJson(QJsonDocument::Compact);
const QByteArray optionsJson = QJsonDocument(options).toJson(QJsonDocument::Compact);
agw_result result = agw_post(m_client, path.toUtf8().constData(), payload.constData(), optionsJson.constData(), 0);
QByteArray responseBody;
if (result.body != nullptr) {
responseBody = QByteArray(result.body, static_cast<qsizetype>(result.body_len));
}
QString trace;
if (result.trace != nullptr) {
trace = QString::fromUtf8(result.trace, static_cast<qsizetype>(result.trace_len));
}
const int code = result.code;
agw_result_free(&result);
logAgwTrace(path, code, trace);
persistState();
return qMakePair(mapResultCode(code, responseBody), responseBody);
}
amnezia::ErrorCode GatewayController::mapResultCode(const int code, const QByteArray &responseBody)
{
switch (code) {
case AGW_OK: return apiUtils::checkApiResponseErrors(responseBody);
case AGW_CANCELLED:
case AGW_ERR_TIMEOUT: return amnezia::ErrorCode::ApiConfigTimeoutError;
case AGW_ERR_SSL: return amnezia::ErrorCode::ApiConfigSslError;
case AGW_ERR_CONFIG: return amnezia::ErrorCode::ApiMissingAgwPublicKey;
case AGW_ERR_DECRYPT: return amnezia::ErrorCode::ApiConfigDecryptionError;
default: return amnezia::ErrorCode::ApiConfigDownloadError;
}
}
void GatewayController::onBeforeRequest(const char *host, void *userData)
{
auto *controller = static_cast<GatewayController *>(userData);
const QString hostString = QString::fromUtf8(host);
if (QThread::currentThread() == controller->thread()) {
controller->handleBeforeRequest(hostString);
} else {
QMetaObject::invokeMethod(
controller, [controller, hostString]() { controller->handleBeforeRequest(hostString); },
Qt::BlockingQueuedConnection);
}
}
void GatewayController::handleBeforeRequest(const QString &host)
{
#ifdef Q_OS_IOS
Q_UNUSED(host);
IosController::Instance()->requestInetAccess();
QThread::msleep(10);
#endif
encRequestData.request.setTransferTimeout(m_requestTimeoutMsecs);
encRequestData.request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
encRequestData.request.setRawHeader(QString("X-Client-Request-ID").toUtf8(), QUuid::createUuid().toString(QUuid::WithoutBraces).toUtf8());
encRequestData.request.setUrl(endpoint.arg(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl));
// bypass killSwitch exceptions for API-gateway
#ifdef AMNEZIA_DESKTOP
if (m_isStrictKillSwitchEnabled) {
QString host = QUrl(encRequestData.request.url()).host();
QString ip = NetworkUtilities::getIPAddress(host);
const QString ip = NetworkUtilities::getIPAddress(host);
if (!ip.isEmpty()) {
IpcClient::withInterface([&](QSharedPointer<IpcInterfaceReplica> iface) {
QRemoteObjectPendingReply<bool> reply = iface->addKillSwitchAllowedRange(QStringList { ip });
if (!reply.waitForFinished(1000) || !reply.returnValue())
qWarning() << "GatewayController::prepareRequest(): Failed to execute remote addKillSwitchAllowedRange call";
if (!reply.waitForFinished(1000) || !reply.returnValue()) {
qWarning() << "GatewayController::handleBeforeRequest(): failed to add killswitch exception for" << host;
}
});
}
}
#else
Q_UNUSED(host);
#endif
encRequestData.key = CryptoUtils::generateRandomBytes(32);
encRequestData.iv = CryptoUtils::generateRandomBytes(32);
encRequestData.salt = CryptoUtils::generateRandomBytes(8);
QJsonObject keyPayload;
keyPayload[apiDefs::key::aesKey] = QString(encRequestData.key.toBase64());
keyPayload[apiDefs::key::aesIv] = QString(encRequestData.iv.toBase64());
keyPayload[apiDefs::key::aesSalt] = QString(encRequestData.salt.toBase64());
QByteArray rsaKey = m_isDevEnvironment ? DEV_AGW_PUBLIC_KEY : PROD_AGW_PUBLIC_KEY;
EVP_PKEY *publicKey = CryptoUtils::loadPublicKeyFromPem(rsaKey);
if (publicKey == nullptr) {
qCritical() << "error loading public key from environment variables";
encRequestData.errorCode = ErrorCode::ApiMissingAgwPublicKey;
return encRequestData;
}
QByteArray encryptedKeyPayload = CryptoUtils::rsaEncrypt(QJsonDocument(keyPayload).toJson(), publicKey, RSA_PKCS1_PADDING);
EVP_PKEY_free(publicKey);
QByteArray encryptedApiPayload = CryptoUtils::encryptAes256Cbc(QJsonDocument(apiPayload).toJson(), encRequestData.key, encRequestData.iv);
if (encryptedKeyPayload.isEmpty() || encryptedApiPayload.isEmpty()) {
qCritical() << "error when encrypting the request body";
encRequestData.errorCode = ErrorCode::ApiConfigDecryptionError;
return encRequestData;
}
QJsonObject requestBody;
requestBody[apiDefs::key::keyPayload] = QString(encryptedKeyPayload.toBase64());
requestBody[apiDefs::key::apiPayload] = QString(encryptedApiPayload.toBase64());
encRequestData.requestBody = QJsonDocument(requestBody).toJson();
return encRequestData;
}
GatewayController::DecryptionResult GatewayController::tryDecryptResponseBody(const QByteArray &encryptedResponseBody,
QNetworkReply::NetworkError replyError, const QByteArray &key,
const QByteArray &iv, const QByteArray &salt)
void GatewayController::persistState()
{
DecryptionResult result;
result.decryptedBody = encryptedResponseBody;
result.isDecryptionSuccessful = false;
QByteArray decrypted = CryptoUtils::decryptAes256Cbc(encryptedResponseBody, key, iv);
if (!decrypted.isEmpty()) {
result.decryptedBody = decrypted;
result.isDecryptionSuccessful = true;
} else {
result.decryptedBody = encryptedResponseBody;
result.isDecryptionSuccessful = false;
}
return result;
}
ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody)
{
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
if (encRequestData.errorCode != ErrorCode::NoError) {
return encRequestData.errorCode;
}
QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
QEventLoop wait;
connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
QList<QSslError> sslErrors;
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
wait.exec(QEventLoop::ExcludeUserInputEvents);
QByteArray encryptedResponseBody = reply->readAll();
QString replyErrorString = reply->errorString();
auto replyError = reply->error();
int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
reply->deleteLater();
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) {
encRequestData.request.setUrl(url);
return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
};
auto replyProcessingFunction = [&encryptedResponseBody, &replyErrorString, &replyError, &httpStatusCode, &sslErrors, &encRequestData,
&decryptionResult, this](QNetworkReply *reply, const QList<QSslError> &nestedSslErrors) {
encryptedResponseBody = reply->readAll();
replyErrorString = reply->errorString();
replyError = reply->error();
httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
if (!sslErrors.isEmpty()
|| shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
sslErrors = nestedSslErrors;
return false;
}
return true;
};
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
bypassProxy(endpoint, serviceType, userCountryCode, requestFunction, replyProcessingFunction);
}
responseBody = decryptionResult.decryptedBody;
const auto errorCode =
apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode, responseBody);
if (errorCode) {
return errorCode;
}
if (!decryptionResult.isDecryptionSuccessful) {
qCritical() << "error when decrypting the request body";
return ErrorCode::ApiConfigDecryptionError;
}
return ErrorCode::NoError;
}
QFuture<QPair<ErrorCode, QByteArray>> GatewayController::postAsync(const QString &endpoint, const QJsonObject apiPayload)
{
auto promise = QSharedPointer<QPromise<QPair<ErrorCode, QByteArray>>>::create();
promise->start();
EncryptedRequestData encRequestData = prepareRequest(endpoint, apiPayload);
if (encRequestData.errorCode != ErrorCode::NoError) {
promise->addResult(qMakePair(encRequestData.errorCode, QByteArray()));
promise->finish();
return promise->future();
}
QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody);
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
connect(reply, &QNetworkReply::sslErrors, [sslErrors](const QList<QSslError> &errors) { *sslErrors = errors; });
connect(reply, &QNetworkReply::finished, this, [promise, sslErrors, encRequestData, endpoint, apiPayload, reply, this]() mutable {
QByteArray encryptedResponseBody = reply->readAll();
QString replyErrorString = reply->errorString();
auto replyError = reply->error();
int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
reply->deleteLater();
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
auto processResponse = [promise, encRequestData](const GatewayController::DecryptionResult &decryptionResult,
const QList<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
const QString &replyErrorString, int httpStatusCode) {
auto errorCode = apiUtils::checkNetworkReplyErrors(sslErrors, replyErrorString, replyError, httpStatusCode,
decryptionResult.decryptedBody);
if (errorCode) {
promise->addResult(qMakePair(errorCode, decryptionResult.decryptedBody));
promise->finish();
return;
}
if (!decryptionResult.isDecryptionSuccessful) {
qCritical() << "error when decrypting the request body";
promise->addResult(qMakePair(ErrorCode::ApiConfigDecryptionError, QByteArray()));
promise->finish();
return;
}
promise->addResult(qMakePair(ErrorCode::NoError, decryptionResult.decryptedBody));
promise->finish();
};
if (sslErrors->isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) {
auto serviceType = apiPayload.value(apiDefs::key::serviceType).toString("");
auto userCountryCode = apiPayload.value(apiDefs::key::userCountryCode).toString("");
QStringList primaryBaseUrls;
QStringList fallbackBaseUrls;
if (m_isDevEnvironment) {
primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
} else {
primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
}
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::shuffle(primaryBaseUrls.begin(), primaryBaseUrls.end(), generator);
std::shuffle(fallbackBaseUrls.begin(), fallbackBaseUrls.end(), generator);
auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) {
if (!serviceType.isEmpty()) {
for (const auto &baseUrl : baseUrls) {
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json");
}
}
for (const auto &baseUrl : baseUrls) {
target.push_back(baseUrl + "endpoints.json");
}
};
QStringList proxyStorageUrls;
appendStorageUrls(primaryBaseUrls, proxyStorageUrls);
appendStorageUrls(fallbackBaseUrls, proxyStorageUrls);
const QString proxyUrlsCacheKey = getProxyUrlsCacheKey(serviceType, userCountryCode);
getProxyUrlsAsync(proxyStorageUrls, 0, proxyUrlsCacheKey, [this, encRequestData, endpoint, processResponse](const QStringList &proxyUrls) {
getProxyUrlAsync(proxyUrls, 0, [this, encRequestData, endpoint, processResponse](const QString &proxyUrl) {
bypassProxyAsync(endpoint, proxyUrl, encRequestData,
[processResponse, this](const QByteArray &decryptedBody, bool isDecryptionSuccessful,
const QList<QSslError> &sslErrors, QNetworkReply::NetworkError replyError,
const QString &replyErrorString, int httpStatusCode) {
GatewayController::DecryptionResult result;
result.decryptedBody = decryptedBody;
result.isDecryptionSuccessful = isDecryptionSuccessful;
processResponse(result, sslErrors, replyError, replyErrorString, httpStatusCode);
});
});
});
} else {
processResponse(decryptionResult, *sslErrors, replyError, replyErrorString, httpStatusCode);
}
});
return promise->future();
}
QStringList GatewayController::getProxyUrls(const QString &serviceType, const QString &userCountryCode)
{
QNetworkRequest request;
request.setTransferTimeout(proxyStorageRequestTimeoutMsecs);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QEventLoop wait;
QList<QSslError> sslErrors;
QNetworkReply *reply;
QStringList primaryBaseUrls;
QStringList fallbackBaseUrls;
if (m_isDevEnvironment) {
primaryBaseUrls = QString(DEV_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
} else {
primaryBaseUrls = QString(PROD_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
fallbackBaseUrls = QString(FALLBACK_S3_ENDPOINT).split(", ", Qt::SkipEmptyParts);
}
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::shuffle(primaryBaseUrls.begin(), primaryBaseUrls.end(), generator);
std::shuffle(fallbackBaseUrls.begin(), fallbackBaseUrls.end(), generator);
auto appendStorageUrls = [&serviceType, &userCountryCode](const QStringList &baseUrls, QStringList &target) {
if (!serviceType.isEmpty()) {
for (const auto &baseUrl : baseUrls) {
QByteArray path = ("endpoints-" + serviceType + "-" + userCountryCode).toUtf8();
target.push_back(baseUrl + path.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals) + ".json");
}
}
for (const auto &baseUrl : baseUrls) {
target.push_back(baseUrl + "endpoints.json");
}
};
QStringList proxyStorageUrls;
appendStorageUrls(primaryBaseUrls, proxyStorageUrls);
appendStorageUrls(fallbackBaseUrls, proxyStorageUrls);
const QString proxyUrlsCacheKey = getProxyUrlsCacheKey(serviceType, userCountryCode);
const QByteArray cachedProxyUrlsEncrypted = m_appSettingsRepository->readGatewayProxyUrls(proxyUrlsCacheKey);
if (proxyStorageUrls.empty()) {
qDebug() << "empty storage endpoint list";
return readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment);
}
for (const auto &proxyStorageUrl : proxyStorageUrls) {
request.setUrl(proxyStorageUrl);
reply = amnApp->networkManager()->get(request);
connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
wait.exec(QEventLoop::ExcludeUserInputEvents);
if (reply->error() == QNetworkReply::NetworkError::NoError) {
auto encryptedResponseBody = reply->readAll();
reply->deleteLater();
QByteArray responseBody;
if (!decryptProxyUrlsPayload(encryptedResponseBody, m_isDevEnvironment, responseBody)) {
qCritical() << "error loading private key from environment variables or decrypting payload" << encryptedResponseBody;
continue;
}
auto endpointsArray = QJsonDocument::fromJson(responseBody).array();
QStringList endpoints;
for (const auto &endpoint : endpointsArray) {
endpoints.push_back(endpoint.toString());
}
m_appSettingsRepository->writeGatewayProxyUrls(proxyUrlsCacheKey, encryptedResponseBody);
return endpoints;
} else {
auto replyError = reply->error();
int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qDebug() << replyError;
qDebug() << httpStatusCode;
qDebug() << "go to the next storage endpoint";
reply->deleteLater();
}
}
return readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment);
}
bool GatewayController::shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody,
bool isDecryptionSuccessful)
{
const QByteArray &responseBody = decryptedResponseBody;
int apiHttpStatus = -1;
QString apiErrorMessage;
if (isDecryptionSuccessful) {
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseBody);
if (jsonDoc.isObject()) {
QJsonObject jsonObj = jsonDoc.object();
apiHttpStatus = jsonObj.value("http_status").toInt(-1);
apiErrorMessage = jsonObj.value(QStringLiteral("message")).toString().trimmed();
}
} else {
qDebug() << "failed to decrypt the data";
return true;
}
if (replyError == QNetworkReply::NetworkError::OperationCanceledError || replyError == QNetworkReply::NetworkError::TimeoutError) {
qDebug() << "timeout occurred";
qDebug() << replyError;
return true;
}
if (responseBody.contains("html")) {
qDebug() << "the response contains an html tag";
return true;
}
if (apiHttpStatus == httpStatusCodeRequestTimeout) {
return false;
}
if (apiHttpStatus == httpStatusCodeNotFound) {
if (responseBody.contains(errorResponsePattern1) || responseBody.contains(errorResponsePattern2)
|| responseBody.contains(errorResponsePattern3) || responseBody.contains(errorResponsePatternQrSessionNotFound)
|| responseBody.contains(errorResponsePatternSessionNotFound)) {
return false;
} else {
qDebug() << replyError;
return true;
}
}
if (apiHttpStatus == httpStatusCodeNotImplemented) {
if (responseBody.contains(updateRequestResponsePattern)) {
return false;
} else {
qDebug() << replyError;
return true;
}
}
if (apiHttpStatus == httpStatusCodeConflict) {
return false;
}
if (apiHttpStatus == httpStatusCodePaymentRequired) {
return false;
}
if (apiHttpStatus == httpStatusCodeUnprocessableEntity) {
return apiErrorMessage != unprocessableSubscriptionMessage;
}
if (replyError != QNetworkReply::NetworkError::NoError) {
qDebug() << replyError;
return true;
}
return false;
}
void GatewayController::bypassProxy(const QString &endpoint, const QString &serviceType, const QString &userCountryCode,
std::function<QNetworkReply *(const QString &url)> requestFunction,
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction)
{
QStringList proxyUrls = getProxyUrls(serviceType, userCountryCode);
std::random_device randomDevice;
std::mt19937 generator(randomDevice());
std::shuffle(proxyUrls.begin(), proxyUrls.end(), generator);
QByteArray responseBody;
auto bypassFunction = [this](const QString &endpoint, const QString &proxyUrl,
std::function<QNetworkReply *(const QString &url)> requestFunction,
std::function<bool(QNetworkReply * reply, const QList<QSslError> &sslErrors)> replyProcessingFunction) {
QEventLoop wait;
QList<QSslError> sslErrors;
qDebug() << "go to the next proxy endpoint";
QNetworkReply *reply = requestFunction(endpoint.arg(proxyUrl));
QObject::connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
wait.exec(QEventLoop::ExcludeUserInputEvents);
auto result = replyProcessingFunction(reply, sslErrors);
reply->deleteLater();
return result;
};
if (m_proxyUrl.isEmpty()) {
QNetworkRequest request;
request.setTransferTimeout(1000);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QEventLoop wait;
QList<QSslError> sslErrors;
QNetworkReply *reply;
for (const QString &proxyUrl : proxyUrls) {
request.setUrl(proxyUrl + "lmbd-health");
reply = amnApp->networkManager()->get(request);
connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit);
connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList<QSslError> &errors) { sslErrors = errors; });
wait.exec(QEventLoop::ExcludeUserInputEvents);
if (reply->error() == QNetworkReply::NetworkError::NoError) {
reply->deleteLater();
m_proxyUrl = proxyUrl;
if (!m_proxyUrl.isEmpty()) {
break;
}
} else {
reply->deleteLater();
}
}
}
if (!m_proxyUrl.isEmpty()) {
if (bypassFunction(endpoint, m_proxyUrl, requestFunction, replyProcessingFunction)) {
return;
}
}
for (const QString &proxyUrl : proxyUrls) {
if (bypassFunction(endpoint, proxyUrl, requestFunction, replyProcessingFunction)) {
m_proxyUrl = proxyUrl;
break;
}
}
}
void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
const QString &proxyUrlsCacheKey, std::function<void(const QStringList &)> onComplete)
{
const QByteArray cachedProxyUrlsEncrypted = m_appSettingsRepository->readGatewayProxyUrls(proxyUrlsCacheKey);
if (currentProxyStorageIndex >= proxyStorageUrls.size()) {
onComplete(shuffledProxyUrls(readCachedProxyUrls(cachedProxyUrlsEncrypted, m_isDevEnvironment)));
if (m_client == 0 || m_appSettingsRepository == nullptr) {
return;
}
QNetworkRequest request;
request.setTransferTimeout(proxyStorageRequestTimeoutMsecs);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setUrl(proxyStorageUrls[currentProxyStorageIndex]);
QNetworkReply *reply = amnApp->networkManager()->get(request);
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) { *(state->sslErrors) = e; });
connect(reply, &QNetworkReply::finished, this,
[this, proxyStorageUrls, currentProxyStorageIndex, proxyUrlsCacheKey, onComplete, reply]() {
if (reply->error() == QNetworkReply::NoError) {
QByteArray encrypted = reply->readAll();
reply->deleteLater();
QByteArray responseBody;
if (!decryptProxyUrlsPayload(encrypted, m_isDevEnvironment, responseBody)) {
qCritical() << "error decrypting payload";
QMetaObject::invokeMethod(
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, proxyUrlsCacheKey, onComplete); }, Qt::QueuedConnection);
return;
}
QJsonArray endpointsArray = QJsonDocument::fromJson(responseBody).array();
QStringList endpoints;
for (const QJsonValue &endpoint : endpointsArray)
endpoints.push_back(endpoint.toString());
m_appSettingsRepository->writeGatewayProxyUrls(proxyUrlsCacheKey, encrypted);
onComplete(shuffledProxyUrls(endpoints));
return;
}
int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qDebug() << httpStatusCode;
qDebug() << "go to the next storage endpoint";
reply->deleteLater();
QMetaObject::invokeMethod(
this, [=]() { getProxyUrlsAsync(proxyStorageUrls, currentProxyStorageIndex + 1, proxyUrlsCacheKey, onComplete); }, Qt::QueuedConnection);
});
}
void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex,
std::function<void(const QString &)> onComplete)
{
if (currentProxyIndex >= proxyUrls.size()) {
onComplete("");
char *state = agw_export_state(m_client);
if (state == nullptr) {
return;
}
const QByteArray blob(state);
agw_string_free(state);
QNetworkRequest request;
request.setTransferTimeout(1000);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setUrl(proxyUrls[currentProxyIndex] + "lmbd-health");
QNetworkReply *reply = amnApp->networkManager()->get(request);
// connect(reply, &QNetworkReply::sslErrors, this, [state](const QList<QSslError> &e) {
// *(state->sslErrors) = e;
// });
connect(reply, &QNetworkReply::finished, this, [this, proxyUrls, currentProxyIndex, onComplete, reply]() {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError) {
m_proxyUrl = proxyUrls[currentProxyIndex];
onComplete(m_proxyUrl);
return;
}
qDebug() << "go to the next proxy endpoint";
QMetaObject::invokeMethod(this, [=]() { getProxyUrlAsync(proxyUrls, currentProxyIndex + 1, onComplete); }, Qt::QueuedConnection);
});
}
void GatewayController::bypassProxyAsync(
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete)
{
auto sslErrors = QSharedPointer<QList<QSslError>>::create();
if (proxyUrl.isEmpty()) {
onComplete(QByteArray(), false, *sslErrors, QNetworkReply::InternalServerError, "empty proxy url", 0);
return;
if (blob != m_lastPersistedState) {
m_appSettingsRepository->writeGatewayProxyUrls(agwStateCacheKey, blob);
m_lastPersistedState = blob;
}
QNetworkRequest request = encRequestData.request;
request.setUrl(endpoint.arg(proxyUrl));
QNetworkReply *reply = amnApp->networkManager()->post(request, encRequestData.requestBody);
connect(reply, &QNetworkReply::sslErrors, this, [sslErrors](const QList<QSslError> &errors) { *sslErrors = errors; });
connect(reply, &QNetworkReply::finished, this, [sslErrors, onComplete, encRequestData, reply, this]() {
QByteArray encryptedResponseBody = reply->readAll();
QString replyErrorString = reply->errorString();
auto replyError = reply->error();
int httpStatusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
reply->deleteLater();
auto decryptionResult =
tryDecryptResponseBody(encryptedResponseBody, replyError, encRequestData.key, encRequestData.iv, encRequestData.salt);
onComplete(decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful, *sslErrors, replyError, replyErrorString,
httpStatusCode);
});
}
+13 -43
View File
@@ -1,20 +1,16 @@
#ifndef GATEWAYCONTROLLER_H
#define GATEWAYCONTROLLER_H
#include <QByteArray>
#include <QFuture>
#include <QNetworkReply>
#include <QJsonObject>
#include <QObject>
#include <QPair>
#include <QPromise>
#include <QSharedPointer>
#include <QString>
#include "core/utils/errorCodes.h"
#include "core/utils/routeModes.h"
#include "core/utils/commonStructs.h"
#ifdef Q_OS_IOS
#include "platforms/ios/ios_controller.h"
#endif
#include "agw.h"
class SecureAppSettingsRepository;
@@ -26,51 +22,25 @@ public:
explicit GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
const bool isStrictKillSwitchEnabled, SecureAppSettingsRepository *appSettingsRepository,
QObject *parent = nullptr);
~GatewayController() override;
amnezia::ErrorCode post(const QString &endpoint, const QJsonObject apiPayload, QByteArray &responseBody);
QFuture<QPair<amnezia::ErrorCode, QByteArray>> postAsync(const QString &endpoint, const QJsonObject apiPayload);
private:
struct EncryptedRequestData
{
QNetworkRequest request;
QByteArray requestBody;
QByteArray key;
QByteArray iv;
QByteArray salt;
amnezia::ErrorCode errorCode;
};
static void onBeforeRequest(const char *host, void *userData);
void handleBeforeRequest(const QString &host);
struct DecryptionResult
{
QByteArray decryptedBody;
bool isDecryptionSuccessful;
};
QPair<amnezia::ErrorCode, QByteArray> executePost(const QString &endpoint, const QJsonObject &apiPayload);
static amnezia::ErrorCode mapResultCode(const int code, const QByteArray &responseBody);
void persistState();
EncryptedRequestData prepareRequest(const QString &endpoint, const QJsonObject &apiPayload);
DecryptionResult tryDecryptResponseBody(const QByteArray &encryptedResponseBody, QNetworkReply::NetworkError replyError,
const QByteArray &key, const QByteArray &iv, const QByteArray &salt);
QStringList getProxyUrls(const QString &serviceType, const QString &userCountryCode);
bool shouldBypassProxy(const QNetworkReply::NetworkError &replyError, const QByteArray &decryptedResponseBody, bool isDecryptionSuccessful);
void bypassProxy(const QString &endpoint, const QString &serviceType, const QString &userCountryCode,
std::function<QNetworkReply *(const QString &url)> requestFunction,
std::function<bool(QNetworkReply *reply, const QList<QSslError> &sslErrors)> replyProcessingFunction);
void getProxyUrlsAsync(const QStringList proxyStorageUrls, const int currentProxyStorageIndex,
const QString &proxyUrlsCacheKey, std::function<void(const QStringList &)> onComplete);
void getProxyUrlAsync(const QStringList proxyUrls, const int currentProxyIndex, std::function<void(const QString &)> onComplete);
void bypassProxyAsync(
const QString &endpoint, const QString &proxyUrl, EncryptedRequestData encRequestData,
std::function<void(const QByteArray &, bool, const QList<QSslError> &, QNetworkReply::NetworkError, const QString &, int)> onComplete);
int m_requestTimeoutMsecs;
QString m_gatewayEndpoint;
bool m_isDevEnvironment = false;
bool m_isStrictKillSwitchEnabled = false;
SecureAppSettingsRepository *m_appSettingsRepository = nullptr;
inline static QString m_proxyUrl;
agw_client_handle m_client = 0;
bool m_publicKeyMissing = false;
QByteArray m_lastPersistedState;
};
#endif // GATEWAYCONTROLLER_H
@@ -1,6 +1,7 @@
#include "ipSplitTunnelingController.h"
#include "core/utils/networkUtilities.h"
#include <QJsonObject>
#include <QDebug>
IpSplitTunnelingController::IpSplitTunnelingController(SecureAppSettingsRepository* appSettingsRepository, QObject* parent)
: QObject(parent),
@@ -14,47 +15,56 @@ IpSplitTunnelingController::IpSplitTunnelingController(SecureAppSettingsReposito
fillSites();
}
bool IpSplitTunnelingController::addSiteInternal(const QString &hostname, const QString &ip)
bool IpSplitTunnelingController::addSiteInternal(const QString &hostname, const QStringList &ips)
{
QVariantMap existing = m_appSettingsRepository->vpnSites(m_currentRouteMode);
if (existing.contains(hostname) && ip.isEmpty()) {
if (existing.contains(hostname) && ips.isEmpty()) {
return false;
}
for (int i = 0; i < m_sites.size(); i++) {
if (m_sites[i].first == hostname && (m_sites[i].second.isEmpty() && !ip.isEmpty())) {
m_sites[i].second = ip;
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ip);
if (m_sites[i].first == hostname) {
bool changed = false;
for (const QString &ip : ips) {
if (!ip.isEmpty() && !m_sites[i].second.contains(ip)) {
m_sites[i].second.append(ip);
changed = true;
}
}
if (!changed) {
return false;
}
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ips);
return true;
} else if (m_sites[i].first == hostname && (m_sites[i].second == ip)) {
return false;
}
}
m_sites.append(qMakePair(hostname, ip));
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ip);
m_sites.append(qMakePair(hostname, ips));
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ips);
return true;
}
void IpSplitTunnelingController::addSites(const QMap<QString, QString> &sites, bool replaceExisting)
void IpSplitTunnelingController::addSites(const QMap<QString, QStringList> &sites, bool replaceExisting)
{
if (replaceExisting) {
m_sites.clear();
}
for (auto it = sites.constBegin(); it != sites.constEnd(); ++it) {
const QString &hostname = it.key();
const QString &ip = it.value();
const QStringList &ips = it.value();
bool found = false;
for (int i = 0; i < m_sites.size(); i++) {
if (m_sites[i].first == hostname) {
if (!ip.isEmpty()) {
m_sites[i].second = ip;
for (const QString &ip : ips) {
if (!ip.isEmpty() && !m_sites[i].second.contains(ip)) {
m_sites[i].second.append(ip);
}
}
found = true;
break;
}
}
if (!found) {
m_sites.append(qMakePair(hostname, ip));
m_sites.append(qMakePair(hostname, ips));
}
}
if (replaceExisting) {
@@ -72,11 +82,11 @@ bool IpSplitTunnelingController::addSite(const QString &hostname)
}
if (NetworkUtilities::ipAddressWithSubnetRegExp().exactMatch(normalizedHostname)) {
processSite(normalizedHostname, "");
processSite(normalizedHostname, {});
return true;
}
if (addSiteInternal(normalizedHostname, "")) {
if (addSiteInternal(normalizedHostname, {})) {
QHostInfo::lookupHost(normalizedHostname, this, SLOT(onHostResolved(QHostInfo)));
return true;
}
@@ -124,7 +134,7 @@ bool IpSplitTunnelingController::isSplitTunnelingEnabled() const
return m_appSettingsRepository->isSitesSplitTunnelingEnabled();
}
QVector<QPair<QString, QString>> IpSplitTunnelingController::getCurrentSites() const
QVector<QPair<QString, QStringList>> IpSplitTunnelingController::getCurrentSites() const
{
return m_sites;
}
@@ -134,7 +144,7 @@ void IpSplitTunnelingController::fillSites()
QVariantMap sitesMap = m_appSettingsRepository->vpnSites(m_currentRouteMode);
m_sites.clear();
for (auto it = sitesMap.begin(); it != sitesMap.end(); ++it) {
m_sites.append(qMakePair(it.key(), it.value().toString()));
m_sites.append(qMakePair(it.key(), SecureAppSettingsRepository::siteIpList(it.value())));
}
}
@@ -144,8 +154,13 @@ QString IpSplitTunnelingController::normalizeHostname(const QString &hostname) c
normalized.replace("https://", "");
normalized.replace("http://", "");
normalized.replace("ftp://", "");
normalized = normalized.split("/", Qt::SkipEmptyParts).first();
return normalized;
if (NetworkUtilities::ipAddressWithSubnetRegExp().exactMatch(normalized)) {
return normalized;
}
const QStringList parts = normalized.split("/", Qt::SkipEmptyParts);
return parts.isEmpty() ? QString() : parts.first();
}
bool IpSplitTunnelingController::validateHostname(const QString &hostname) const
@@ -164,29 +179,40 @@ void IpSplitTunnelingController::onHostResolved(const QHostInfo &hostInfo)
{
const QList<QHostAddress> &addresses = hostInfo.addresses();
QString hostname = hostInfo.hostName();
QStringList allIpv4;
for (const QHostAddress &addr : addresses) {
if (addr.protocol() == QAbstractSocket::NetworkLayerProtocol::IPv4Protocol) {
processSiteAfterResolve(hostname, addr.toString());
break;
allIpv4.append(addr.toString());
}
}
allIpv4.removeDuplicates();
qDebug() << "[SplitTunneling] Host resolved:" << hostname
<< "-> adding all IPv4 addresses to list:" << allIpv4;
if (!allIpv4.isEmpty()) {
processSiteAfterResolve(hostname, allIpv4);
}
}
void IpSplitTunnelingController::processSiteAfterResolve(const QString &hostname, const QString &ip)
void IpSplitTunnelingController::processSiteAfterResolve(const QString &hostname, const QStringList &ips)
{
for (int i = 0; i < m_sites.size(); i++) {
if (m_sites[i].first == hostname && m_sites[i].second.isEmpty()) {
m_sites[i].second = ip;
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ip);
if (m_sites[i].first == hostname) {
for (const QString &ip : ips) {
if (!ip.isEmpty() && !m_sites[i].second.contains(ip)) {
m_sites[i].second.append(ip);
}
}
break;
}
}
m_appSettingsRepository->addVpnSite(m_currentRouteMode, hostname, ips);
}
void IpSplitTunnelingController::processSite(const QString &hostname, const QString &ip)
void IpSplitTunnelingController::processSite(const QString &hostname, const QStringList &ips)
{
addSiteInternal(hostname, ip);
addSiteInternal(hostname, ips);
}
bool IpSplitTunnelingController::importSitesFromJson(const QByteArray& jsonData, bool replaceExisting, QString &errorMessage)
@@ -205,12 +231,25 @@ bool IpSplitTunnelingController::importSitesFromJson(const QByteArray& jsonData,
}
QJsonArray jsonArray = jsonDocument.array();
QMap<QString, QString> sites;
QMap<QString, QStringList> sites;
for (auto jsonValue : jsonArray) {
QJsonObject jsonObject = jsonValue.toObject();
QString hostname = jsonObject.value("hostname").toString("");
QString ip = jsonObject.value("ip").toString("");
QStringList ips;
if (jsonObject.value("ips").isArray()) {
const QJsonArray ipsArray = jsonObject.value("ips").toArray();
for (const auto &ipValue : ipsArray) {
ips.append(ipValue.toString());
}
}
const QString singleIp = jsonObject.value("ip").toString("");
if (!singleIp.isEmpty()) {
ips.append(singleIp);
}
ips.removeAll(QString());
ips.removeDuplicates();
QString normalizedHostname = normalizeHostname(hostname);
@@ -219,7 +258,7 @@ bool IpSplitTunnelingController::importSitesFromJson(const QByteArray& jsonData,
continue;
}
sites.insert(normalizedHostname, ip);
sites.insert(normalizedHostname, ips);
}
addSites(sites, replaceExisting);
@@ -229,13 +268,21 @@ bool IpSplitTunnelingController::importSitesFromJson(const QByteArray& jsonData,
QByteArray IpSplitTunnelingController::exportSitesToJson() const
{
QVector<QPair<QString, QString>> sites = getCurrentSites();
QVector<QPair<QString, QStringList>> sites = getCurrentSites();
QJsonArray jsonArray;
for (const auto &site : sites) {
QJsonObject jsonObject;
jsonObject["hostname"] = site.first;
jsonObject["ip"] = site.second;
QJsonArray ipsArray;
for (const QString &ip : site.second) {
ipsArray.append(ip);
}
jsonObject["ips"] = ipsArray;
// Keep the legacy "ip" field (first address) for backward compatibility.
jsonObject["ip"] = site.second.isEmpty() ? QString() : site.second.first();
jsonArray.append(jsonObject);
}
@@ -25,7 +25,7 @@ public:
explicit IpSplitTunnelingController(SecureAppSettingsRepository* appSettingsRepository, QObject* parent = nullptr);
bool addSite(const QString &hostname);
void addSites(const QMap<QString, QString> &sites, bool replaceExisting);
void addSites(const QMap<QString, QStringList> &sites, bool replaceExisting);
bool removeSite(const QString &hostname);
void removeSites();
void setRouteMode(RouteMode routeMode);
@@ -33,7 +33,7 @@ public:
RouteMode getRouteMode() const;
bool isSplitTunnelingEnabled() const;
QVector<QPair<QString, QString>> getCurrentSites() const;
QVector<QPair<QString, QStringList>> getCurrentSites() const;
bool importSitesFromJson(const QByteArray& jsonData, bool replaceExisting, QString &errorMessage);
QByteArray exportSitesToJson() const;
@@ -43,15 +43,15 @@ private slots:
private:
void fillSites();
bool addSiteInternal(const QString &hostname, const QString &ip);
bool addSiteInternal(const QString &hostname, const QStringList &ips);
QString normalizeHostname(const QString &hostname) const;
bool validateHostname(const QString &hostname) const;
void processSiteAfterResolve(const QString &hostname, const QString &ip);
void processSite(const QString &hostname, const QString &ip);
void processSiteAfterResolve(const QString &hostname, const QStringList &ips);
void processSite(const QString &hostname, const QStringList &ips);
SecureAppSettingsRepository* m_appSettingsRepository;
RouteMode m_currentRouteMode;
QVector<QPair<QString, QString>> m_sites;
QVector<QPair<QString, QStringList>> m_sites;
};
#endif // IPSPLITTUNNELINGCONTROLLER_H
+270
View File
@@ -0,0 +1,270 @@
#include "logsController.h"
#include <QFile>
#include <QFileInfo>
#ifdef AMNEZIA_HAS_QZIPWRITER
#include <QBuffer>
#include <QtCore/private/qzipwriter_p.h>
#else
#include <QDateTime>
#include <array>
#endif
#include <limits>
#include "logger.h"
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "core/utils/swiftBridge.h"
#elif defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#endif
#ifndef AMNEZIA_HAS_QZIPWRITER
namespace
{
quint32 crc32(const QByteArray &data)
{
static const std::array<quint32, 256> table = [] {
std::array<quint32, 256> result {};
for (quint32 i = 0; i < 256; ++i) {
quint32 value = i;
for (int bit = 0; bit < 8; ++bit) {
value = (value & 1) ? 0xEDB88320u ^ (value >> 1) : value >> 1;
}
result[i] = value;
}
return result;
}();
quint32 crc = 0xFFFFFFFFu;
for (const char byte : data) {
crc = table[(crc ^ static_cast<uchar>(byte)) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFFu;
}
void put16(QByteArray &out, quint16 value)
{
out.append(static_cast<char>(value & 0xFF));
out.append(static_cast<char>(value >> 8));
}
void put32(QByteArray &out, quint32 value)
{
put16(out, static_cast<quint16>(value & 0xFFFF));
put16(out, static_cast<quint16>(value >> 16));
}
QByteArray storedZip(const QList<QPair<QString, QByteArray>> &entries)
{
const QDateTime now = QDateTime::currentDateTime();
const quint16 dosTime = (now.time().hour() << 11) | (now.time().minute() << 5) | (now.time().second() / 2);
const quint16 dosDate = ((now.date().year() - 1980) << 9) | (now.date().month() << 5) | now.date().day();
QByteArray archive;
QByteArray centralDirectory;
for (const auto &[name, data] : entries) {
const QByteArray fileName = name.toUtf8();
const quint32 offset = static_cast<quint32>(archive.size());
const quint32 size = static_cast<quint32>(data.size());
QByteArray fields;
put16(fields, 10);
put16(fields, 0);
put16(fields, 0);
put16(fields, dosTime);
put16(fields, dosDate);
put32(fields, crc32(data));
put32(fields, size);
put32(fields, size);
put16(fields, static_cast<quint16>(fileName.size()));
put16(fields, 0);
put32(archive, 0x04034b50);
archive.append(fields).append(fileName).append(data);
put32(centralDirectory, 0x02014b50);
put16(centralDirectory, 20);
centralDirectory.append(fields);
put16(centralDirectory, 0);
put16(centralDirectory, 0);
put16(centralDirectory, 0);
put32(centralDirectory, 0);
put32(centralDirectory, offset);
centralDirectory.append(fileName);
}
const quint32 centralDirectoryOffset = static_cast<quint32>(archive.size());
archive.append(centralDirectory);
put32(archive, 0x06054b50);
put16(archive, 0);
put16(archive, 0);
put16(archive, static_cast<quint16>(entries.size()));
put16(archive, static_cast<quint16>(entries.size()));
put32(archive, static_cast<quint32>(centralDirectory.size()));
put32(archive, centralDirectoryOffset);
put16(archive, 0);
return archive;
}
}
#endif
QStringList LogsController::files(Stream stream)
{
#if defined(AMNEZIA_DESKTOP)
if (stream == Stream::App) {
return { Logger::userLogsFilePath() };
}
return { Logger::serviceLogsFilePath() };
#elif defined(Q_OS_IOS) || defined(MACOS_NE)
if (stream == Stream::App) {
return { QString::fromStdString(SWIFT_BRIDGE_NAMESPACE::swiftAppLogPath()), Logger::userLogsFilePath() };
}
return { QString::fromStdString(SWIFT_BRIDGE_NAMESPACE::swiftTunnelLogPath()) };
#elif defined(Q_OS_ANDROID)
return AndroidController::logFiles(static_cast<int>(stream));
#endif
}
qint64 LogsController::size(Stream stream)
{
qint64 total = 0;
for (const QString &path : files(stream)) {
total += fileSize(path);
}
return total;
}
qint64 LogsController::fileSize(const QString &path)
{
QFile file(path);
return file.open(QIODevice::ReadOnly) ? file.size() : 0;
}
QString LogsController::readTail(Stream stream, qint64 *newestFileEnd, qint64 maxBytes)
{
return QString::fromUtf8(read(stream, maxBytes, newestFileEnd));
}
QByteArray LogsController::readAll(Stream stream)
{
return read(stream, std::numeric_limits<qint64>::max());
}
QByteArray LogsController::read(Stream stream, qint64 maxBytes, qint64 *newestFileEnd)
{
QByteArray data = tailOfFiles(files(stream), maxBytes, newestFileEnd);
#if defined(Q_OS_ANDROID)
if (stream == Stream::App && maxBytes == std::numeric_limits<qint64>::max()) {
data.prepend(AndroidController::deviceInfo().toUtf8() + '\n');
}
#endif
return data;
}
void LogsController::clear(Stream stream)
{
#if defined(AMNEZIA_DESKTOP)
if (stream == Stream::App) {
Logger::clearLogs(false);
} else {
Logger::clearServiceLogs();
}
#elif defined(Q_OS_IOS) || defined(MACOS_NE)
if (stream == Stream::App) {
Logger::clearLogs(false);
QFile::resize(QString::fromStdString(SWIFT_BRIDGE_NAMESPACE::swiftAppLogPath()), 0);
} else {
QFile::resize(QString::fromStdString(SWIFT_BRIDGE_NAMESPACE::swiftTunnelLogPath()), 0);
}
#elif defined(Q_OS_ANDROID)
AndroidController::clearLogStream(static_cast<int>(stream));
#endif
}
QByteArray LogsController::zipAll()
{
return zipFiles({ { QStringLiteral("app.log"), readAll(Stream::App) },
{ QStringLiteral("tunnel.log"), readAll(Stream::Tunnel) } });
}
QByteArray LogsController::zipFiles(const QList<QPair<QString, QByteArray>> &entries)
{
#ifdef AMNEZIA_HAS_QZIPWRITER
QBuffer buffer;
buffer.open(QIODevice::WriteOnly);
QZipWriter zip(&buffer);
zip.setCompressionPolicy(QZipWriter::AlwaysCompress);
for (const auto &[name, data] : entries) {
zip.addFile(name, data);
}
zip.close();
return buffer.data();
#else
return storedZip(entries);
#endif
}
QByteArray LogsController::tailOfFiles(const QStringList &paths, qint64 maxBytes, qint64 *newestFileEnd)
{
if (newestFileEnd) {
*newestFileEnd = 0;
}
QList<QPair<QString, QByteArray>> chunks;
qint64 remaining = maxBytes;
for (auto it = paths.crbegin(); it != paths.crend() && remaining > 0; ++it) {
QFile file(*it);
if (!file.open(QIODevice::ReadOnly)) {
continue;
}
const qint64 fileSize = file.size();
QByteArray chunk;
if (fileSize <= remaining) {
chunk = file.read(fileSize);
remaining -= fileSize;
} else {
file.seek(fileSize - remaining - 1);
chunk = file.read(remaining + 1);
const qsizetype newline = chunk.indexOf('\n');
qsizetype start = newline + 1;
if (newline < 0) {
start = 1;
while (start < chunk.size() && (static_cast<uchar>(chunk.at(start)) & 0xC0) == 0x80) {
++start;
}
}
chunk = chunk.mid(start);
remaining = 0;
}
if (newestFileEnd && it == paths.crbegin()) {
*newestFileEnd = file.pos();
}
if (!chunk.isEmpty()) {
chunks.prepend({ QFileInfo(*it).fileName(), chunk });
}
}
if (chunks.size() == 1) {
return chunks.first().second;
}
QByteArray result;
for (const auto &[fileName, chunk] : chunks) {
result += "----- " + fileName.toUtf8() + " -----\n" + chunk;
if (!chunk.endsWith('\n')) {
result += '\n';
}
}
return result;
}
+30
View File
@@ -0,0 +1,30 @@
#ifndef LOGSCONTROLLER_H
#define LOGSCONTROLLER_H
#include <QByteArray>
#include <QList>
#include <QPair>
#include <QString>
#include <QStringList>
class LogsController
{
public:
enum class Stream { App, Tunnel };
static QStringList files(Stream stream);
static qint64 size(Stream stream);
static qint64 fileSize(const QString &path);
static QString readTail(Stream stream, qint64 *newestFileEnd = nullptr, qint64 maxBytes = 512 * 1024);
static QByteArray readAll(Stream stream);
static void clear(Stream stream);
static QByteArray zipAll();
static QByteArray zipFiles(const QList<QPair<QString, QByteArray>> &entries);
static QByteArray tailOfFiles(const QStringList &paths, qint64 maxBytes, qint64 *newestFileEnd = nullptr);
private:
static QByteArray read(Stream stream, qint64 maxBytes, qint64 *newestFileEnd = nullptr);
};
#endif // LOGSCONTROLLER_H
@@ -14,6 +14,7 @@
#include "core/models/selfhosted/selfHostedAdminServerConfig.h"
#include "core/models/containerConfig.h"
#include "core/models/protocolConfig.h"
#include "version.h"
using namespace amnezia;
@@ -220,7 +221,10 @@ ExportController::ExportResult ExportController::generateWireGuardConfig(const Q
result.config.append(line + "\n");
}
result.qrCodes << generateSingleQrCode(result.config.toUtf8());
const QString qr = qrCodeUtils::generatePlainQrCodeImage(result.config.toUtf8());
if (!qr.isEmpty()) {
result.qrCodes << qr;
}
return result;
}
@@ -251,7 +255,10 @@ ExportController::ExportResult ExportController::generateAwgConfig(const QString
result.config.append(line + "\n");
}
result.qrCodes << generateSingleQrCode(result.config.toUtf8());
const QString qr = qrCodeUtils::generatePlainQrCodeImage(result.config.toUtf8());
if (!qr.isEmpty()) {
result.qrCodes << qr;
}
return result;
}
@@ -337,7 +344,7 @@ ExportController::ExportResult ExportController::generateXrayConfig(const QStrin
// VlessServerObject doesn't have alpn field, so we embed in serverName if needed
}
result.nativeConfigString = amnezia::serialization::vless::Serialize(vlessServer, "AmneziaVPN");
result.nativeConfigString = amnezia::serialization::vless::Serialize(vlessServer, APPLICATION_NAME);
return result;
}
@@ -369,9 +376,3 @@ QList<QString> ExportController::generateQrCodesFromConfig(const QByteArray &dat
{
return qrCodeUtils::generateQrCodeImageSeries(data);
}
QString ExportController::generateSingleQrCode(const QByteArray &data)
{
auto qr = qrCodeUtils::generateQrCode(data);
return qrCodeUtils::svgToBase64(QString::fromStdString(toSvgString(qr, 1)));
}
@@ -69,7 +69,6 @@ private:
QString generateVpnUrl(const QByteArray &compressedConfig);
QList<QString> generateQrCodesFromConfig(const QByteArray &data);
QString generateSingleQrCode(const QByteArray &data);
SecureServersRepository* m_serversRepository;
SecureAppSettingsRepository* m_appSettingsRepository;
@@ -33,6 +33,18 @@ using namespace ProtocolUtils;
namespace
{
bool rejectUnsupportedFormatVersion(ImportController::ImportResult &result)
{
if (serverConfigUtils::isConfigFormatVersionSupported(result.config)) {
return false;
}
qWarning() << "Config format version" << serverConfigUtils::configFormatVersion(result.config)
<< "is newer than supported" << serverConfigUtils::currentConfigFormatVersion;
result.errorCode = ErrorCode::ConfigFormatVersionNotSupportedError;
result.config = {};
return true;
}
ConfigTypes checkConfigFormat(const QString &config)
{
const QString openVpnConfigPatternCli = "client";
@@ -52,6 +64,7 @@ namespace
const QString amneziaFreeConfigPattern = "api_key";
const QString amneziaPremiumConfigPattern = "auth_data";
const QString backupPattern = "Servers/serversList";
const QString xraySettingsPattern = "serverConfig";
if (config.contains(backupPattern)) {
return ConfigTypes::Backup;
@@ -67,6 +80,8 @@ namespace
} else if (config.contains(openVpnConfigPatternCli)
&& (config.contains(openVpnConfigPatternDriver1) || config.contains(openVpnConfigPatternDriver2))) {
return ConfigTypes::OpenVpn;
} else if (QJsonDocument::fromJson(config.toUtf8()).object().value(xraySettingsPattern).isObject()) {
return ConfigTypes::XraySettings;
}
return ConfigTypes::Invalid;
}
@@ -207,6 +222,10 @@ ImportController::ImportResult ImportController::extractConfigFromData(const QSt
case ConfigTypes::Amnezia: {
result.config = QJsonDocument::fromJson(config.toUtf8()).object();
if (rejectUnsupportedFormatVersion(result)) {
return result;
}
if (serverConfigUtils::isServerFromApi(result.config)) {
auto apiConfig = result.config.value(apiDefs::key::apiConfig).toObject();
apiConfig[apiDefs::key::vpnKey] = data;
@@ -231,6 +250,10 @@ ImportController::ImportResult ImportController::extractConfigFromData(const QSt
result.errorCode = ErrorCode::ImportBackupFileUseRestoreInstead;
return result;
}
case ConfigTypes::XraySettings: {
result.errorCode = ErrorCode::ImportXraySettingsFileUseXrayPage;
return result;
}
case ConfigTypes::Invalid: {
result.errorCode = ErrorCode::ImportInvalidConfigError;
result.configFileName.clear();
@@ -256,6 +279,7 @@ ImportController::ImportResult ImportController::extractConfigFromQr(const QByte
if (!dataObj.isEmpty()) {
result.config = dataObj;
result.configType = ConfigTypes::Amnezia;
rejectUnsupportedFormatVersion(result);
return result;
}
@@ -267,6 +291,7 @@ ImportController::ImportResult ImportController::extractConfigFromQr(const QByte
return result;
}
result.configType = ConfigTypes::Amnezia;
rejectUnsupportedFormatVersion(result);
return result;
}
@@ -284,6 +309,7 @@ ImportController::ImportResult ImportController::extractConfigFromQr(const QByte
return result;
}
result.configType = ConfigTypes::Amnezia;
rejectUnsupportedFormatVersion(result);
return result;
}
@@ -379,6 +405,11 @@ int ImportController::qrChunksTotal() const
void ImportController::importConfig(const QJsonObject &config)
{
if (!serverConfigUtils::isConfigFormatVersionSupported(config)) {
emit importErrorOccurred(ErrorCode::ConfigFormatVersionNotSupportedError, false);
return;
}
ServerCredentials credentials;
credentials.hostName = config.value(configKey::hostName).toString();
credentials.port = config.value(configKey::port).toInt();
@@ -511,9 +542,10 @@ QJsonObject ImportController::extractWireGuardConfig(const QString &data, Config
if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) {
continue;
} else {
QStringList parts = trimmedLine.split(" = ");
if (parts.count() == 2) {
configMap[parts.at(0).trimmed()] = parts.at(1).trimmed();
const qsizetype separatorIndex = trimmedLine.indexOf('=');
if (separatorIndex > 0) {
configMap[trimmedLine.left(separatorIndex).trimmed()] =
trimmedLine.mid(separatorIndex + 1).trimmed();
}
}
}
@@ -566,53 +598,26 @@ QJsonObject ImportController::extractWireGuardConfig(const QString &data, Config
lastConfig[configKey::persistentKeepAlive] = configMap.value(protocols::wireguard::PersistentKeepalive);
}
QJsonArray allowedIpsJsonArray = QJsonArray::fromStringList(
configMap.value(protocols::wireguard::AllowedIPs).split(", "));
const QStringList allowedIps = configMap.value(protocols::wireguard::AllowedIPs).split(
QRegularExpression("\\s*,\\s*"), Qt::SkipEmptyParts);
QJsonArray allowedIpsJsonArray = QJsonArray::fromStringList(allowedIps);
lastConfig[configKey::allowedIps] = allowedIpsJsonArray;
QString protocolName = configKey::wireguard;
QString protocolVersion;
ConfigTypes detectedType = ConfigTypes::WireGuard;
const QStringList requiredJunkFields = { configKey::junkPacketCount, configKey::junkPacketMinSize,
configKey::junkPacketMaxSize, configKey::initPacketJunkSize,
configKey::responsePacketJunkSize, configKey::initPacketMagicHeader,
configKey::responsePacketMagicHeader, configKey::underloadPacketMagicHeader,
configKey::transportPacketMagicHeader };
const QStringList awgProtocolKeys = configKey::awgProtocolKeys();
const QStringList optionalJunkFields = { configKey::cookieReplyPacketJunkSize,
configKey::transportPacketJunkSize,
configKey::specialJunk1, configKey::specialJunk2, configKey::specialJunk3,
configKey::specialJunk4, configKey::specialJunk5
};
bool hasAllRequiredFields = std::all_of(requiredJunkFields.begin(), requiredJunkFields.end(),
[&configMap](const QString &field) { return !configMap.value(field).isEmpty(); });
if (hasAllRequiredFields) {
for (const QString &field : requiredJunkFields) {
lastConfig[field] = configMap.value(field);
}
for (const QString &field : optionalJunkFields) {
if (!configMap.value(field).isEmpty()) {
lastConfig[field] = configMap.value(field);
bool hasAwgKeys = std::any_of(awgProtocolKeys.begin(), awgProtocolKeys.end(),
[&configMap](const QString &field) { return !configMap.value(field).isEmpty(); });
if (hasAwgKeys) {
for (const QString &key : awgProtocolKeys) {
if (!configMap.value(key).isEmpty()) {
lastConfig[key] = configMap.value(key);
}
}
bool hasCookieReplyPacketJunkSize = !configMap.value(configKey::cookieReplyPacketJunkSize).isEmpty();
bool hasTransportPacketJunkSize = !configMap.value(configKey::transportPacketJunkSize).isEmpty();
bool hasSpecialJunk = !configMap.value(configKey::specialJunk1).isEmpty() ||
!configMap.value(configKey::specialJunk2).isEmpty() ||
!configMap.value(configKey::specialJunk3).isEmpty() ||
!configMap.value(configKey::specialJunk4).isEmpty() ||
!configMap.value(configKey::specialJunk5).isEmpty();
if (hasCookieReplyPacketJunkSize && hasTransportPacketJunkSize) {
protocolVersion = "2";
} else if (hasSpecialJunk && !hasCookieReplyPacketJunkSize && !hasTransportPacketJunkSize) {
protocolVersion = "1.5";
}
protocolName = configKey::awg;
detectedType = ConfigTypes::Awg;
}
@@ -630,9 +635,6 @@ QJsonObject ImportController::extractWireGuardConfig(const QString &data, Config
wireguardConfig[configKey::isThirdPartyConfig] = true;
wireguardConfig[configKey::port] = port;
wireguardConfig[configKey::transportProto] = protocols::openvpn::defaultTransportProto;
if (protocolName == configKey::awg && !protocolVersion.isEmpty()) {
wireguardConfig[configKey::protocolVersion] = protocolVersion;
}
QJsonObject containers;
QString containerName = (protocolName == configKey::awg) ? configKey::amneziaAwg : configKey::amneziaWireguard;
@@ -666,11 +668,24 @@ QJsonObject ImportController::extractXrayConfig(const QString &data, ConfigTypes
{
QJsonParseError parserErr;
QJsonDocument jsonConf = QJsonDocument::fromJson(data.toLocal8Bit(), &parserErr);
if (parserErr.error != QJsonParseError::NoError || !jsonConf.isObject()) {
qDebug() << "Xray config JSON parse failed:" << parserErr.errorString();
return QJsonObject();
}
const QJsonObject parsedConfig = jsonConf.object();
if (!parsedConfig.value(protocols::xray::inbounds).isArray()
|| !parsedConfig.value(protocols::xray::outbounds).isArray()) {
qDebug() << "Xray config is missing inbounds or outbounds";
return QJsonObject();
}
const QString serializedConfig = QString::fromUtf8(jsonConf.toJson());
QJsonObject xrayVpnConfig;
xrayVpnConfig[configKey::config] = jsonConf.toJson().constData();
xrayVpnConfig[configKey::config] = serializedConfig;
QJsonObject lastConfig;
lastConfig[configKey::lastConfig] = jsonConf.toJson().constData();
lastConfig[configKey::lastConfig] = serializedConfig;
lastConfig[configKey::isThirdPartyConfig] = true;
QJsonObject containers;
@@ -22,6 +22,7 @@ namespace
Xray,
ShadowSocks,
Backup,
XraySettings,
Invalid
};
}
@@ -11,6 +11,8 @@
#include <QtConcurrent>
#include "core/configurators/configuratorBase.h"
#include "core/configurators/xrayConfigurator.h"
#include "core/models/protocols/xrayProtocolConfig.h"
#include "core/utils/containerEnum.h"
#include "core/utils/containers/containerUtils.h"
#include "core/utils/protocolEnum.h"
@@ -22,6 +24,7 @@
#include "core/installers/socks5Installer.h"
#include "core/installers/mtProxyInstaller.h"
#include "core/installers/telemtInstaller.h"
#include "core/installers/tProxyInstaller.h"
#include "core/installers/torInstaller.h"
#include "core/installers/wireguardInstaller.h"
#include "core/installers/xrayInstaller.h"
@@ -58,6 +61,70 @@ namespace
{
Logger logger("InstallController");
QString effectiveXrayPort(const XrayProtocolConfig *cfg)
{
if (!cfg || cfg->serverConfig.port.isEmpty()) {
return QString::fromLatin1(protocols::xray::defaultPort);
}
return cfg->serverConfig.port;
}
QString normalizeXrayRelease(QString raw)
{
raw = raw.trimmed();
if (raw.startsWith(QLatin1String("v"), Qt::CaseInsensitive)) {
raw.remove(0, 1);
}
return raw.trimmed();
}
QString parseXrayVersionOutput(const QString &output)
{
static const QRegularExpression re(QStringLiteral(R"(Xray\s+v?(\d+\.\d+\.\d+))"),
QRegularExpression::CaseInsensitiveOption);
const auto match = re.match(output);
if (!match.hasMatch()) {
return {};
}
return match.captured(1);
}
enum class XrayBinaryProbeResult {
Match,
Mismatch,
Failed
};
XrayBinaryProbeResult probeXrayServerBinary(const ServerCredentials &credentials, SshSession &sshSession)
{
const QString expected =
normalizeXrayRelease(QString::fromLatin1(protocols::xray::expectedServerXrayRelease));
QString stdOut;
auto collect = [&stdOut](const QString &data, libssh::Client &) {
stdOut += data;
return ErrorCode::NoError;
};
const QString script = SshSession::replaceVars(
QStringLiteral("sudo docker exec $CONTAINER_NAME xray version 2>/dev/null | head -1"),
amnezia::genBaseVars(credentials, DockerContainer::Xray, QString(), QString()));
const ErrorCode errorCode = sshSession.runScript(credentials, script, collect, collect);
const QString live = parseXrayVersionOutput(stdOut);
if (errorCode != ErrorCode::NoError || live.isEmpty()) {
return XrayBinaryProbeResult::Failed;
}
if (live != expected) {
return XrayBinaryProbeResult::Mismatch;
}
return XrayBinaryProbeResult::Match;
}
bool xrayServerBinaryMismatchesExpected(const ServerCredentials &credentials, SshSession &sshSession)
{
return probeXrayServerBinary(credentials, sshSession) == XrayBinaryProbeResult::Mismatch;
}
bool dockerDaemonContainerMissing(const QString &out, const QString &containerDockerName)
{
if (!out.contains(QLatin1String("Error response from daemon"), Qt::CaseInsensitive)) {
@@ -73,15 +140,34 @@ namespace
return false;
}
bool containerKeepsIdentityInDataVolume(DockerContainer container)
{
return container == DockerContainer::MtProxy
|| container == DockerContainer::Telemt
|| container == DockerContainer::TProxy
|| container == DockerContainer::Xray;
}
QString buildRemoveContainerScript(const amnezia::ScriptVars &vars, bool removeDataVolume)
{
QString script = SshSession::replaceVars(amnezia::scriptData(SharedScriptType::remove_container), vars);
if (removeDataVolume) {
script += QLatin1String("\nsudo docker volume rm -f $CONTAINER_NAME-data 2>/dev/null || true");
script += QLatin1String(
"\nfor attempt in 1 2 3 4 5; do"
"\n sudo docker volume rm -f $CONTAINER_NAME-data && break"
"\n sleep 1"
"\ndone"
"\necho \"amnezia_volume_left=$(sudo docker volume ls -q -f name=^$CONTAINER_NAME-data$ | head -1)\"");
script = SshSession::replaceVars(script, vars);
}
return script;
}
bool dataVolumeSurvivedRemoval(const QString &out)
{
static const QRegularExpression reLeft(QStringLiteral("amnezia_volume_left=(\\S+)"));
return reLeft.match(out).hasMatch();
}
}
InstallController::InstallController(SecureServersRepository *serversRepository,
@@ -100,9 +186,8 @@ InstallController::~InstallController()
}
ErrorCode InstallController::setupContainer(const ServerCredentials &credentials, DockerContainer container, ContainerConfig &config,
bool isUpdate)
bool isUpdate, bool acceptXrayKeyLoss)
{
qDebug().noquote() << "InstallController::setupContainer" << ContainerUtils::containerToString(container);
SshSession sshSession;
ErrorCode e = ErrorCode::NoError;
@@ -117,7 +202,6 @@ ErrorCode InstallController::setupContainer(const ServerCredentials &credentials
e = installDockerWorker(credentials, container, sshSession);
if (e)
return e;
qDebug().noquote() << "InstallController::setupContainer installDockerWorker finished";
if (!isUpdate) {
e = isServerPortBusy(credentials, container, config, sshSession);
@@ -128,82 +212,154 @@ ErrorCode InstallController::setupContainer(const ServerCredentials &credentials
e = prepareHostWorker(credentials, container, sshSession);
if (e)
return e;
qDebug().noquote() << "InstallController::setupContainer prepareHostWorker finished";
QMap<QString, QString> xrayStateToMigrate;
if (isUpdate) {
if (!acceptXrayKeyLoss) {
e = readXrayStateBeforeVolumeMigration(credentials, container, sshSession, xrayStateToMigrate);
if (e)
return e;
}
}
const amnezia::ScriptVars removeContainerVars =
amnezia::genBaseVars(credentials, container, QString(), QString());
const bool removeDataVolume = !isUpdate && (container == DockerContainer::MtProxy || container == DockerContainer::Telemt);
sshSession.runScript(credentials, buildRemoveContainerScript(removeContainerVars, removeDataVolume));
const bool removeDataVolume = !isUpdate && containerKeepsIdentityInDataVolume(container);
QString removeOut;
auto collectRemoveOut = [&removeOut](const QString &data, libssh::Client &) {
removeOut += data + "\n";
return ErrorCode::NoError;
};
const ErrorCode removeError =
sshSession.runScript(credentials, buildRemoveContainerScript(removeContainerVars, removeDataVolume),
collectRemoveOut, collectRemoveOut);
qDebug().noquote() << "InstallController::setupContainer removeContainer finished";
if (removeDataVolume) {
if (removeError != ErrorCode::NoError) {
return ErrorCode::ServerDataVolumeNotRemoved;
}
if (dataVolumeSurvivedRemoval(removeOut)) {
return ErrorCode::ServerDataVolumeNotRemoved;
}
}
qDebug().noquote() << "buildContainerWorker start";
e = buildContainerWorker(credentials, container, config, sshSession);
if (e)
return e;
qDebug().noquote() << "InstallController::setupContainer buildContainerWorker finished";
e = runContainerWorker(credentials, container, config, sshSession);
if (e)
return e;
qDebug().noquote() << "InstallController::setupContainer runContainerWorker finished";
e = restoreXrayStateIntoDataVolume(credentials, container, sshSession, xrayStateToMigrate);
if (e)
return e;
e = configureContainerWorker(credentials, container, config, sshSession);
if (e)
return e;
qDebug().noquote() << "InstallController::setupContainer configureContainerWorker finished";
if (container == DockerContainer::Xray || container == DockerContainer::SSXray) {
DnsSettings dnsSettings = { m_appSettingsRepository->primaryDns(), m_appSettingsRepository->secondaryDns() };
XrayConfigurator xrayConfigurator(&sshSession);
e = xrayConfigurator.writeServerConfigForSetup(credentials, container, config, dnsSettings);
if (e)
return e;
}
setupServerFirewall(credentials, sshSession);
qDebug().noquote() << "InstallController::setupContainer setupServerFirewall finished";
return startupContainerWorker(credentials, container, config, sshSession);
}
ErrorCode InstallController::updateServerConfig(const QString &serverId, DockerContainer container, const ContainerConfig &oldConfig,
ContainerConfig &newConfig)
ContainerConfig &newConfig, bool acceptXrayKeyLoss)
{
if (!isUpdateDockerContainerRequired(container, oldConfig, newConfig)) {
auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId);
if (!adminConfig.has_value()) {
return ErrorCode::InternalError;
}
if (container == DockerContainer::MtProxy) {
ServerCredentials credentials = adminConfig->credentials();
SshSession sshSession;
MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
} else if (container == DockerContainer::Telemt) {
ServerCredentials credentials = adminConfig->credentials();
SshSession sshSession;
TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
}
adminConfig->updateContainerConfig(container, newConfig);
m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin);
return ErrorCode::NoError;
}
const bool serverSettingsChanged = isUpdateDockerContainerRequired(container, oldConfig, newConfig);
auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId);
if (!adminConfig.has_value()) {
return ErrorCode::InternalError;
}
ServerCredentials credentials = adminConfig->credentials();
SshSession sshSession;
bool reinstallRequired = false;
bool portReinstall = false;
bool binaryMismatch = false;
if (serverSettingsChanged) {
if (!credentials.isValid()) {
return ErrorCode::InternalError;
}
portReinstall = isReinstallContainerRequired(container, oldConfig, newConfig);
reinstallRequired = portReinstall;
}
if (container == DockerContainer::Xray) {
if (credentials.isValid()) {
binaryMismatch = xrayServerBinaryMismatchesExpected(credentials, sshSession);
if (binaryMismatch) {
reinstallRequired = true;
}
}
}
if (!serverSettingsChanged && !reinstallRequired) {
if (container == DockerContainer::MtProxy) {
MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
} else if (container == DockerContainer::Telemt) {
TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
} else if (container == DockerContainer::TProxy) {
ServerCredentials credentials = adminConfig->credentials();
SshSession sshSession;
TProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
}
adminConfig->updateContainerConfig(container, newConfig);
m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin);
return ErrorCode::NoError;
}
if (!credentials.isValid()) {
return ErrorCode::InternalError;
}
SshSession sshSession;
bool reinstallRequired = isReinstallContainerRequired(container, oldConfig, newConfig);
qDebug() << "InstallController::updateServerConfig for container" << container << "reinstall required is" << reinstallRequired;
ErrorCode errorCode = ErrorCode::NoError;
if (reinstallRequired) {
errorCode = setupContainer(credentials, container, newConfig, true);
} else {
if (container == DockerContainer::Xray) {
const QString oldPort = effectiveXrayPort(oldConfig.getXrayProtocolConfig());
const QString newPort = effectiveXrayPort(newConfig.getXrayProtocolConfig());
if (oldPort != newPort) {
errorCode = isServerPortBusy(credentials, container, newConfig, sshSession);
if (errorCode != ErrorCode::NoError) {
return errorCode;
}
}
}
errorCode = setupContainer(credentials, container, newConfig, true, acceptXrayKeyLoss);
// Reinstall pulls the latest container image, so the server runs the latest protocol version
if (errorCode == ErrorCode::NoError && container == DockerContainer::Awg2) {
if (auto* awgConfig = newConfig.getAwgProtocolConfig()) {
awgConfig->serverConfig.protocolVersion = protocols::awg::awgV3;
}
}
} else if (container == DockerContainer::Xray) {
DnsSettings dnsSettings = { m_appSettingsRepository->primaryDns(), m_appSettingsRepository->secondaryDns() };
XrayConfigurator xrayConfigurator(&sshSession);
errorCode = xrayConfigurator.writeServerConfigForSetup(credentials, container, newConfig, dnsSettings,
true);
} else if (container != DockerContainer::SSXray) {
errorCode = configureContainerWorker(credentials, container, newConfig, sshSession);
if (errorCode == ErrorCode::NoError) {
errorCode = startupContainerWorker(credentials, container, newConfig, sshSession);
}
if (errorCode == ErrorCode::NoError
&& (container == DockerContainer::MtProxy || container == DockerContainer::Telemt)) {
&& (container == DockerContainer::MtProxy || container == DockerContainer::Telemt
|| container == DockerContainer::TProxy)) {
const QString containerName = ContainerUtils::containerToString(container);
errorCode = sshSession.runScript(credentials, "sudo docker restart " + containerName);
}
@@ -214,9 +370,13 @@ ErrorCode InstallController::updateServerConfig(const QString &serverId, DockerC
MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
} else if (container == DockerContainer::Telemt) {
TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
} else if (container == DockerContainer::TProxy) {
TProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, newConfig);
}
if (reinstallRequired) {
clearCachedProfile(serverId, container);
if (container != DockerContainer::Xray) {
clearCachedProfile(serverId, container);
}
}
adminConfig->updateContainerConfig(container, newConfig);
m_serversRepository->editServer(serverId, adminConfig->toJson(), serverConfigUtils::ConfigType::SelfHostedAdmin);
@@ -397,6 +557,11 @@ ErrorCode InstallController::prepareContainerConfig(DockerContainer container, c
}
if (ContainerUtils::containerService(container) != ServiceType::Other) {
if ((container == DockerContainer::Xray || container == DockerContainer::SSXray)
&& containerConfig.protocolConfig.hasClientConfig()) {
return ErrorCode::NoError;
}
Proto protocol = ContainerUtils::defaultProtocol(container);
DnsSettings dnsSettings = {
@@ -420,6 +585,9 @@ ErrorCode InstallController::prepareContainerConfig(DockerContainer container, c
void InstallController::adminAppendRequested(const QString &serverId, DockerContainer container,
const ContainerConfig &containerConfig, const QString &clientName)
{
if (container == DockerContainer::Xray) {
return;
}
if (ContainerUtils::containerService(container) == ServiceType::Other
|| !containerConfig.protocolConfig.hasClientConfig()) {
return;
@@ -484,6 +652,12 @@ ErrorCode InstallController::buildContainerWorker(const ServerCredentials &crede
if (stdOut.contains("have reached") && stdOut.contains("pull rate limit"))
return ErrorCode::DockerPullRateLimit;
if (stdOut.contains("returned a non-zero code")
|| stdOut.contains("failed to solve")
|| stdOut.contains("Unable to find image")
|| stdOut.contains("Couldn't connect to server"))
return ErrorCode::ServerDockerFailedError;
return error;
}
@@ -508,6 +682,8 @@ ErrorCode InstallController::runContainerWorker(const ServerCredentials &credent
return ErrorCode::ServerPortAlreadyAllocatedError;
if (stdOut.contains("invalid publish"))
return ErrorCode::ServerDockerFailedError;
if (stdOut.contains("Unable to find image") || stdOut.contains("No such image"))
return ErrorCode::ServerDockerFailedError;
return e;
}
@@ -541,12 +717,18 @@ ErrorCode InstallController::configureContainerWorker(const ServerCredentials &c
return ErrorCode::ServerContainerMissingError;
}
if (stdOut.contains(QLatin1String("amnezia_xray_keygen=failed"))) {
return ErrorCode::XrayKeyGenerationFailed;
}
updateContainerConfigAfterInstallation(container, config, stdOut);
if (container == DockerContainer::MtProxy) {
MtProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, config);
} else if (container == DockerContainer::Telemt) {
TelemtInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, config);
} else if (container == DockerContainer::TProxy) {
TProxyInstaller::uploadClientSettingsSnapshot(sshSession, credentials, container, config);
}
return ErrorCode::NoError;
@@ -575,6 +757,127 @@ ErrorCode InstallController::startupContainerWorker(const ServerCredentials &cre
baseVars));
}
ErrorCode InstallController::readXrayStateBeforeVolumeMigration(const ServerCredentials &credentials,
DockerContainer container, SshSession &sshSession,
QMap<QString, QString> &outFiles)
{
outFiles.clear();
if (container != DockerContainer::Xray) {
return ErrorCode::NoError;
}
const amnezia::ScriptVars vars = amnezia::genBaseVars(credentials, container, QString(), QString());
QString stdOut;
auto collect = [&stdOut](const QString &data, libssh::Client &) {
stdOut += data + "\n";
return ErrorCode::NoError;
};
const QString probe = QStringLiteral(
"echo \"amnezia_container=$(sudo docker ps -a -q -f name=^$CONTAINER_NAME$ | head -1)\"\n"
"if [ -n \"$(sudo docker ps -a -q -f name=^$CONTAINER_NAME$ | head -1)\" ] && "
"[ -z \"$(sudo docker ps -q -f name=^$CONTAINER_NAME$ | head -1)\" ]; then\n"
" sudo docker start $CONTAINER_NAME > /dev/null 2>&1\n"
" sleep 2\n"
"fi\n"
"echo \"amnezia_running=$(sudo docker ps -q -f name=^$CONTAINER_NAME$ | head -1)\"\n"
"echo \"amnezia_mounted=$(sudo docker inspect -f "
"'{{range .Mounts}}{{if and (eq .Destination \"%1\") "
"(eq .Name \"$CONTAINER_NAME-data\")}}yes{{end}}{{end}}' "
"$CONTAINER_NAME 2>/dev/null | head -1)\"")
.arg(QString::fromLatin1(amnezia::protocols::xray::dataDir));
ErrorCode errorCode = sshSession.runScript(credentials, SshSession::replaceVars(probe, vars), collect, collect);
if (errorCode != ErrorCode::NoError) {
return ErrorCode::XrayKeyMigrationFailed;
}
static const QRegularExpression reContainer(QStringLiteral("amnezia_container=(\\S+)"));
static const QRegularExpression reRunning(QStringLiteral("amnezia_running=(\\S+)"));
static const QRegularExpression reMounted(QStringLiteral("amnezia_mounted=(\\S+)"));
const bool containerExists = reContainer.match(stdOut).hasMatch();
const bool containerRunning = reRunning.match(stdOut).hasMatch();
const bool dataDirIsVolume = reMounted.match(stdOut).hasMatch();
if (!containerExists) {
return ErrorCode::NoError;
}
if (dataDirIsVolume) {
return ErrorCode::NoError;
}
if (!containerRunning) {
return ErrorCode::XrayKeyMigrationNeedsConfirm;
}
const QStringList requiredPaths = {
QString::fromLatin1(amnezia::protocols::xray::PrivateKeyPath),
QString::fromLatin1(amnezia::protocols::xray::PublicKeyPath),
QString::fromLatin1(amnezia::protocols::xray::uuidPath),
QString::fromLatin1(amnezia::protocols::xray::shortidPath),
};
for (const QString &path : requiredPaths) {
QString content;
bool read = false;
for (int attempt = 0; attempt < 3 && !read; ++attempt) {
ErrorCode fileError = ErrorCode::NoError;
content = QString::fromUtf8(sshSession.getTextFileFromContainer(container, credentials, path, fileError));
if (fileError == ErrorCode::NoError && !content.trimmed().isEmpty()) {
read = true;
break;
}
if (attempt < 2) {
QThread::msleep(500);
}
}
if (!read) {
return ErrorCode::XrayKeyMigrationFailed;
}
outFiles.insert(path, content);
}
QString serverConfig;
bool configRead = false;
for (int attempt = 0; attempt < 3 && !configRead; ++attempt) {
ErrorCode configError = ErrorCode::NoError;
serverConfig = QString::fromUtf8(sshSession.getTextFileFromContainer(
container, credentials, QString::fromLatin1(amnezia::protocols::xray::serverConfigPath), configError));
if (configError == ErrorCode::NoError && !serverConfig.trimmed().isEmpty()) {
configRead = true;
break;
}
if (attempt < 2) {
QThread::msleep(500);
}
}
if (!configRead) {
return ErrorCode::XrayKeyMigrationFailed;
}
outFiles.insert(QString::fromLatin1(amnezia::protocols::xray::serverConfigPath), serverConfig);
return ErrorCode::NoError;
}
ErrorCode InstallController::restoreXrayStateIntoDataVolume(const ServerCredentials &credentials,
DockerContainer container, SshSession &sshSession,
const QMap<QString, QString> &files)
{
if (files.isEmpty()) {
return ErrorCode::NoError;
}
for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
const ErrorCode errorCode = sshSession.uploadTextFileToContainer(
container, credentials, it.value(), it.key(), libssh::ScpOverwriteMode::ScpOverwriteExisting);
if (errorCode != ErrorCode::NoError) {
return ErrorCode::XrayKeyMigrationFailed;
}
}
return ErrorCode::NoError;
}
ErrorCode InstallController::isServerPortBusy(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config, SshSession &sshSession)
{
if (container == DockerContainer::Dns) {
@@ -598,22 +901,42 @@ ErrorCode InstallController::isServerPortBusy(const ServerCredentials &credentia
if (port.isEmpty()) {
port = QString::number(ProtocolUtils::defaultPort(protocol));
}
if (container == DockerContainer::TProxy) {
if (const auto *tProxyConfig = config.getTProxyProtocolConfig()) {
const QString httpPort =
tProxyConfig->httpPort.isEmpty() ? QString(protocols::tProxy::defaultHttpPort) : tProxyConfig->httpPort;
if (!fixedPorts.contains(httpPort) && httpPort != port) {
fixedPorts.append(httpPort);
}
}
}
QString transportProto = config.protocolConfig.transportProto();
if (transportProto.isEmpty()) {
transportProto = ProtocolUtils::transportProtoToString(ProtocolUtils::defaultTransportProto(protocol), protocol);
}
// TODO reimplement with netstat
QString script = QString("which lsof > /dev/null 2>&1 || true && sudo lsof -i -P -n 2>/dev/null | grep -E ':%1 ").arg(port);
for (auto &port : fixedPorts) {
script = script.append("|:%1").arg(port);
// Match exact host ports in lsof output (e.g. *:80) but not prefixes like *:8025 or *:8080.
QStringList portsToCheck;
portsToCheck << port;
for (const QString &fixedPort : fixedPorts) {
if (!portsToCheck.contains(fixedPort)) {
portsToCheck << fixedPort;
}
}
QStringList portRegexParts;
for (const QString &p : portsToCheck) {
portRegexParts << QString(":%1([^0-9]|$)").arg(p);
}
const QString portRegex = portRegexParts.join(QLatin1Char('|'));
QString script = QString("which lsof > /dev/null 2>&1 || true && sudo lsof -i -P -n 2>/dev/null | grep -E '%1'")
.arg(portRegex);
if (transportProto == "tcpandudp") {
QString tcpProtoScript = script;
QString udpProtoScript = script;
tcpProtoScript.append("' | grep -i tcp");
udpProtoScript.append("' | grep -i udp");
tcpProtoScript.append(" | grep -i tcp");
udpProtoScript.append(" | grep -i udp");
tcpProtoScript.append(" | grep LISTEN");
ErrorCode errorCode = sshSession.runScript(
@@ -638,7 +961,7 @@ ErrorCode InstallController::isServerPortBusy(const ServerCredentials &credentia
return ErrorCode::NoError;
}
script = script.append("' | grep -i %1").arg(transportProto);
script = script.append(" | grep -i %1").arg(transportProto);
if (transportProto == "tcp") {
script = script.append(" | grep LISTEN");
@@ -692,7 +1015,20 @@ bool InstallController::isReinstallContainerRequired(DockerContainer container,
}
}
if (container == DockerContainer::Xray || container == DockerContainer::SSXray) {
if (container == DockerContainer::Xray) {
const auto *oldXrayConfig = oldConfig.getXrayProtocolConfig();
const auto *newXrayConfig = newConfig.getXrayProtocolConfig();
if (oldXrayConfig && newXrayConfig) {
const QString oldPort = effectiveXrayPort(oldXrayConfig);
const QString newPort = effectiveXrayPort(newXrayConfig);
if (oldPort != newPort) {
return true;
}
}
}
if (container == DockerContainer::SSXray) {
const auto *oldXrayConfig = oldConfig.getXrayProtocolConfig();
const auto *newXrayConfig = newConfig.getXrayProtocolConfig();
@@ -731,6 +1067,24 @@ bool InstallController::isReinstallContainerRequired(DockerContainer container,
}
}
if (container == DockerContainer::TProxy) {
const auto *oldP = oldConfig.getTProxyProtocolConfig();
const auto *newP = newConfig.getTProxyProtocolConfig();
if (oldP && newP) {
const QString oldHttps =
oldP->port.isEmpty() ? QString(protocols::tProxy::defaultPort) : oldP->port;
const QString newHttps =
newP->port.isEmpty() ? QString(protocols::tProxy::defaultPort) : newP->port;
const QString oldHttp =
oldP->httpPort.isEmpty() ? QString(protocols::tProxy::defaultHttpPort) : oldP->httpPort;
const QString newHttp =
newP->httpPort.isEmpty() ? QString(protocols::tProxy::defaultHttpPort) : newP->httpPort;
if (oldHttps != newHttps || oldHttp != newHttp) {
return true;
}
}
}
if (container == DockerContainer::Socks5Proxy) {
return true;
}
@@ -767,7 +1121,8 @@ ErrorCode InstallController::installDockerWorker(const ServerCredentials &creden
qDebug().noquote() << "InstallController::installDockerWorker" << stdOut;
if (container == DockerContainer::MtProxy || container == DockerContainer::Telemt) {
if (container == DockerContainer::MtProxy || container == DockerContainer::Telemt
|| container == DockerContainer::TProxy) {
QString conntrackOut;
auto cbConntrack = [&](const QString &data, libssh::Client &) {
conntrackOut += data + "\n";
@@ -980,9 +1335,19 @@ ErrorCode InstallController::removeContainer(const QString &serverId, DockerCont
SshSession sshSession;
const amnezia::ScriptVars removeContainerVars =
amnezia::genBaseVars(credentials, container, QString(), QString());
const bool removeDataVolume = (container == DockerContainer::MtProxy || container == DockerContainer::Telemt);
ErrorCode errorCode =
sshSession.runScript(credentials, buildRemoveContainerScript(removeContainerVars, removeDataVolume));
const bool removeDataVolume = containerKeepsIdentityInDataVolume(container);
QString removeOut;
auto collectRemoveOut = [&removeOut](const QString &data, libssh::Client &) {
removeOut += data + "\n";
return ErrorCode::NoError;
};
ErrorCode errorCode = sshSession.runScript(
credentials, buildRemoveContainerScript(removeContainerVars, removeDataVolume), collectRemoveOut,
collectRemoveOut);
if (errorCode == ErrorCode::NoError && removeDataVolume && dataVolumeSurvivedRemoval(removeOut)) {
errorCode = ErrorCode::ServerDataVolumeNotRemoved;
}
if (errorCode == ErrorCode::NoError) {
QMap<DockerContainer, ContainerConfig> containers = adminConfig->containers;
@@ -1019,6 +1384,7 @@ QScopedPointer<InstallerBase> InstallController::createInstaller(DockerContainer
case DockerContainer::Socks5Proxy: return QScopedPointer<InstallerBase>(new Socks5Installer(this));
case DockerContainer::MtProxy: return QScopedPointer<InstallerBase>(new MtProxyInstaller(this));
case DockerContainer::Telemt: return QScopedPointer<InstallerBase>(new TelemtInstaller(this));
case DockerContainer::TProxy: return QScopedPointer<InstallerBase>(new TProxyInstaller(this));
default: return QScopedPointer<InstallerBase>(new InstallerBase(this));
}
}
@@ -1033,9 +1399,35 @@ ErrorCode InstallController::installContainer(const ServerCredentials &credentia
TransportProto transportProto, ContainerConfig &config)
{
config = generateConfig(container, port, transportProto);
if (container == DockerContainer::TProxy) {
auto *tProxyConfig = config.getTProxyProtocolConfig();
if (tProxyConfig) {
if (!m_tproxyInstallHostname.isEmpty()) {
tProxyConfig->hostname = m_tproxyInstallHostname;
}
if (!m_tproxyInstallEmail.isEmpty()) {
tProxyConfig->acmeEmail = m_tproxyInstallEmail;
}
}
m_tproxyInstallHostname.clear();
m_tproxyInstallEmail.clear();
// TProxy needs a hostname and ACME email before the first deploy (configure_container.sh
// exits 1 without them). Fail fast with a clear error instead of a confusing server-side
// failure if the install path is reached without them being supplied via setTProxyInstallHints().
if (!tProxyConfig || tProxyConfig->hostname.isEmpty() || tProxyConfig->acmeEmail.isEmpty()) {
return ErrorCode::InternalError;
}
}
return setupContainer(credentials, container, config, false);
}
void InstallController::setTProxyInstallHints(const QString &hostname, const QString &email)
{
m_tproxyInstallHostname = hostname;
m_tproxyInstallEmail = email;
}
bool InstallController::isUpdateDockerContainerRequired(DockerContainer container, const ContainerConfig &oldConfig, const ContainerConfig &newConfig)
{
@@ -1057,6 +1449,13 @@ bool InstallController::isUpdateDockerContainerRequired(DockerContainer containe
return false;
}
}
} else if (container == DockerContainer::Xray) {
const auto *oldXray = oldConfig.getXrayProtocolConfig();
const auto *newXray = newConfig.getXrayProtocolConfig();
if (oldXray && newXray && oldXray->serverConfig.hasEqualServerSettings(newXray->serverConfig)) {
return false;
}
return true;
} else if (container == DockerContainer::MtProxy) {
const auto *oldMt = oldConfig.getMtProxyProtocolConfig();
const auto *newMt = newConfig.getMtProxyProtocolConfig();
@@ -1071,6 +1470,13 @@ bool InstallController::isUpdateDockerContainerRequired(DockerContainer containe
return true;
}
return !oldT->equalsDockerDeploymentSettings(*newT);
} else if (container == DockerContainer::TProxy) {
const auto *oldP = oldConfig.getTProxyProtocolConfig();
const auto *newP = newConfig.getTProxyProtocolConfig();
if (!oldP || !newP) {
return true;
}
return !oldP->equalsDockerDeploymentSettings(*newP);
}
return true;
@@ -1422,6 +1828,34 @@ void InstallController::updateContainerConfigAfterInstallation(DockerContainer c
telemtConfig->tmeLink = mTmeLink.captured(1);
}
}
} else if (container == DockerContainer::TProxy) {
if (auto *tProxyConfig = containerConfig.getTProxyProtocolConfig()) {
static const QRegularExpression reSecret(
QStringLiteral(R"(\[\*\]\s+Secret:\s+([0-9a-fA-F]{32}))"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression reTgLink(QStringLiteral(R"(\[\*\]\s+tg://\s+link:\s+(tg://webproxy\?[^\s]+))"));
static const QRegularExpression reTmeLink(
QStringLiteral(R"(\[\*\]\s+t\.me\s+link:\s+(https://t\.me/webproxy\?[^\s]+))"));
static const QRegularExpression reHost(QStringLiteral(R"(\[\*\]\s+Hostname:\s+(\S+))"));
const QRegularExpressionMatch mSecret = reSecret.match(stdOut);
const QRegularExpressionMatch mTgLink = reTgLink.match(stdOut);
const QRegularExpressionMatch mTmeLink = reTmeLink.match(stdOut);
const QRegularExpressionMatch mHost = reHost.match(stdOut);
if (mSecret.hasMatch()) {
tProxyConfig->secret = mSecret.captured(1);
}
if (mTgLink.hasMatch()) {
tProxyConfig->tgLink = mTgLink.captured(1);
}
if (mTmeLink.hasMatch()) {
tProxyConfig->tmeLink = mTmeLink.captured(1);
}
if (mHost.hasMatch() && tProxyConfig->hostname.isEmpty()) {
tProxyConfig->hostname = mHost.captured(1);
}
}
}
}
@@ -1469,6 +1903,11 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia
auto installer = createInstaller(container);
ContainerConfig config = installer->createBaseConfig(container, port, transportProto);
if (container == DockerContainer::TProxy) {
if (auto *tProxyConfig = config.getTProxyProtocolConfig()) {
TProxyInstaller::applyDockerPublishedPorts(containerInfo, *tProxyConfig);
}
}
ErrorCode extractError = installer->extractConfigFromContainer(container, credentials, &sshSession, config);
if (extractError != ErrorCode::NoError && extractError != ErrorCode::ServerContainerMissingError) {
@@ -1494,6 +1933,11 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia
auto installer = createInstaller(container);
ContainerConfig config = installer->createBaseConfig(container, port, transportProto);
if (container == DockerContainer::TProxy) {
if (auto *tProxyConfig = config.getTProxyProtocolConfig()) {
TProxyInstaller::applyDockerPublishedPorts(containerInfo, *tProxyConfig);
}
}
ErrorCode extractError = installer->extractConfigFromContainer(container, credentials, &sshSession, config);
if (extractError != ErrorCode::NoError && extractError != ErrorCode::ServerContainerMissingError) {
@@ -1509,7 +1953,8 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia
ErrorCode InstallController::setDockerContainerEnabledState(const QString &serverId, DockerContainer container, bool enabled)
{
if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) {
if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt
&& container != DockerContainer::TProxy) {
return ErrorCode::InternalError;
}
auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId);
@@ -1536,6 +1981,9 @@ ErrorCode InstallController::setDockerContainerEnabledState(const QString &serve
} else if (auto *telemtConfig = currentConfig.getTelemtProtocolConfig()) {
telemtConfig->isEnabled = enabled;
persist = true;
} else if (auto *tProxyConfig = currentConfig.getTProxyProtocolConfig()) {
tProxyConfig->isEnabled = enabled;
persist = true;
}
if (persist) {
adminConfig->updateContainerConfig(container, currentConfig);
@@ -1601,7 +2049,8 @@ ErrorCode InstallController::queryMtProxyDiagnostics(const QString &serverId, Do
QString InstallController::fetchDockerContainerSecret(const QString &serverId, DockerContainer container)
{
if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt) {
if (container != DockerContainer::MtProxy && container != DockerContainer::Telemt
&& container != DockerContainer::TProxy) {
return {};
}
auto adminConfig = m_serversRepository->selfHostedAdminConfig(serverId);
@@ -33,10 +33,10 @@ public:
QObject *parent = nullptr);
~InstallController();
ErrorCode setupContainer(const ServerCredentials &credentials, DockerContainer container, ContainerConfig &config, bool isUpdate = false);
ErrorCode setupContainer(const ServerCredentials &credentials, DockerContainer container, ContainerConfig &config, bool isUpdate = false, bool acceptXrayKeyLoss = false);
// Updates server-side container settings (admin self-hosted only): reconfigures the container over SSH.
ErrorCode updateServerConfig(const QString &serverId, DockerContainer container, const ContainerConfig &oldConfig, ContainerConfig &newConfig);
ErrorCode updateServerConfig(const QString &serverId, DockerContainer container, const ContainerConfig &oldConfig, ContainerConfig &newConfig, bool acceptXrayKeyLoss = false);
// Updates client-local settings only: rewrites the stored container config for any self-hosted/native server. No SSH.
ErrorCode updateClientConfig(const QString &serverId, DockerContainer container, ContainerConfig &newConfig);
@@ -55,6 +55,8 @@ public:
QString fetchDockerContainerSecret(const QString &serverId, DockerContainer container);
void setTProxyInstallHints(const QString &hostname, const QString &email);
ContainerConfig generateConfig(DockerContainer container, int port, TransportProto transportProto);
ErrorCode getAlreadyInstalledContainers(const ServerCredentials &credentials, QMap<DockerContainer, ContainerConfig> &installedContainers, SshSession &sshSession);
@@ -104,6 +106,11 @@ private:
ErrorCode configureContainerWorker(const ServerCredentials &credentials, DockerContainer container, ContainerConfig &config, SshSession &sshSession);
ErrorCode startupContainerWorker(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config, SshSession &sshSession);
ErrorCode readXrayStateBeforeVolumeMigration(const ServerCredentials &credentials, DockerContainer container,
SshSession &sshSession, QMap<QString, QString> &outFiles);
ErrorCode restoreXrayStateIntoDataVolume(const ServerCredentials &credentials, DockerContainer container,
SshSession &sshSession, const QMap<QString, QString> &files);
ErrorCode isServerPortBusy(const ServerCredentials &credentials, DockerContainer container, const ContainerConfig &config, SshSession &sshSession);
ErrorCode isUserInSudo(const ServerCredentials &credentials, SshSession &sshSession);
ErrorCode isServerDpkgBusy(const ServerCredentials &credentials, SshSession &sshSession);
@@ -126,7 +133,9 @@ private:
SecureServersRepository* m_serversRepository;
SecureAppSettingsRepository* m_appSettingsRepository;
bool m_cancelInstallation = false;
QString m_tproxyInstallHostname;
QString m_tproxyInstallEmail;
#ifndef Q_OS_IOS
QList<QSharedPointer<QProcess>> m_sftpMountProcesses;
#endif
@@ -2,6 +2,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QDateTime>
#include "core/utils/containerEnum.h"
@@ -55,6 +56,33 @@ int UsersController::clientIndexById(const QString &clientId, const QJsonArray &
return -1;
}
QString UsersController::readXrayVolumeUuid(const DockerContainer container, const ServerCredentials &credentials,
SshSession *sshSession) const
{
ErrorCode error = ErrorCode::NoError;
const QString uuid = QString::fromUtf8(sshSession->getTextFileFromContainer(
container, credentials, QString::fromLatin1(amnezia::protocols::xray::uuidPath), error));
if (error != ErrorCode::NoError) {
return {};
}
return uuid.trimmed();
}
int UsersController::stripXrayVolumeUuidFromTable(QJsonArray &clientsTable, const QString &volumeUuid)
{
if (volumeUuid.isEmpty()) {
return 0;
}
int removed = 0;
for (int i = clientsTable.size() - 1; i >= 0; --i) {
if (clientsTable.at(i).toObject().value(configKey::clientId).toString() == volumeUuid) {
clientsTable.removeAt(i);
++removed;
}
}
return removed;
}
void UsersController::migration(const QByteArray &clientsTableString, QJsonArray &clientsTable)
{
QJsonObject clientsTableObj = QJsonDocument::fromJson(clientsTableString).object();
@@ -81,7 +109,7 @@ ErrorCode UsersController::wgShow(const DockerContainer container, const ServerC
ErrorCode error = ErrorCode::NoError;
QString stdOut;
auto cbReadStdOut = [&](const QString &data, libssh::Client &) {
stdOut += data + "\n";
stdOut += data;
return ErrorCode::NoError;
};
@@ -103,16 +131,6 @@ ErrorCode UsersController::wgShow(const DockerContainer container, const ServerC
const auto getStrValue = [](const auto str) { return str.mid(str.indexOf(":") + 1).trimmed(); };
const auto parts = stdOut.split('\n');
const auto peerList = parts.filter("peer:");
const auto latestHandshakeList = parts.filter("latest handshake:");
const auto transferredDataList = parts.filter("transfer:");
const auto allowedIpsList = parts.filter("allowed ips:");
if (allowedIpsList.isEmpty() || latestHandshakeList.isEmpty() || transferredDataList.isEmpty() || peerList.isEmpty()) {
return error;
}
const auto changeHandshakeFormat = [](QString &latestHandshake) {
const std::vector<std::pair<QString, QString>> replaceMap = { { " days", "d" }, { " hours", "h" }, { " minutes", "m" },
{ " seconds", "s" }, { " day", "d" }, { " hour", "h" },
@@ -123,20 +141,49 @@ ErrorCode UsersController::wgShow(const DockerContainer container, const ServerC
}
};
for (int i = 0; i < peerList.size() && i < transferredDataList.size() && i < latestHandshakeList.size() && i < allowedIpsList.size(); ++i) {
const auto lines = stdOut.split('\n');
WgShowData currentPeer;
bool hasPeer = false;
const auto transferredData = getStrValue(transferredDataList[i]).split(",");
auto latestHandshake = getStrValue(latestHandshakeList[i]);
auto serverBytesReceived = transferredData.front().trimmed();
auto serverBytesSent = transferredData.back().trimmed();
auto allowedIps = getStrValue(allowedIpsList[i]);
for (const QString &line : lines) {
const QString trimmedLine = line.trimmed();
changeHandshakeFormat(latestHandshake);
serverBytesReceived.chop(QStringLiteral(" received").length());
serverBytesSent.chop(QStringLiteral(" sent").length());
data.push_back({ getStrValue(peerList[i]), latestHandshake, serverBytesSent, serverBytesReceived, allowedIps });
if (trimmedLine.startsWith("peer:")) {
if (hasPeer) {
data.push_back(currentPeer);
}
currentPeer = WgShowData();
currentPeer.clientId = getStrValue(trimmedLine);
hasPeer = true;
} else if (hasPeer) {
if (trimmedLine.startsWith("latest handshake:")) {
auto latestHandshake = getStrValue(trimmedLine);
changeHandshakeFormat(latestHandshake);
currentPeer.latestHandshake = latestHandshake;
} else if (trimmedLine.startsWith("transfer:")) {
const auto transferredData = getStrValue(trimmedLine).split(",");
if (transferredData.size() == 2) {
auto serverBytesReceived = transferredData.front().trimmed();
auto serverBytesSent = transferredData.back().trimmed();
if (serverBytesReceived.endsWith(" received")) {
serverBytesReceived.chop(QStringLiteral(" received").length());
}
if (serverBytesSent.endsWith(" sent")) {
serverBytesSent.chop(QStringLiteral(" sent").length());
}
currentPeer.dataReceived = serverBytesSent;
currentPeer.dataSent = serverBytesReceived;
} else {
logger.warning() << QString("Unexpected %1 show transfer format, skipping traffic stats for the peer")
.arg(showBin);
}
} else if (trimmedLine.startsWith("allowed ips:")) {
currentPeer.allowedIps = getStrValue(trimmedLine);
}
}
}
if (hasPeer) {
data.push_back(currentPeer);
}
return error;
@@ -229,7 +276,8 @@ ErrorCode UsersController::getWireGuardClients(const DockerContainer container,
}
ErrorCode UsersController::getXrayClients(const DockerContainer container, const ServerCredentials& credentials,
SshSession* sshSession, int &count, QJsonArray &clientsTable)
SshSession* sshSession, int &count, QJsonArray &clientsTable,
const QString &volumeDefaultUuid)
{
ErrorCode error = ErrorCode::NoError;
@@ -264,6 +312,7 @@ ErrorCode UsersController::getXrayClients(const DockerContainer container, const
}
const QJsonArray clients = settings[protocols::xray::clients].toArray();
const QString xrayDefaultUuid = volumeDefaultUuid;
for (const auto &clientValue : clients) {
const QJsonObject clientObj = clientValue.toObject();
if (!clientObj.contains(protocols::xray::id)) {
@@ -271,9 +320,6 @@ ErrorCode UsersController::getXrayClients(const DockerContainer container, const
continue;
}
QString clientId = clientObj[protocols::xray::id].toString();
QString xrayDefaultUuid = sshSession->getTextFileFromContainer(container, credentials, amnezia::protocols::xray::uuidPath, error);
xrayDefaultUuid.replace("\n", "");
if (!isClientExists(clientId, clientsTable) && clientId != xrayDefaultUuid) {
QJsonObject client;
@@ -320,6 +366,9 @@ ErrorCode UsersController::updateClients(const QString &serverId, const DockerCo
m_clientsTable = QJsonDocument::fromJson(clientsTableString).array();
const QString xrayVolumeUuid =
(container == DockerContainer::Xray) ? readXrayVolumeUuid(container, credentials, &sshSession) : QString();
if (m_clientsTable.isEmpty()) {
migration(clientsTableString, m_clientsTable);
@@ -330,7 +379,7 @@ ErrorCode UsersController::updateClients(const QString &serverId, const DockerCo
} else if (container == DockerContainer::WireGuard || ContainerUtils::isAwgContainer(container)) {
error = getWireGuardClients(container, credentials, &sshSession, count, m_clientsTable);
} else if (container == DockerContainer::Xray) {
error = getXrayClients(container, credentials, &sshSession, count, m_clientsTable);
error = getXrayClients(container, credentials, &sshSession, count, m_clientsTable, xrayVolumeUuid);
}
if (error != ErrorCode::NoError) {
emit clientsUpdated(QJsonArray());
@@ -346,6 +395,15 @@ ErrorCode UsersController::updateClients(const QString &serverId, const DockerCo
}
}
if (container == DockerContainer::Xray) {
const int stripped = stripXrayVolumeUuidFromTable(m_clientsTable, xrayVolumeUuid);
if (stripped > 0) {
const QByteArray strippedTable = QJsonDocument(m_clientsTable).toJson();
const ErrorCode writeError =
sshSession.uploadTextFileToContainer(container, credentials, strippedTable, clientsTableFile);
}
}
std::vector<WgShowData> data;
wgShow(container, credentials, &sshSession, data);
@@ -405,6 +463,13 @@ ErrorCode UsersController::appendClient(const QString &serverId, const QString &
return error;
}
if (container == DockerContainer::Xray) {
const QString volumeUuid = readXrayVolumeUuid(container, credentials, &sshSession);
if (!volumeUuid.isEmpty() && clientId == volumeUuid) {
return ErrorCode::NoError;
}
}
int existingIndex = clientIndexById(clientId, m_clientsTable);
if (existingIndex >= 0) {
return renameClient(serverId, existingIndex, clientName, container, true);
@@ -615,6 +680,19 @@ ErrorCode UsersController::revokeXray(const int row,
ErrorCode error = ErrorCode::NoError;
auto client = clientsTable.at(row).toObject();
QString clientId = client.value(configKey::clientId).toString();
const QString volumeUuid = readXrayVolumeUuid(container, credentials, sshSession);
if (!volumeUuid.isEmpty() && clientId == volumeUuid) {
clientsTable.removeAt(row);
const QByteArray clientsTableString = QJsonDocument(clientsTable).toJson();
const QString clientsTableFile = QString("/opt/amnezia/%1/clientsTable")
.arg(ContainerUtils::containerTypeToString(container));
error = sshSession->uploadTextFileToContainer(container, credentials, clientsTableString, clientsTableFile);
return ErrorCode::NoError;
}
const QString serverConfigPath = amnezia::protocols::xray::serverConfigPath;
const QString configString = sshSession->getTextFileFromContainer(container, credentials, serverConfigPath, error);
if (error != ErrorCode::NoError) {
@@ -628,9 +706,6 @@ ErrorCode UsersController::revokeXray(const int row,
return ErrorCode::InternalError;
}
auto client = clientsTable.at(row).toObject();
QString clientId = client.value(configKey::clientId).toString();
QJsonObject configObj = serverConfig.object();
if (!configObj.contains(protocols::xray::inbounds)) {
logger.error() << "Missing inbounds in xray config";
@@ -64,7 +64,12 @@ private:
ErrorCode getWireGuardClients(const DockerContainer container, const ServerCredentials &credentials,
SshSession* sshSession, int &count, QJsonArray &clientsTable);
ErrorCode getXrayClients(const DockerContainer container, const ServerCredentials& credentials,
SshSession* sshSession, int &count, QJsonArray &clientsTable);
SshSession* sshSession, int &count, QJsonArray &clientsTable,
const QString &volumeDefaultUuid);
QString readXrayVolumeUuid(const DockerContainer container, const ServerCredentials &credentials,
SshSession *sshSession) const;
static int stripXrayVolumeUuidFromTable(QJsonArray &clientsTable, const QString &volumeUuid);
ErrorCode wgShow(const DockerContainer container, const ServerCredentials &credentials,
SshSession* sshSession, std::vector<WgShowData> &data);
@@ -8,7 +8,7 @@
#include "core/models/serverDescription.h"
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include <AmneziaVPN-Swift.h>
#include "core/utils/swiftBridge.h"
#endif
+23 -18
View File
@@ -8,8 +8,9 @@
#include "version.h"
#include "ui/utils/qAutoStart.h"
#include "logger.h"
#ifdef Q_OS_ANDROID
#include "platforms/android/android_controller.h"
#include "core/controllers/logsController.h"
#if defined(Q_OS_IOS) || defined(MACOS_NE)
#include "core/utils/swiftBridge.h"
#endif
QString getPlatformName()
@@ -86,6 +87,9 @@ void SettingsController::toggleLogging(bool enable)
qWarning() << "Initialization of debug subsystem failed";
}
}
#endif
#if defined(Q_OS_IOS) || defined(MACOS_NE)
SWIFT_BRIDGE_NAMESPACE::toggleLogging(enable);
#endif
Logger::setServiceLogsEnabled(enable);
@@ -96,12 +100,8 @@ void SettingsController::toggleLogging(bool enable)
void SettingsController::clearLogs()
{
#ifdef Q_OS_ANDROID
AndroidController::instance()->clearLogs();
#else
Logger::clearLogs(false);
Logger::clearServiceLogs();
#endif
LogsController::clear(LogsController::Stream::App);
LogsController::clear(LogsController::Stream::Tunnel);
}
QByteArray SettingsController::backupAppConfig() const
@@ -119,6 +119,11 @@ QByteArray SettingsController::backupAppConfig() const
return QJsonDocument(config).toJson();
}
int SettingsController::unsupportedFormatConfigsSkippedCount() const
{
return m_serversRepository->unsupportedFormatConfigsCount();
}
ErrorCode SettingsController::restoreAppConfigFromData(const QByteArray &data)
{
if (!m_appSettingsRepository->restoreAppConfig(data)) {
@@ -250,6 +255,16 @@ void SettingsController::toggleNewsNotificationsEnabled(bool enable)
m_appSettingsRepository->setNewsNotifications(enable);
}
bool SettingsController::isAutoUpdateCheckEnabled() const
{
return m_appSettingsRepository->isAutoUpdateCheckEnabled();
}
void SettingsController::toggleAutoUpdateCheckEnabled(bool enable)
{
m_appSettingsRepository->setAutoUpdateCheckEnabled(enable);
}
bool SettingsController::isKillSwitchEnabled() const
{
return m_appSettingsRepository->isKillSwitchEnabled();
@@ -351,16 +366,6 @@ void SettingsController::setAppLanguage(const QLocale &locale)
m_appSettingsRepository->setAppLanguage(locale);
}
bool SettingsController::isPremV1MigrationReminderActive() const
{
return m_appSettingsRepository->isPremV1MigrationReminderActive();
}
void SettingsController::disablePremV1MigrationReminder()
{
m_appSettingsRepository->disablePremV1MigrationReminder();
}
QString SettingsController::nextAvailableServerName() const
{
return m_serversRepository->nextAvailableServerName();
+4 -3
View File
@@ -39,6 +39,7 @@ public:
QByteArray backupAppConfig() const;
ErrorCode restoreAppConfigFromData(const QByteArray &data);
int unsupportedFormatConfigsSkippedCount() const;
QString getAppVersion() const;
@@ -59,6 +60,9 @@ public:
bool isNewsNotificationsEnabled() const;
void toggleNewsNotificationsEnabled(bool enable);
bool isAutoUpdateCheckEnabled() const;
void toggleAutoUpdateCheckEnabled(bool enable);
bool isKillSwitchEnabled() const;
void toggleKillSwitch(bool enable);
@@ -69,9 +73,6 @@ public:
void enableDevMode();
bool isPremV1MigrationReminderActive() const;
void disablePremV1MigrationReminder();
QString nextAvailableServerName() const;
bool isDevModeEnabled() const;
+204 -153
View File
@@ -1,20 +1,25 @@
#include "updateController.h"
#include <QNetworkReply>
#include <QVersionNumber>
#include <QUrl>
#include <QDesktopServices>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSysInfo>
#include <QNetworkReply>
#include <QTimer>
#include <QUrl>
#include "amneziaApplication.h"
#include "logger.h"
#include "version.h"
#include "core/controllers/gatewayController.h"
#include "core/utils/api/gatewayPayloadBuilder.h"
#include "core/utils/appUiConfig.h"
#include "core/utils/constants/apiKeys.h"
#include "core/utils/selfhosted/scriptsRegistry.h"
#if defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#endif
namespace
{
Logger logger("UpdateController");
@@ -29,6 +34,18 @@ namespace
const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_linux_x64.run");
const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.run";
#endif
QStringList stringListFromJsonValue(const QJsonValue &value)
{
QStringList result;
for (const QJsonValue &item : value.toArray()) {
const QString text = item.toString();
if (!text.isEmpty()) {
result.append(text);
}
}
return result;
}
}
UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent)
@@ -36,9 +53,9 @@ UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepos
{
}
QString UpdateController::getRawChangelogText() const
QString UpdateController::getVersion() const
{
return m_changelogText;
return m_version;
}
QString UpdateController::getReleaseDate() const
@@ -46,219 +63,253 @@ QString UpdateController::getReleaseDate() const
return m_releaseDate;
}
QString UpdateController::getVersion() const
QString UpdateController::getDescription() const
{
return m_version;
return m_description;
}
QStringList UpdateController::getTags() const
{
return m_tags;
}
QStringList UpdateController::getNewFeatures() const
{
return m_newFeatures;
}
QStringList UpdateController::getImprovements() const
{
return m_improvements;
}
QStringList UpdateController::getBugFixes() const
{
return m_bugFixes;
}
UpdateState::State UpdateController::getUpdateState() const
{
return m_updateState;
}
bool UpdateController::isStoreUpdate() const
{
#if defined(Q_OS_IOS) || defined(MACOS_NE)
return true;
#elif defined(Q_OS_ANDROID)
return AndroidController::instance()->isPlay();
#else
return false;
#endif
}
void UpdateController::setUpdateState(UpdateState::State state)
{
if (m_updateState == state) {
return;
}
m_updateState = state;
emit updateStateChanged();
}
bool UpdateController::isUpdateCheckRunning() const
{
return m_updateCheckRunning;
}
void UpdateController::setUpdateCheckRunning(bool running)
{
if (m_updateCheckRunning == running) {
return;
}
m_updateCheckRunning = running;
emit updateCheckRunningChanged();
}
void UpdateController::checkForUpdates()
{
#if !CLIENT_ENABLE_APP_UPDATES
return;
#endif
if (m_updateCheckRunning || !m_appSettingsRepository) {
return;
}
m_updateCheckRunning = true;
setUpdateCheckRunning(true);
fetchGatewayUrl();
}
void UpdateController::finishUpdateCheck()
{
m_updateCheckRunning = false;
}
void UpdateController::doGetAsync(const QString &endpoint, std::function<void(bool, QByteArray)> onDone)
{
QString fullUrl = m_baseUrl + endpoint;
QNetworkRequest req;
req.setTransferTimeout(7000);
req.setUrl(QUrl(fullUrl));
QNetworkReply *reply = amnApp->networkManager()->get(req);
setupNetworkErrorHandling(reply, endpoint);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, endpoint, onDone]() {
const bool ok = (reply->error() == QNetworkReply::NoError);
QByteArray data;
if (ok) {
data = reply->readAll();
} else {
handleNetworkError(reply, endpoint);
}
reply->deleteLater();
onDone(ok, data);
});
}
void UpdateController::fetchGatewayUrl()
{
auto gatewayController = QSharedPointer<GatewayController>::create(m_appSettingsRepository->getGatewayEndpoint(),
m_appSettingsRepository->isDevGatewayEnv(),
7000,
m_appSettingsRepository->isStrictKillSwitchEnabled(),
m_appSettingsRepository);
QJsonObject apiPayload;
apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION);
apiPayload[apiDefs::key::osVersion] = QSysInfo::productType();
apiPayload[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true);
QJsonObject apiPayload = GatewayPayloadBuilder(m_appSettingsRepository).build();
// Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.)
QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() {
gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload)
gatewayController->postAsync(QStringLiteral("%1v1/app_update"), apiPayload)
.then(this, [this, gatewayController](QPair<ErrorCode, QByteArray> result) {
setUpdateCheckRunning(false);
auto [err, gatewayResponse] = result;
if (err != ErrorCode::NoError) {
logger.error() << "Gateway request failed, error code:" << static_cast<int>(err);
finishUpdateCheck();
emit updateCheckFailed();
return;
}
QJsonObject gatewayData = QJsonDocument::fromJson(gatewayResponse).object();
QString baseUrl = gatewayData.value("url").toString();
if (baseUrl.endsWith('/')) {
baseUrl.chop(1);
if (!gatewayData.value(apiDefs::key::updateAvailable).toBool()) {
logger.info() << "No application update available";
emit updateNotFound();
return;
}
m_baseUrl = baseUrl;
fetchVersionInfo();
m_version = gatewayData.value(apiDefs::key::updateVersion).toString();
m_releaseDate = gatewayData.value(apiDefs::key::releaseDate).toString();
m_description = gatewayData.value(apiDefs::key::updateDescription).toString();
m_tags = stringListFromJsonValue(gatewayData.value(apiDefs::key::updateTags));
m_newFeatures = stringListFromJsonValue(gatewayData.value(apiDefs::key::newFeatures));
m_improvements = stringListFromJsonValue(gatewayData.value(apiDefs::key::improvements));
m_bugFixes = stringListFromJsonValue(gatewayData.value(apiDefs::key::bugFixes));
m_downloadBaseUrl = gatewayData.value(apiDefs::key::downloadBaseUrl).toString();
if (m_downloadBaseUrl.endsWith('/')) {
m_downloadBaseUrl.chop(1);
}
m_releasePageUrl = gatewayData.value(apiDefs::key::releasePageUrl).toString();
if (m_version.isEmpty()) {
logger.error() << "Update response has no version, ignoring";
emit updateCheckFailed();
return;
}
logger.info() << "Application update available:" << m_version;
setUpdateState(UpdateState::State::Idle);
emit updateFound();
});
});
}
void UpdateController::fetchVersionInfo()
void UpdateController::openStorePage() const
{
doGetAsync("/VERSION", [this](bool ok, QByteArray data) {
if (!ok) {
finishUpdateCheck();
return;
}
m_version = QString::fromUtf8(data).trimmed();
if (!isNewVersionAvailable()) {
finishUpdateCheck();
return;
}
fetchChangelog();
});
#if defined(Q_OS_IOS)
QDesktopServices::openUrl(QUrl(QLatin1String(APP_IOS_STORE_URL_FALLBACK)));
#elif defined(MACOS_NE)
QDesktopServices::openUrl(QUrl(QStringLiteral("https://apps.apple.com/app/id1600529900")));
#elif defined(Q_OS_ANDROID)
QDesktopServices::openUrl(
QUrl(QStringLiteral("https://play.google.com/store/apps/details?id=%1").arg(QLatin1String(APP_ANDROID_PACKAGE))));
#endif
}
void UpdateController::fetchChangelog()
void UpdateController::startUpdate()
{
doGetAsync("/CHANGELOG", [this](bool ok, QByteArray data) {
if (!ok) {
m_changelogText.clear();
} else {
m_changelogText = QString::fromUtf8(data);
}
fetchReleaseDate();
});
}
if (isStoreUpdate()) {
openStorePage();
return;
}
void UpdateController::fetchReleaseDate()
{
doGetAsync("/RELEASE_DATE", [this](bool ok, QByteArray data) {
if (ok) {
m_releaseDate = QString::fromUtf8(data).trimmed();
} else {
m_releaseDate = QString();
}
m_downloadUrl = composeDownloadUrl();
emit updateFound();
finishUpdateCheck();
});
}
bool UpdateController::isNewVersionAvailable() const
{
auto currentVersion = QVersionNumber::fromString(QString(APP_VERSION));
auto newVersion = QVersionNumber::fromString(m_version);
return newVersion > currentVersion;
}
void UpdateController::setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation)
{
QObject::connect(reply, &QNetworkReply::errorOccurred, [reply, operation](QNetworkReply::NetworkError error) {
logger.error() << QString("Network error occurred while fetching %1: %2 %3")
.arg(operation, reply->errorString(), QString::number(error));
});
QObject::connect(reply, &QNetworkReply::sslErrors, [operation](const QList<QSslError> &errors) {
QStringList errorStrings;
for (const QSslError &err : errors) {
errorStrings << err.errorString();
}
logger.error() << QString("SSL errors while fetching %1: %2").arg(operation, errorStrings.join("; "));
});
}
void UpdateController::handleNetworkError(QNetworkReply* reply, const QString& operation)
{
logger.error() << "Network error code:" << QString::number(static_cast<int>(reply->error()));
logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
#if defined(Q_OS_ANDROID)
// GitHub build on Android: open the release page in a browser, the system downloads the APK.
if (m_releasePageUrl.isEmpty()) {
logger.error() << "Release page URL is empty";
setUpdateState(UpdateState::State::DownloadError);
return;
}
QDesktopServices::openUrl(QUrl(m_releasePageUrl));
#elif !defined(Q_OS_IOS) && !defined(MACOS_NE)
downloadInstaller();
#endif
}
QString UpdateController::composeDownloadUrl() const
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
if (m_downloadBaseUrl.isEmpty() || m_version.isEmpty()) {
return QString();
}
const QString fileName = QString(kInstallerRemoteFileNamePattern).arg(m_version);
return m_baseUrl + "/" + fileName;
return m_downloadBaseUrl + "/" + fileName;
#else
return QString();
#endif
}
void UpdateController::runInstaller()
void UpdateController::downloadInstaller()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
if (m_downloadUrl.isEmpty()) {
logger.error() << "Download URL is empty";
if (m_updateState == UpdateState::State::Downloading) {
return;
}
const QString downloadUrl = composeDownloadUrl();
if (downloadUrl.isEmpty()) {
logger.error() << "Download URL is empty";
setUpdateState(UpdateState::State::DownloadError);
return;
}
setUpdateState(UpdateState::State::Downloading);
QNetworkRequest request;
request.setTransferTimeout(30000);
request.setUrl(m_downloadUrl);
request.setUrl(QUrl(downloadUrl));
QNetworkReply *reply = amnApp->networkManager()->get(request);
QObject::connect(reply, &QNetworkReply::finished, [this, reply]() {
if (reply->error() == QNetworkReply::NoError) {
QFile file(kInstallerLocalPath);
if (!file.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to open installer file for writing:" << kInstallerLocalPath << "Error:" << file.errorString();
reply->deleteLater();
return;
}
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (file.write(reply->readAll()) == -1) {
logger.error() << "Failed to write installer data to file:" << kInstallerLocalPath << "Error:" << file.errorString();
file.close();
reply->deleteLater();
return;
}
file.close();
#if defined(Q_OS_WINDOWS)
runWindowsInstaller(kInstallerLocalPath);
#elif defined(Q_OS_MACOS) && !defined(MACOS_NE)
runMacInstaller(kInstallerLocalPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
runLinuxInstaller(kInstallerLocalPath);
#endif
} else {
if (reply->error() != QNetworkReply::NoError) {
logger.error() << "Installer download failed, network error:" << static_cast<int>(reply->error())
<< reply->errorString();
logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
setUpdateState(UpdateState::State::DownloadError);
return;
}
reply->deleteLater();
QFile file(kInstallerLocalPath);
if (!file.open(QIODevice::WriteOnly)) {
logger.error() << "Failed to open installer file for writing:" << kInstallerLocalPath
<< "Error:" << file.errorString();
setUpdateState(UpdateState::State::DownloadError);
return;
}
if (file.write(reply->readAll()) == -1) {
logger.error() << "Failed to write installer data to file:" << kInstallerLocalPath
<< "Error:" << file.errorString();
file.close();
setUpdateState(UpdateState::State::DownloadError);
return;
}
file.close();
setUpdateState(UpdateState::State::ReadyToInstall);
});
#endif
}
void UpdateController::installUpdate()
{
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
if (m_updateState != UpdateState::State::ReadyToInstall) {
logger.error() << "Installer is not downloaded yet";
return;
}
#if defined(Q_OS_WINDOWS)
runWindowsInstaller(kInstallerLocalPath);
#elif defined(Q_OS_MACOS) && !defined(MACOS_NE)
runMacInstaller(kInstallerLocalPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
runLinuxInstaller(kInstallerLocalPath);
#endif
#endif
}
#if defined(Q_OS_WINDOWS)
int UpdateController::runWindowsInstaller(const QString &installerPath)
{
+49 -18
View File
@@ -1,53 +1,84 @@
#ifndef UPDATECONTROLLER_H
#define UPDATECONTROLLER_H
#include <functional>
#include <QObject>
#include <QNetworkReply>
#include <QQmlEngine>
#include <QStringList>
#include "core/repositories/secureAppSettingsRepository.h"
namespace UpdateState
{
Q_NAMESPACE
enum class State {
Idle = 0,
Downloading,
ReadyToInstall,
DownloadError
};
Q_ENUM_NS(State)
inline void declareQmlUpdateStateEnum()
{
qmlRegisterUncreatableMetaObject(UpdateState::staticMetaObject, "UpdateState", 1, 0, "UpdateState",
"Error: only enums");
}
}
class UpdateController : public QObject
{
Q_OBJECT
public:
explicit UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr);
QString getRawChangelogText() const;
QString getReleaseDate() const;
QString getVersion() const;
QString getReleaseDate() const;
QString getDescription() const;
QStringList getTags() const;
QStringList getNewFeatures() const;
QStringList getImprovements() const;
QStringList getBugFixes() const;
UpdateState::State getUpdateState() const;
bool isStoreUpdate() const;
bool isUpdateCheckRunning() const;
public slots:
void checkForUpdates();
void runInstaller();
void startUpdate();
void installUpdate();
signals:
void updateFound();
void updateNotFound();
void updateCheckFailed();
void updateStateChanged();
void updateCheckRunningChanged();
private:
void finishUpdateCheck();
void fetchGatewayUrl();
void fetchVersionInfo();
void fetchChangelog();
void fetchReleaseDate();
void doGetAsync(const QString &endpoint, std::function<void(bool, QByteArray)> onDone);
bool isNewVersionAvailable() const;
void setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation);
void handleNetworkError(QNetworkReply* reply, const QString& operation);
void setUpdateState(UpdateState::State state);
void setUpdateCheckRunning(bool running);
void downloadInstaller();
QString composeDownloadUrl() const;
void openStorePage() const;
SecureAppSettingsRepository* m_appSettingsRepository;
QString m_baseUrl;
QString m_changelogText;
QString m_version;
QString m_releaseDate;
QString m_downloadUrl;
QString m_description;
QStringList m_tags;
QStringList m_newFeatures;
QStringList m_improvements;
QStringList m_bugFixes;
QString m_downloadBaseUrl;
QString m_releasePageUrl;
UpdateState::State m_updateState = UpdateState::State::Idle;
bool m_updateCheckRunning = false;
#if defined(Q_OS_WINDOWS)
int runWindowsInstaller(const QString &installerPath);
#elif defined(Q_OS_MACOS)
#elif defined(Q_OS_MACOS) && !defined(MACOS_NE)
int runMacInstaller(const QString &installerPath);
#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)
int runLinuxInstaller(const QString &installerPath);
+36 -96
View File
@@ -1,11 +1,10 @@
#include "awgInstaller.h"
#include <QPair>
#include <QRandomGenerator>
#include <QSet>
#include <QStringList>
#include <QVector>
#include "core/configurators/wireguardConfigurator.h"
#include "core/utils/containerEnum.h"
#include "core/utils/containers/containerUtils.h"
#include "core/utils/protocolEnum.h"
@@ -28,109 +27,42 @@ AwgInstaller::AwgInstaller(QObject *parent)
ContainerConfig AwgInstaller::generateConfig(DockerContainer container, int port, TransportProto transportProto)
{
ContainerConfig config = createBaseConfig(container, port, transportProto);
bool isAwg2 = (container == DockerContainer::Awg2);
if (auto* awgConfig = config.getAwgProtocolConfig()) {
generateAwgParameters(awgConfig->serverConfig, isAwg2);
if (isAwg2) {
awgConfig->serverConfig.protocolVersion = "2";
}
generateAwgParameters(awgConfig->serverConfig);
awgConfig->serverConfig.protocolVersion = protocols::awg::awgV3;
}
return config;
}
void AwgInstaller::generateAwgParameters(AwgServerConfig &serverConfig, bool isAwg2)
void AwgInstaller::generateAwgParameters(AwgServerConfig &serverConfig)
{
QString junkPacketCount = QString::number(QRandomGenerator::global()->bounded(4, 7));
QString junkPacketMinSize = QString::number(10);
QString junkPacketMaxSize = QString::number(50);
int s1 = QRandomGenerator::global()->bounded(15, 150);
int s2 = QRandomGenerator::global()->bounded(15, 150);
int s3 = QRandomGenerator::global()->bounded(0, 64);
int s4 = QRandomGenerator::global()->bounded(0, 20);
// Ensure all values are unique and don't create equal packet sizes
QSet<int> usedValues;
usedValues.insert(s1);
while (usedValues.contains(s2) || s1 + amnezia::AwgConstant::messageInitiationSize == s2 + amnezia::AwgConstant::messageResponseSize) {
s2 = QRandomGenerator::global()->bounded(15, 150);
}
usedValues.insert(s2);
while (usedValues.contains(s3) || s1 + amnezia::AwgConstant::messageInitiationSize == s3 + amnezia::AwgConstant::messageCookieReplySize
|| s2 + amnezia::AwgConstant::messageResponseSize == s3 + amnezia::AwgConstant::messageCookieReplySize) {
s3 = QRandomGenerator::global()->bounded(0, 64);
}
usedValues.insert(s3);
while (usedValues.contains(s4)) {
s4 = QRandomGenerator::global()->bounded(0, 20);
}
QString initPacketJunkSize = QString::number(s1);
QString responsePacketJunkSize = QString::number(s2);
QString cookieReplyPacketJunkSize = QString::number(s3);
QString transportPacketJunkSize = QString::number(s4);
QString initPacketMagicHeader;
QString responsePacketMagicHeader;
QString underloadPacketMagicHeader;
QString transportPacketMagicHeader;
if (isAwg2) {
// AWG 2.0: use range format for magic headers
QVector<QPair<QString, QString>> headersValue;
int min = 5;
auto max = (std::numeric_limits<qint32>::max)();
while (headersValue.size() != 4) {
auto first = QRandomGenerator::global()->bounded(min, max);
auto second = QRandomGenerator::global()->bounded(first, max);
min = second;
headersValue.push_back(QPair<QString, QString>(QString::number(first), QString::number(second)));
}
initPacketMagicHeader = headersValue.at(0).first + "-" + headersValue.at(0).second;
responsePacketMagicHeader = headersValue.at(1).first + "-" + headersValue.at(1).second;
underloadPacketMagicHeader = headersValue.at(2).first + "-" + headersValue.at(2).second;
transportPacketMagicHeader = headersValue.at(3).first + "-" + headersValue.at(3).second;
} else {
// AWG legacy: use single values for magic headers
QSet<QString> headersValue;
while (headersValue.size() != 4) {
auto max = (std::numeric_limits<qint32>::max)();
headersValue.insert(QString::number(QRandomGenerator::global()->bounded(5, max)));
}
auto headersValueList = headersValue.values();
initPacketMagicHeader = headersValueList.at(0);
responsePacketMagicHeader = headersValueList.at(1);
underloadPacketMagicHeader = headersValueList.at(2);
transportPacketMagicHeader = headersValueList.at(3);
}
serverConfig.junkPacketCount = junkPacketCount;
serverConfig.junkPacketMinSize = junkPacketMinSize;
serverConfig.junkPacketMaxSize = junkPacketMaxSize;
serverConfig.initPacketJunkSize = initPacketJunkSize;
serverConfig.responsePacketJunkSize = responsePacketJunkSize;
serverConfig.initPacketMagicHeader = initPacketMagicHeader;
serverConfig.responsePacketMagicHeader = responsePacketMagicHeader;
serverConfig.underloadPacketMagicHeader = underloadPacketMagicHeader;
serverConfig.transportPacketMagicHeader = transportPacketMagicHeader;
serverConfig.initPacketJunkSize = protocols::awg::defaultPadding;
serverConfig.responsePacketJunkSize = protocols::awg::defaultPadding;
serverConfig.cookieReplyPacketJunkSize = protocols::awg::defaultPadding;
serverConfig.transportPacketJunkSize = protocols::awg::defaultPadding;
serverConfig.cookieReplyPacketJunkSize = cookieReplyPacketJunkSize;
serverConfig.transportPacketJunkSize = transportPacketJunkSize;
serverConfig.initPacketMagicHeader = protocols::awg::defaultInitPacketMagicHeader;
serverConfig.responsePacketMagicHeader = protocols::awg::defaultResponsePacketMagicHeader;
serverConfig.underloadPacketMagicHeader = protocols::awg::defaultUnderloadPacketMagicHeader;
serverConfig.transportPacketMagicHeader = protocols::awg::defaultTransportPacketMagicHeader;
serverConfig.headerProtectionKey = WireguardConfigurator::genClientKeys().clientPrivKey;
serverConfig.rekeyAfterTime = protocols::awg::defaultRekeyAfterTime;
serverConfig.rekeyTimeout = protocols::awg::defaultRekeyTimeout;
serverConfig.rejectAfterTime = protocols::awg::defaultRejectAfterTime;
serverConfig.keepaliveTimeout = protocols::awg::defaultKeepaliveTimeout;
serverConfig.maxHandshakeAttempts = protocols::awg::defaultMaxHandshakeAttempts;
serverConfig.randomTrailers = protocols::awg::defaultRandomTrailers;
serverConfig.disableCookies = protocols::awg::defaultDisableCookies;
serverConfig.specialJunk1 = protocols::awg::defaultSpecialJunk1;
serverConfig.specialJunk2 = protocols::awg::defaultSpecialJunk2;
serverConfig.specialJunk3 = protocols::awg::defaultSpecialJunk3;
serverConfig.specialJunk4 = protocols::awg::defaultSpecialJunk4;
serverConfig.specialJunk5 = protocols::awg::defaultSpecialJunk5;
}
ErrorCode AwgInstaller::extractConfigFromContainer(DockerContainer container, const ServerCredentials &credentials,
@@ -187,14 +119,22 @@ ErrorCode AwgInstaller::extractConfigFromContainer(DockerContainer container, co
awgConfig->serverConfig.specialJunk4 = serverConfigMap.value(QString("# ") + configKey::specialJunk4);
awgConfig->serverConfig.specialJunk5 = serverConfigMap.value(QString("# ") + configKey::specialJunk5);
// AWG 2.0 specific fields
if (container == DockerContainer::Awg2) {
awgConfig->serverConfig.protocolVersion = "2";
awgConfig->serverConfig.cookieReplyPacketJunkSize = serverConfigMap.value(configKey::cookieReplyPacketJunkSize);
awgConfig->serverConfig.transportPacketJunkSize = serverConfigMap.value(configKey::transportPacketJunkSize);
}
awgConfig->serverConfig.cookieReplyPacketJunkSize = serverConfigMap.value(configKey::cookieReplyPacketJunkSize);
awgConfig->serverConfig.transportPacketJunkSize = serverConfigMap.value(configKey::transportPacketJunkSize);
awgConfig->serverConfig.headerProtectionKey = serverConfigMap.value(configKey::headerProtectionKey);
awgConfig->serverConfig.contentPaddingAddition = serverConfigMap.value(configKey::contentPaddingAddition);
awgConfig->serverConfig.rekeyAfterTime = serverConfigMap.value(configKey::rekeyAfterTime);
awgConfig->serverConfig.rekeyTimeout = serverConfigMap.value(configKey::rekeyTimeout);
awgConfig->serverConfig.rejectAfterTime = serverConfigMap.value(configKey::rejectAfterTime);
awgConfig->serverConfig.keepaliveTimeout = serverConfigMap.value(configKey::keepaliveTimeout);
awgConfig->serverConfig.maxHandshakeAttempts = serverConfigMap.value(configKey::maxHandshakeAttempts);
awgConfig->serverConfig.randomTrailers = serverConfigMap.value(configKey::randomTrailers);
awgConfig->serverConfig.disableCookies = serverConfigMap.value(configKey::disableCookies);
awgConfig->serverConfig.protocolVersion = awgConfig->serverProtocolVersion();
}
return ErrorCode::NoError;
}
+1 -1
View File
@@ -14,7 +14,7 @@ public:
SshSession* serverController, amnezia::ContainerConfig &config) override;
private:
void generateAwgParameters(amnezia::AwgServerConfig &serverConfig, bool isAwg2 = false);
void generateAwgParameters(amnezia::AwgServerConfig &serverConfig);
};
#endif // AWGINSTALLER_H
+22 -2
View File
@@ -16,6 +16,7 @@
#include "core/models/protocols/socks5ProxyProtocolConfig.h"
#include "core/models/protocols/mtProxyProtocolConfig.h"
#include "core/models/protocols/telemtProtocolConfig.h"
#include "core/models/protocols/tProxyProtocolConfig.h"
#include "core/models/protocols/ikev2ProtocolConfig.h"
#include "core/models/protocols/torProtocolConfig.h"
@@ -56,6 +57,7 @@ ContainerConfig InstallerBase::createBaseConfig(DockerContainer container, int p
AwgProtocolConfig awgConfig;
awgConfig.serverConfig.port = portStr;
awgConfig.serverConfig.transportProto = transportProtoStr;
awgConfig.serverConfig.subnetAddress = protocols::wireguard::defaultSubnetAddress;
config.protocolConfig = awgConfig;
break;
}
@@ -63,6 +65,7 @@ ContainerConfig InstallerBase::createBaseConfig(DockerContainer container, int p
WireGuardProtocolConfig wgConfig;
wgConfig.serverConfig.port = portStr;
wgConfig.serverConfig.transportProto = transportProtoStr;
wgConfig.serverConfig.subnetAddress = protocols::wireguard::defaultSubnetAddress;
config.protocolConfig = wgConfig;
break;
}
@@ -76,8 +79,16 @@ ContainerConfig InstallerBase::createBaseConfig(DockerContainer container, int p
case Proto::Xray:
case Proto::SSXray: {
XrayProtocolConfig xrayConfig;
xrayConfig.serverConfig.port = portStr;
xrayConfig.serverConfig.transportProto = transportProtoStr;
XrayServerConfig &srv = xrayConfig.serverConfig;
srv.port = portStr;
srv.transportProto = transportProtoStr;
srv.transport = protocols::xray::defaultTransport;
srv.security = protocols::xray::defaultSecurity;
srv.flow = protocols::xray::defaultFlow;
srv.site = protocols::xray::defaultSite;
srv.sni = protocols::xray::defaultSni;
srv.alpn = protocols::xray::defaultAlpn;
srv.fingerprint = protocols::xray::defaultFingerprint;
config.protocolConfig = xrayConfig;
break;
}
@@ -105,6 +116,15 @@ ContainerConfig InstallerBase::createBaseConfig(DockerContainer container, int p
config.protocolConfig = telemtConfig;
break;
}
case Proto::TProxy: {
TProxyProtocolConfig tProxyConfig;
tProxyConfig.port = portStr.isEmpty() ? QString(protocols::tProxy::defaultPort) : portStr;
tProxyConfig.httpPort = QString(protocols::tProxy::defaultHttpPort);
tProxyConfig.carrierMode = QString(protocols::tProxy::carrierModeHttps);
tProxyConfig.workers = QString(protocols::tProxy::defaultWorkers);
config.protocolConfig = tProxyConfig;
break;
}
case Proto::Ikev2: {
Ikev2ProtocolConfig ikev2Config;
config.protocolConfig = ikev2Config;
+93 -6
View File
@@ -2,6 +2,7 @@
#include "core/utils/containerEnum.h"
#include "core/utils/containers/containerUtils.h"
#include "core/utils/constants/protocolConstants.h"
#include "core/utils/protocolEnum.h"
#include "core/utils/selfhosted/sshSession.h"
#include "core/models/containerConfig.h"
@@ -20,6 +21,8 @@ namespace {
constexpr QLatin1String kMtProxyClientJsonPath("/data/amnezia-mtproxy-client.json");
constexpr QLatin1String kMtProxyClientJsonUploadPath("data/amnezia-mtproxy-client.json");
constexpr QLatin1String kMtProxySecretPath("/data/secret");
constexpr QLatin1String kMtProxyMetaPath("/data/mtproxy-meta");
constexpr QLatin1String kMtProxyStartScriptPath("/opt/amnezia/start.sh");
}
MtProxyInstaller::MtProxyInstaller(QObject *parent)
@@ -53,14 +56,98 @@ ErrorCode MtProxyInstaller::extractConfigFromContainer(DockerContainer container
}
}
static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$"));
const auto addExtra = [&](const QString &s) {
if (hex32.match(s).hasMatch() && !mt->additionalSecrets.contains(s)) {
mt->additionalSecrets.append(s);
}
};
ErrorCode secretErr = ErrorCode::NoError;
const QByteArray secretRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kMtProxySecretPath), secretErr);
const QString sec = QString::fromUtf8(secretRaw).trimmed();
if (sec.length() == 32) {
static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$"));
if (hex32.match(sec).hasMatch()) {
mt->secret = sec;
if (sec.length() == 32 && hex32.match(sec).hasMatch()) {
mt->secret = sec;
}
bool metaRestored = false;
ErrorCode metaErr = ErrorCode::NoError;
const QByteArray metaRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kMtProxyMetaPath), metaErr);
if (metaErr == ErrorCode::NoError && !metaRaw.trimmed().isEmpty()) {
QString mode, domain, workersMode, workers, natInternal, natExternal;
bool natEnabled = false;
const QList<QByteArray> lines = metaRaw.split('\n');
for (const QByteArray &rawLine : lines) {
const QString line = QString::fromUtf8(rawLine).trimmed();
const int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
const QString key = line.left(eq);
const QString val = line.mid(eq + 1).trimmed();
if (key == QLatin1String("mode")) mode = val;
else if (key == QLatin1String("domain")) domain = val;
else if (key == QLatin1String("tag")) { if (mt->tag.isEmpty()) mt->tag = val; }
else if (key == QLatin1String("additional")) {
for (const QString &s : val.split(',', Qt::SkipEmptyParts)) addExtra(s.trimmed());
}
else if (key == QLatin1String("workers_mode")) workersMode = val;
else if (key == QLatin1String("workers")) workers = val;
else if (key == QLatin1String("nat_enabled")) natEnabled = (val == QLatin1String("1"));
else if (key == QLatin1String("nat_internal")) natInternal = val;
else if (key == QLatin1String("nat_external")) natExternal = val;
else if (key == QLatin1String("public_host")) { if (mt->publicHost.isEmpty()) mt->publicHost = val; }
}
if (!mode.isEmpty()) {
mt->transportMode = mode;
if (!domain.isEmpty()) mt->tlsDomain = domain;
if (!workersMode.isEmpty()) mt->workersMode = workersMode;
if (workersMode == QLatin1String(protocols::mtProxy::workersModeManual) && !workers.isEmpty()) {
mt->workers = workers;
}
if (natEnabled) {
mt->natEnabled = true;
mt->natInternalIp = natInternal;
mt->natExternalIp = natExternal;
}
metaRestored = true;
}
}
if (!metaRestored) {
ErrorCode startErr = ErrorCode::NoError;
const QByteArray startRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kMtProxyStartScriptPath), startErr);
if (startErr == ErrorCode::NoError && !startRaw.trimmed().isEmpty()) {
const QString start = QString::fromUtf8(startRaw);
static const QRegularExpression modeRe(QStringLiteral("\\[ \"(standard|faketls)\" = \"faketls\" \\]"));
const QRegularExpressionMatch m = modeRe.match(start);
if (m.hasMatch()) {
mt->transportMode = m.captured(1);
if (m.captured(1) == QLatin1String(protocols::mtProxy::transportModeFakeTLS)) {
static const QRegularExpression domRe(QStringLiteral("--domain ([A-Za-z0-9.\\-]+)"));
const QRegularExpressionMatch dm = domRe.match(start);
if (dm.hasMatch()) mt->tlsDomain = dm.captured(1);
}
}
static const QRegularExpression tagRe(QStringLiteral("-P ([0-9a-fA-F]{32})"));
const QRegularExpressionMatch tm = tagRe.match(start);
if (tm.hasMatch() && mt->tag.isEmpty()) mt->tag = tm.captured(1);
static const QRegularExpression addRe(QStringLiteral("echo \"([0-9a-fA-F,]+)\" \\| tr ',' ' '"));
const QRegularExpressionMatch am = addRe.match(start);
if (am.hasMatch()) {
for (const QString &s : am.captured(1).split(',', Qt::SkipEmptyParts)) addExtra(s.trimmed());
}
static const QRegularExpression natRe(QStringLiteral("NAT_VALUE=\"([0-9.]+):([0-9.]+)\""));
const QRegularExpressionMatch nm = natRe.match(start);
if (nm.hasMatch()) {
mt->natEnabled = true;
mt->natInternalIp = nm.captured(1);
mt->natExternalIp = nm.captured(2);
}
}
}
@@ -75,6 +162,8 @@ ErrorCode MtProxyInstaller::queryDiagnostics(SshSession &sshSession, const Serve
if (container == DockerContainer::MtProxy || container == DockerContainer::Telemt) {
const QString containerName = ContainerUtils::containerToString(container);
const bool isTelemt = container == DockerContainer::Telemt;
const QString confFile =
isTelemt ? QStringLiteral("/data/config.toml") : QStringLiteral("/data/proxy-multi.conf");
const QString sportFilter = QString::number(listenPort);
const QString peersCmd = QStringLiteral("sudo conntrack -L -p tcp --dport ") + sportFilter
@@ -85,8 +174,6 @@ ErrorCode MtProxyInstaller::queryDiagnostics(SshSession &sshSession, const Serve
"01])\\.|::1$|fe80:|f[cd][0-9a-f][0-9a-f]:)'");
const QString clientsCmd =
QStringLiteral("CLIENTS=$(") + peersCmd + publicFilter + QStringLiteral(" | sort -u | grep -c .); ");
const QString confFile =
isTelemt ? QStringLiteral("/data/config.toml") : QStringLiteral("/data/proxy-multi.conf");
const QString statsUrl = QString();
const QString script = QStringLiteral("CN=") + containerName + QStringLiteral("; ")
+141
View File
@@ -0,0 +1,141 @@
#include "tProxyInstaller.h"
#include "core/utils/containerEnum.h"
#include "core/utils/constants/protocolConstants.h"
#include "core/utils/selfhosted/sshSession.h"
#include "core/models/containerConfig.h"
#include "core/models/protocols/tProxyProtocolConfig.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QRegularExpression>
#include <QtGlobal>
using namespace amnezia;
namespace {
constexpr QLatin1String kTProxyClientJsonPath("/data/amnezia-tproxy-client.json");
constexpr QLatin1String kTProxyClientJsonUploadPath("data/amnezia-tproxy-client.json");
constexpr QLatin1String kTProxySecretPath("/data/secret");
constexpr QLatin1String kTProxyMetaPath("/data/tproxy-meta");
void rebuildTProxyLinksIfNeeded(TProxyProtocolConfig *tc)
{
if (!tc || tc->hostname.isEmpty() || tc->secret.isEmpty()) {
return;
}
const QString host = tc->hostname;
const QString sec = tc->secret;
// WEB proxy type fixes HTTPS/443; the canonical webproxy link carries no port.
if (tc->tgLink.isEmpty()) {
tc->tgLink = QStringLiteral("tg://webproxy?server=%1&secret=%2").arg(host, sec);
}
if (tc->tmeLink.isEmpty()) {
tc->tmeLink = QStringLiteral("https://t.me/webproxy?server=%1&secret=%2").arg(host, sec);
}
}
}
void TProxyInstaller::applyDockerPublishedPorts(const QString &dockerPsPortsLine, TProxyProtocolConfig &config)
{
static const QRegularExpression httpRe(QStringLiteral(":(\\d+)->80/(tcp|udp)"));
static const QRegularExpression httpsRe(QStringLiteral(":(\\d+)->443/(tcp|udp)"));
const QRegularExpressionMatch httpMatch = httpRe.match(dockerPsPortsLine);
const QRegularExpressionMatch httpsMatch = httpsRe.match(dockerPsPortsLine);
if (httpsMatch.hasMatch()) {
config.port = httpsMatch.captured(1);
}
if (httpMatch.hasMatch()) {
config.httpPort = httpMatch.captured(1);
}
}
TProxyInstaller::TProxyInstaller(QObject *parent) : InstallerBase(parent) {}
ErrorCode TProxyInstaller::extractConfigFromContainer(DockerContainer container, const ServerCredentials &credentials,
SshSession *sshSession, ContainerConfig &config)
{
if (container != DockerContainer::TProxy || !sshSession) {
return ErrorCode::NoError;
}
TProxyProtocolConfig *tc = config.getTProxyProtocolConfig();
if (!tc) {
return ErrorCode::NoError;
}
ErrorCode jsonErr = ErrorCode::NoError;
const QByteArray jsonRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kTProxyClientJsonPath), jsonErr);
if (jsonErr == ErrorCode::NoError && !jsonRaw.trimmed().isEmpty()) {
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(jsonRaw.trimmed(), &parseError);
if (parseError.error == QJsonParseError::NoError && doc.isObject()) {
QJsonObject merged = tc->toJson();
const QJsonObject snap = doc.object();
for (auto it = snap.constBegin(); it != snap.constEnd(); ++it) {
merged.insert(it.key(), it.value());
}
*tc = TProxyProtocolConfig::fromJson(merged);
}
}
static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$"));
ErrorCode secretErr = ErrorCode::NoError;
const QByteArray secretRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kTProxySecretPath), secretErr);
const QString sec = QString::fromUtf8(secretRaw).trimmed();
if (sec.length() == 32 && hex32.match(sec).hasMatch()) {
tc->secret = sec;
}
ErrorCode metaErr = ErrorCode::NoError;
const QByteArray metaRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kTProxyMetaPath), metaErr);
if (metaErr == ErrorCode::NoError && !metaRaw.trimmed().isEmpty()) {
const QList<QByteArray> lines = metaRaw.split('\n');
for (const QByteArray &rawLine : lines) {
const QString line = QString::fromUtf8(rawLine).trimmed();
const int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
const QString key = line.left(eq);
const QString val = line.mid(eq + 1).trimmed();
if (key == QLatin1String("hostname") && tc->hostname.isEmpty()) {
tc->hostname = val;
} else if (key == QLatin1String("email") && tc->acmeEmail.isEmpty()) {
tc->acmeEmail = val;
} else if (key == QLatin1String("carrier") && tc->carrierMode.isEmpty()) {
tc->carrierMode = val;
} else if (key == QLatin1String("workers") && tc->workers.isEmpty()) {
tc->workers = val;
} else if (key == QLatin1String("http_port") && !val.isEmpty()) {
tc->httpPort = val;
} else if (key == QLatin1String("https_port") && !val.isEmpty()) {
tc->port = val;
}
}
}
rebuildTProxyLinksIfNeeded(tc);
return ErrorCode::NoError;
}
void TProxyInstaller::uploadClientSettingsSnapshot(SshSession &sshSession, const ServerCredentials &credentials,
DockerContainer container, const ContainerConfig &config)
{
const TProxyProtocolConfig *tc = config.getTProxyProtocolConfig();
if (!tc) {
return;
}
const QByteArray payload = QJsonDocument(tc->toJson()).toJson(QJsonDocument::Compact);
const ErrorCode err = sshSession.uploadTextFileToContainer(container, credentials, QString::fromUtf8(payload),
QString(kTProxyClientJsonUploadPath));
if (err != ErrorCode::NoError) {
qWarning() << "TProxyInstaller::uploadClientSettingsSnapshot failed" << err;
}
}
+25
View File
@@ -0,0 +1,25 @@
#ifndef TPROXYINSTALLER_H
#define TPROXYINSTALLER_H
#include "installerBase.h"
#include "core/models/protocols/tProxyProtocolConfig.h"
class TProxyInstaller : public InstallerBase {
Q_OBJECT
public:
explicit TProxyInstaller(QObject *parent = nullptr);
amnezia::ErrorCode
extractConfigFromContainer(amnezia::DockerContainer container, const amnezia::ServerCredentials &credentials,
SshSession *sshSession, amnezia::ContainerConfig &config) override;
static void applyDockerPublishedPorts(const QString &dockerPsPortsLine,
amnezia::TProxyProtocolConfig &config);
static void uploadClientSettingsSnapshot(SshSession &sshSession, const amnezia::ServerCredentials &credentials,
amnezia::DockerContainer container,
const amnezia::ContainerConfig &config);
};
#endif // TPROXYINSTALLER_H
+79 -4
View File
@@ -2,6 +2,7 @@
#include "core/utils/containerEnum.h"
#include "core/utils/containers/containerUtils.h"
#include "core/utils/constants/protocolConstants.h"
#include "core/utils/selfhosted/sshSession.h"
#include "core/models/containerConfig.h"
#include "core/models/protocols/telemtProtocolConfig.h"
@@ -19,6 +20,7 @@ namespace {
constexpr QLatin1String kTelemtClientJsonPath("/data/amnezia-telemt-client.json");
constexpr QLatin1String kTelemtClientJsonUploadPath("data/amnezia-telemt-client.json");
constexpr QLatin1String kTelemtSecretPath("/data/secret");
constexpr QLatin1String kTelemtConfigTomlPath("/data/config.toml");
}
TelemtInstaller::TelemtInstaller(QObject *parent) : InstallerBase(parent) {}
@@ -54,10 +56,83 @@ ErrorCode TelemtInstaller::extractConfigFromContainer(DockerContainer container,
const QByteArray secretRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kTelemtSecretPath), secretErr);
const QString sec = QString::fromUtf8(secretRaw).trimmed();
if (sec.length() == 32) {
static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$"));
if (hex32.match(sec).hasMatch()) {
tc->secret = sec;
static const QRegularExpression hex32(QStringLiteral("^[0-9a-fA-F]{32}$"));
if (sec.length() == 32 && hex32.match(sec).hasMatch()) {
tc->secret = sec;
}
ErrorCode tomlErr = ErrorCode::NoError;
const QByteArray tomlRaw =
sshSession->getTextFileFromContainer(container, credentials, QString(kTelemtConfigTomlPath), tomlErr);
if (tomlErr == ErrorCode::NoError && !tomlRaw.trimmed().isEmpty()) {
QString section;
bool userNameSet = false;
const QList<QByteArray> lines = tomlRaw.split('\n');
for (const QByteArray &rawLine : lines) {
const QString line = QString::fromUtf8(rawLine).trimmed();
if (line.isEmpty() || line.startsWith('#')) {
continue;
}
if (line.startsWith('[')) {
section = line;
continue;
}
const int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
const QString key = line.left(eq).trimmed();
QString val = line.mid(eq + 1).trimmed();
if (val.startsWith('"')) {
const int last = val.lastIndexOf('"');
val = (last > 0) ? val.mid(1, last - 1) : val.mid(1);
} else {
const int inlineComment = val.indexOf(QLatin1String(" #"));
if (inlineComment >= 0) {
val = val.left(inlineComment).trimmed();
}
}
if (section == QLatin1String("[access.users]")) {
if (key.startsWith(QLatin1String("extra"))) {
if (hex32.match(val).hasMatch() && !tc->additionalSecrets.contains(val)) {
tc->additionalSecrets.append(val);
}
} else if (!userNameSet) {
tc->userName = key;
userNameSet = true;
if (tc->secret.isEmpty() && hex32.match(val).hasMatch()) {
tc->secret = val;
}
}
continue;
}
if (key == QLatin1String("tls") && section == QLatin1String("[general.modes]")) {
tc->transportMode = (val == QLatin1String("true"))
? QString::fromUtf8(protocols::telemt::transportModeFakeTLS)
: QString::fromUtf8(protocols::telemt::transportModeStandard);
} else if (key == QLatin1String("tls_domain") && section == QLatin1String("[censorship]")) {
tc->tlsDomain = val;
} else if (key == QLatin1String("mask") && section == QLatin1String("[censorship]")) {
tc->maskEnabled = (val == QLatin1String("true"));
} else if (key == QLatin1String("tls_emulation") && section == QLatin1String("[censorship]")) {
tc->tlsEmulation = (val == QLatin1String("true"));
} else if (key == QLatin1String("use_middle_proxy") && section == QLatin1String("[general]")) {
tc->useMiddleProxy = (val == QLatin1String("true"));
} else if (key == QLatin1String("middle_proxy_nat_ip") && section == QLatin1String("[general]")) {
if (!val.isEmpty()) {
tc->natExternalIp = val;
tc->natEnabled = true;
}
} else if (key == QLatin1String("ad_tag") && section == QLatin1String("[general]") && tc->tag.isEmpty()) {
tc->tag = val;
} else if (key == QLatin1String("public_host") && section == QLatin1String("[general.links]")
&& tc->publicHost.isEmpty()) {
tc->publicHost = val;
} else if (key == QLatin1String("port") && section == QLatin1String("[server]") && tc->port.isEmpty()) {
tc->port = val;
}
}
}
+19 -263
View File
@@ -1,7 +1,6 @@
#include "xrayInstaller.h"
#include <QJsonDocument>
#include <QJsonArray>
#include "core/utils/containerEnum.h"
#include "core/utils/containers/containerUtils.h"
@@ -11,6 +10,7 @@
#include "core/utils/constants/configKeys.h"
#include "core/utils/constants/protocolConstants.h"
#include "core/utils/selfhosted/sshSession.h"
#include "core/configurators/xrayConfigurator.h"
#include "core/models/protocols/xrayProtocolConfig.h"
#include "logger.h"
@@ -19,12 +19,21 @@ namespace
Logger logger("XrayInstaller");
// Xray expects uTLS preset names (chrome, firefox, …). Old Amnezia/server templates used "Mozilla/5.0".
QString normalizeXrayFingerprint(const QString &fp)
QString describeServerJsonStatus(amnezia::XrayServerJsonStatus status)
{
if (fp.isEmpty() || fp.contains(QLatin1String("Mozilla/5.0"), Qt::CaseInsensitive)) {
return QString::fromLatin1(protocols::xray::defaultFingerprint);
switch (status) {
case amnezia::XrayServerJsonStatus::MissingInbounds:
return QStringLiteral("server config missing 'inbounds' field");
case amnezia::XrayServerJsonStatus::EmptyInbounds:
return QStringLiteral("server config has empty 'inbounds' array");
case amnezia::XrayServerJsonStatus::MissingStreamSettings:
return QStringLiteral("inbound missing 'streamSettings' field");
case amnezia::XrayServerJsonStatus::MissingSettings:
return QStringLiteral("inbound missing 'settings' field");
case amnezia::XrayServerJsonStatus::Ok:
break;
}
return fp;
return QStringLiteral("ok");
}
}
@@ -40,7 +49,7 @@ ErrorCode XrayInstaller::extractConfigFromContainer(DockerContainer container, c
SshSession* sshSession, ContainerConfig &config)
{
ErrorCode errorCode = ErrorCode::NoError;
QString currentConfig = sshSession->getTextFileFromContainer(
container, credentials, amnezia::protocols::xray::serverConfigPath, errorCode);
@@ -53,269 +62,16 @@ ErrorCode XrayInstaller::extractConfigFromContainer(DockerContainer container, c
logger.error() << "Failed to parse server config JSON";
return ErrorCode::InternalError;
}
QJsonObject serverConfig = doc.object();
if (!serverConfig.contains(protocols::xray::inbounds)) {
logger.error() << "Server config missing 'inbounds' field";
return ErrorCode::InternalError;
}
QJsonArray inbounds = serverConfig[protocols::xray::inbounds].toArray();
if (inbounds.isEmpty()) {
logger.error() << "Server config has empty 'inbounds' array";
return ErrorCode::InternalError;
}
QJsonObject inbound = inbounds[0].toObject();
if (!inbound.contains(protocols::xray::streamSettings)) {
logger.error() << "Inbound missing 'streamSettings' field";
return ErrorCode::InternalError;
}
QJsonObject streamSettings = inbound[protocols::xray::streamSettings].toObject();
auto *xrayConfig = config.getXrayProtocolConfig();
if (!xrayConfig) {
logger.error() << "No XrayProtocolConfig in ContainerConfig";
return ErrorCode::InternalError;
}
XrayServerConfig &srv = xrayConfig->serverConfig;
// ── Port ─────────────────────────────────────────────────────────
if (inbound.contains(protocols::xray::port)) {
srv.port = QString::number(inbound[protocols::xray::port].toInt());
}
// ── Network (transport) ───────────────────────────────────────────
QString networkVal = streamSettings.value(protocols::xray::network).toString("tcp");
if (networkVal == "xhttp") {
srv.transport = "xhttp";
} else if (networkVal == "kcp") {
srv.transport = "mkcp";
} else {
srv.transport = "raw";
}
// ── Security ──────────────────────────────────────────────────────
srv.security = streamSettings.value(protocols::xray::security).toString("reality");
// ── Reality settings ──────────────────────────────────────────────
if (srv.security == "reality") {
QJsonObject rs = streamSettings.value(protocols::xray::realitySettings).toObject();
// serverNames array → site + sni
if (rs.contains(protocols::xray::serverNames)) {
QString sniVal = rs[protocols::xray::serverNames].toArray().first().toString();
srv.sni = sniVal;
srv.site = sniVal;
} else if (rs.contains(protocols::xray::serverName)) {
srv.sni = rs[protocols::xray::serverName].toString();
srv.site = srv.sni;
}
srv.fingerprint = normalizeXrayFingerprint(rs.value(protocols::xray::fingerprint).toString());
}
// ── TLS settings ──────────────────────────────────────────────────
if (srv.security == "tls") {
QJsonObject tls = streamSettings.value("tlsSettings").toObject();
srv.sni = tls.value(protocols::xray::serverName).toString();
srv.fingerprint = normalizeXrayFingerprint(tls.value(protocols::xray::fingerprint).toString());
QJsonArray alpnArr = tls.value("alpn").toArray();
QStringList alpnList;
for (const QJsonValue &v : alpnArr) {
alpnList << v.toString();
}
srv.alpn = alpnList.join(",");
}
// ── Flow (from users array) ───────────────────────────────────────
if (inbound.contains(protocols::xray::settings)) {
QJsonObject s = inbound[protocols::xray::settings].toObject();
QJsonArray clientsArr = s.value(protocols::xray::clients).toArray();
if (!clientsArr.isEmpty()) {
srv.flow = clientsArr[0].toObject().value(protocols::xray::flow).toString();
}
}
// ── XHTTP settings (Xray-core SplitHTTPConfig + legacy Amnezia keys) ──
if (srv.transport == "xhttp") {
QJsonObject xhttpObj = streamSettings.value("xhttpSettings").toObject();
{
const QString m = xhttpObj.value("mode").toString();
if (m.isEmpty() || m == QLatin1String("auto"))
srv.xhttp.mode = QStringLiteral("Auto");
else if (m == QLatin1String("packet-up"))
srv.xhttp.mode = QStringLiteral("Packet-up");
else if (m == QLatin1String("stream-up"))
srv.xhttp.mode = QStringLiteral("Stream-up");
else if (m == QLatin1String("stream-one"))
srv.xhttp.mode = QStringLiteral("Stream-one");
else
srv.xhttp.mode = m;
}
srv.xhttp.host = xhttpObj.value("host").toString();
srv.xhttp.path = xhttpObj.value("path").toString();
{
const QJsonObject hdrs = xhttpObj.value("headers").toObject();
if (hdrs.contains(QLatin1String("Host")) || !hdrs.isEmpty())
srv.xhttp.headersTemplate = QStringLiteral("HTTP");
}
if (xhttpObj.contains(QLatin1String("uplinkHTTPMethod")))
srv.xhttp.uplinkMethod = xhttpObj.value("uplinkHTTPMethod").toString();
else
srv.xhttp.uplinkMethod = xhttpObj.value("method").toString();
srv.xhttp.disableGrpc = xhttpObj.value("noGRPCHeader").toBool(true);
srv.xhttp.disableSse = xhttpObj.value("noSSEHeader").toBool(true);
auto sessionSeqUi = [](const QString &core) -> QString {
if (core.isEmpty() || core == QLatin1String("path"))
return QStringLiteral("Path");
if (core == QLatin1String("cookie"))
return QStringLiteral("Cookie");
if (core == QLatin1String("header"))
return QStringLiteral("Header");
if (core == QLatin1String("query"))
return QStringLiteral("Query");
return core;
};
QString sess = xhttpObj.value("sessionPlacement").toString();
if (sess.isEmpty())
sess = xhttpObj.value("scSessionPlacement").toString();
srv.xhttp.sessionPlacement = sessionSeqUi(sess);
QString seq = xhttpObj.value("seqPlacement").toString();
if (seq.isEmpty())
seq = xhttpObj.value("scSeqPlacement").toString();
srv.xhttp.seqPlacement = sessionSeqUi(seq);
auto uplinkDataUi = [](const QString &core) -> QString {
if (core.isEmpty() || core == QLatin1String("body"))
return QStringLiteral("Body");
if (core == QLatin1String("auto"))
return QStringLiteral("Auto");
if (core == QLatin1String("header"))
return QStringLiteral("Header");
if (core == QLatin1String("cookie"))
return QStringLiteral("Cookie");
return core;
};
QString udata = xhttpObj.value("uplinkDataPlacement").toString();
if (udata.isEmpty())
udata = xhttpObj.value("scUplinkDataPlacement").toString();
srv.xhttp.uplinkDataPlacement = uplinkDataUi(udata);
srv.xhttp.sessionKey = xhttpObj.value("sessionKey").toString();
srv.xhttp.seqKey = xhttpObj.value("seqKey").toString();
srv.xhttp.uplinkDataKey = xhttpObj.value("uplinkDataKey").toString();
if (xhttpObj.contains(QLatin1String("uplinkChunkSize"))) {
QJsonObject uc = xhttpObj.value("uplinkChunkSize").toObject();
if (!uc.isEmpty())
srv.xhttp.uplinkChunkSize = QString::number(uc.value("from").toInt());
} else if (xhttpObj.contains(QLatin1String("xhttpUplinkChunkSize"))) {
srv.xhttp.uplinkChunkSize = QString::number(xhttpObj.value("xhttpUplinkChunkSize").toInt());
}
if (xhttpObj.contains(QLatin1String("scMaxBufferedPosts"))) {
srv.xhttp.scMaxBufferedPosts = QString::number(xhttpObj.value("scMaxBufferedPosts").toVariant().toLongLong());
}
auto readRange = [&](const char *key, QString &minOut, QString &maxOut) {
QJsonObject r = xhttpObj.value(QLatin1String(key)).toObject();
if (!r.isEmpty()) {
minOut = QString::number(r.value("from").toInt());
maxOut = QString::number(r.value("to").toInt());
}
};
readRange("scMaxEachPostBytes", srv.xhttp.scMaxEachPostBytesMin, srv.xhttp.scMaxEachPostBytesMax);
readRange("scMinPostsIntervalMs", srv.xhttp.scMinPostsIntervalMsMin, srv.xhttp.scMinPostsIntervalMsMax);
readRange("scStreamUpServerSecs", srv.xhttp.scStreamUpServerSecsMin, srv.xhttp.scStreamUpServerSecsMax);
auto loadPaddingFromObject = [&](const QJsonObject &pad) {
if (pad.contains(QLatin1String("xPaddingObfsMode")))
srv.xhttp.xPadding.obfsMode = pad.value("xPaddingObfsMode").toBool(true);
srv.xhttp.xPadding.key = pad.value("xPaddingKey").toString();
srv.xhttp.xPadding.header = pad.value("xPaddingHeader").toString();
srv.xhttp.xPadding.placement = pad.value("xPaddingPlacement").toString();
srv.xhttp.xPadding.method = pad.value("xPaddingMethod").toString();
QJsonObject bytesRange = pad.value("xPaddingBytes").toObject();
if (!bytesRange.isEmpty()) {
srv.xhttp.xPadding.bytesMin = QString::number(bytesRange.value("from").toInt());
srv.xhttp.xPadding.bytesMax = QString::number(bytesRange.value("to").toInt());
}
QString pl = srv.xhttp.xPadding.placement.toLower();
if (pl == QLatin1String("cookie"))
srv.xhttp.xPadding.placement = QStringLiteral("Cookie");
else if (pl == QLatin1String("header"))
srv.xhttp.xPadding.placement = QStringLiteral("Header");
else if (pl == QLatin1String("query"))
srv.xhttp.xPadding.placement = QStringLiteral("Query");
else if (pl == QLatin1String("queryinheader"))
srv.xhttp.xPadding.placement = QStringLiteral("Query in header");
QString met = srv.xhttp.xPadding.method.toLower();
if (met == QLatin1String("repeat-x"))
srv.xhttp.xPadding.method = QStringLiteral("Repeat-x");
else if (met == QLatin1String("tokenish"))
srv.xhttp.xPadding.method = QStringLiteral("Tokenish");
};
if (xhttpObj.contains(QLatin1String("xPaddingObfsMode")) || xhttpObj.contains(QLatin1String("xPaddingKey"))
|| !xhttpObj.value("xPaddingBytes").toObject().isEmpty()) {
loadPaddingFromObject(xhttpObj);
} else if (xhttpObj.contains(QLatin1String("xPadding")) && xhttpObj.value("xPadding").isObject()) {
const QJsonObject nested = xhttpObj.value("xPadding").toObject();
if (!nested.isEmpty()) {
loadPaddingFromObject(nested);
if (!nested.contains(QLatin1String("xPaddingObfsMode")))
srv.xhttp.xPadding.obfsMode = true;
}
}
if (xhttpObj.contains(QLatin1String("xmux"))) {
QJsonObject mux = xhttpObj.value("xmux").toObject();
srv.xhttp.xmux.enabled = true;
auto readMuxRange = [&](const char *key, QString &minOut, QString &maxOut) {
QJsonObject r = mux.value(QLatin1String(key)).toObject();
if (!r.isEmpty()) {
minOut = QString::number(r.value("from").toInt());
maxOut = QString::number(r.value("to").toInt());
}
};
readMuxRange("maxConcurrency", srv.xhttp.xmux.maxConcurrencyMin, srv.xhttp.xmux.maxConcurrencyMax);
readMuxRange("maxConnections", srv.xhttp.xmux.maxConnectionsMin, srv.xhttp.xmux.maxConnectionsMax);
readMuxRange("cMaxReuseTimes", srv.xhttp.xmux.cMaxReuseTimesMin, srv.xhttp.xmux.cMaxReuseTimesMax);
readMuxRange("hMaxRequestTimes", srv.xhttp.xmux.hMaxRequestTimesMin, srv.xhttp.xmux.hMaxRequestTimesMax);
readMuxRange("hMaxReusableSecs", srv.xhttp.xmux.hMaxReusableSecsMin, srv.xhttp.xmux.hMaxReusableSecsMax);
if (mux.contains(QLatin1String("hKeepAlivePeriod")))
srv.xhttp.xmux.hKeepAlivePeriod = QString::number(mux.value("hKeepAlivePeriod").toVariant().toLongLong());
}
}
// ── mKCP settings ─────────────────────────────────────────────────
if (srv.transport == "mkcp") {
QJsonObject kcp = streamSettings.value("kcpSettings").toObject();
if (kcp.contains("tti")) {
srv.mkcp.tti = QString::number(kcp["tti"].toInt());
}
if (kcp.contains("uplinkCapacity")) {
srv.mkcp.uplinkCapacity = QString::number(kcp["uplinkCapacity"].toInt());
}
if (kcp.contains("downlinkCapacity")) {
srv.mkcp.downlinkCapacity = QString::number(kcp["downlinkCapacity"].toInt());
}
if (kcp.contains("readBufferSize")) {
srv.mkcp.readBufferSize = QString::number(kcp["readBufferSize"].toInt());
}
if (kcp.contains("writeBufferSize")) {
srv.mkcp.writeBufferSize = QString::number(kcp["writeBufferSize"].toInt());
}
srv.mkcp.congestion = kcp.value("congestion").toBool(true);
const amnezia::XrayServerJsonStatus status =
XrayServerConfig::fromServerInboundJson(doc.object(), xrayConfig->serverConfig);
if (status != amnezia::XrayServerJsonStatus::Ok) {
return ErrorCode::InternalError;
}
return ErrorCode::NoError;
+4
View File
@@ -118,6 +118,9 @@ QJsonObject ApiConfig::toJson() const
if (!serverCountryCode.isEmpty()) {
obj[apiDefs::key::serverCountryCode] = serverCountryCode;
}
if (!serverCountryCodeL10n.isEmpty()) {
obj[apiDefs::key::serverCountryCodeL10n] = serverCountryCodeL10n;
}
if (!serverCountryName.isEmpty()) {
obj[apiDefs::key::serverCountryName] = serverCountryName;
}
@@ -181,6 +184,7 @@ ApiConfig ApiConfig::fromJson(const QJsonObject& json)
config.serviceProtocol = json.value(QLatin1String("service_protocol")).toString();
config.userCountryCode = json.value(QLatin1String("user_country_code")).toString();
config.serverCountryCode = json.value(apiDefs::key::serverCountryCode).toString();
config.serverCountryCodeL10n = json.value(apiDefs::key::serverCountryCodeL10n).toString();
config.serverCountryName = json.value(apiDefs::key::serverCountryName).toString();
config.vpnKey = json.value(apiDefs::key::vpnKey).toString();
+1
View File
@@ -19,6 +19,7 @@ struct ApiConfig
QString serviceProtocol;
QString userCountryCode;
QString serverCountryCode;
QString serverCountryCodeL10n;
QString serverCountryName;
QString vpnKey;
@@ -85,6 +85,7 @@ QPair<QString, QString> ApiV2ServerConfig::getDnsPair(const QString &primaryDns,
QJsonObject ApiV2ServerConfig::toJson() const
{
QJsonObject obj;
obj[configKey::formatVersion] = serverConfigUtils::currentConfigFormatVersion;
if (!name.isEmpty()) {
obj[configKey::name] = name;
+10
View File
@@ -133,6 +133,16 @@ const TelemtProtocolConfig* ContainerConfig::getTelemtProtocolConfig() const
return protocolConfig.as<TelemtProtocolConfig>();
}
TProxyProtocolConfig* ContainerConfig::getTProxyProtocolConfig()
{
return protocolConfig.as<TProxyProtocolConfig>();
}
const TProxyProtocolConfig* ContainerConfig::getTProxyProtocolConfig() const
{
return protocolConfig.as<TProxyProtocolConfig>();
}
Ikev2ProtocolConfig* ContainerConfig::getIkev2ProtocolConfig()
{
return protocolConfig.as<Ikev2ProtocolConfig>();
+3
View File
@@ -63,6 +63,9 @@ struct ContainerConfig {
TelemtProtocolConfig* getTelemtProtocolConfig();
const TelemtProtocolConfig* getTelemtProtocolConfig() const;
TProxyProtocolConfig* getTProxyProtocolConfig();
const TProxyProtocolConfig* getTProxyProtocolConfig() const;
Ikev2ProtocolConfig* getIkev2ProtocolConfig();
const Ikev2ProtocolConfig* getIkev2ProtocolConfig() const;
+9
View File
@@ -11,6 +11,7 @@
#include "core/models/protocols/dnsProtocolConfig.h"
#include "core/models/protocols/mtProxyProtocolConfig.h"
#include "core/models/protocols/telemtProtocolConfig.h"
#include "core/models/protocols/tProxyProtocolConfig.h"
namespace amnezia
{
@@ -44,6 +45,8 @@ Proto ProtocolConfig::type() const
return Proto::MtProxy;
} else if constexpr (std::is_same_v<T, TelemtProtocolConfig>) {
return Proto::Telemt;
} else if constexpr (std::is_same_v<T, TProxyProtocolConfig>) {
return Proto::TProxy;
}
return Proto::Unknown;
}, data);
@@ -75,6 +78,8 @@ QString ProtocolConfig::port() const
return arg.port.isEmpty() ? QString(protocols::mtProxy::defaultPort) : arg.port;
} else if constexpr (std::is_same_v<T, TelemtProtocolConfig>) {
return arg.port.isEmpty() ? QString(protocols::telemt::defaultPort) : arg.port;
} else if constexpr (std::is_same_v<T, TProxyProtocolConfig>) {
return arg.port.isEmpty() ? QString(protocols::tProxy::defaultPort) : arg.port;
}
return QString();
}, data);
@@ -102,6 +107,8 @@ QString ProtocolConfig::transportProto() const
return QStringLiteral("tcp");
} else if constexpr (std::is_same_v<T, TelemtProtocolConfig>) {
return QStringLiteral("tcp");
} else if constexpr (std::is_same_v<T, TProxyProtocolConfig>) {
return QStringLiteral("tcp");
}
return QString();
}, data);
@@ -317,6 +324,8 @@ ProtocolConfig ProtocolConfig::fromJson(const QJsonObject& json, Proto type)
return ProtocolConfig{MtProxyProtocolConfig::fromJson(json)};
case Proto::Telemt:
return ProtocolConfig{TelemtProtocolConfig::fromJson(json)};
case Proto::TProxy:
return ProtocolConfig{TProxyProtocolConfig::fromJson(json)};
default:
return ProtocolConfig{AwgProtocolConfig{}};
}
+2
View File
@@ -24,6 +24,7 @@
#include "core/models/protocols/dnsProtocolConfig.h"
#include "core/models/protocols/mtProxyProtocolConfig.h"
#include "core/models/protocols/telemtProtocolConfig.h"
#include "core/models/protocols/tProxyProtocolConfig.h"
namespace amnezia
{
@@ -40,6 +41,7 @@ struct ProtocolConfig {
Socks5ProxyProtocolConfig,
MtProxyProtocolConfig,
TelemtProtocolConfig,
TProxyProtocolConfig,
Ikev2ProtocolConfig,
TorProtocolConfig,
DnsProtocolConfig
@@ -2,6 +2,10 @@
#include <QJsonDocument>
#include <QJsonArray>
#include <QObject>
#include <QSet>
#include <algorithm>
#include "../../../core/utils/protocolEnum.h"
#include "../../../core/protocols/protocolUtils.h"
@@ -13,6 +17,54 @@ using namespace ProtocolUtils;
namespace amnezia
{
namespace
{
template <typename T>
bool hasAwg3Markers(const T &config)
{
auto hasValue = [](const QString &value) { return !value.trimmed().isEmpty(); };
const QStringList awg3Params = { config.headerProtectionKey, config.contentPaddingAddition,
config.rekeyAfterTime, config.rekeyTimeout,
config.rejectAfterTime, config.keepaliveTimeout,
config.maxHandshakeAttempts };
if (std::any_of(awg3Params.begin(), awg3Params.end(), hasValue)) {
return true;
}
return AwgProtocolConfig::isToggleEnabled(config.randomTrailers)
|| AwgProtocolConfig::isToggleEnabled(config.disableCookies);
}
template <typename T>
QString awgVersionOf(const T &config)
{
auto hasValue = [](const QString &value) { return !value.trimmed().isEmpty(); };
if (hasAwg3Markers(config)) {
return protocols::awg::awgV3;
}
const QStringList junkSizes = { config.cookieReplyPacketJunkSize, config.transportPacketJunkSize };
const QStringList magicHeaders = { config.initPacketMagicHeader, config.responsePacketMagicHeader,
config.underloadPacketMagicHeader, config.transportPacketMagicHeader };
bool hasJunkSizes = std::any_of(junkSizes.begin(), junkSizes.end(), hasValue);
bool hasHeaderRanges = std::any_of(magicHeaders.begin(), magicHeaders.end(),
[](const QString &header) { return header.contains('-'); });
if (hasJunkSizes || hasHeaderRanges) {
return protocols::awg::awgV2;
}
const QStringList specialJunk = { config.specialJunk1, config.specialJunk2, config.specialJunk3,
config.specialJunk4, config.specialJunk5 };
if (std::any_of(specialJunk.begin(), specialJunk.end(), hasValue)) {
return protocols::awg::awgV1_5;
}
return QString();
}
} // namespace
QJsonObject AwgServerConfig::toJson() const
{
QJsonObject obj;
@@ -74,6 +126,34 @@ QJsonObject AwgServerConfig::toJson() const
obj[configKey::specialJunk4] = specialJunk4;
obj[configKey::specialJunk5] = specialJunk5;
if (!headerProtectionKey.isEmpty()) {
obj[configKey::headerProtectionKey] = headerProtectionKey;
}
if (!contentPaddingAddition.isEmpty()) {
obj[configKey::contentPaddingAddition] = contentPaddingAddition;
}
if (!rekeyAfterTime.isEmpty()) {
obj[configKey::rekeyAfterTime] = rekeyAfterTime;
}
if (!rekeyTimeout.isEmpty()) {
obj[configKey::rekeyTimeout] = rekeyTimeout;
}
if (!rejectAfterTime.isEmpty()) {
obj[configKey::rejectAfterTime] = rejectAfterTime;
}
if (!keepaliveTimeout.isEmpty()) {
obj[configKey::keepaliveTimeout] = keepaliveTimeout;
}
if (!maxHandshakeAttempts.isEmpty()) {
obj[configKey::maxHandshakeAttempts] = maxHandshakeAttempts;
}
if (!randomTrailers.isEmpty()) {
obj[configKey::randomTrailers] = randomTrailers;
}
if (!disableCookies.isEmpty()) {
obj[configKey::disableCookies] = disableCookies;
}
if (isThirdPartyConfig) {
obj[configKey::isThirdPartyConfig] = isThirdPartyConfig;
}
@@ -109,7 +189,17 @@ AwgServerConfig AwgServerConfig::fromJson(const QJsonObject& json)
config.specialJunk3 = json.value(configKey::specialJunk3).toString();
config.specialJunk4 = json.value(configKey::specialJunk4).toString();
config.specialJunk5 = json.value(configKey::specialJunk5).toString();
config.headerProtectionKey = json.value(configKey::headerProtectionKey).toString();
config.contentPaddingAddition = json.value(configKey::contentPaddingAddition).toString();
config.rekeyAfterTime = json.value(configKey::rekeyAfterTime).toString();
config.rekeyTimeout = json.value(configKey::rekeyTimeout).toString();
config.rejectAfterTime = json.value(configKey::rejectAfterTime).toString();
config.keepaliveTimeout = json.value(configKey::keepaliveTimeout).toString();
config.maxHandshakeAttempts = json.value(configKey::maxHandshakeAttempts).toString();
config.randomTrailers = json.value(configKey::randomTrailers).toString();
config.disableCookies = json.value(configKey::disableCookies).toString();
config.isThirdPartyConfig = json.value(configKey::isThirdPartyConfig).toBool(false);
return config;
@@ -196,16 +286,50 @@ QJsonObject AwgClientConfig::toJson() const
obj[configKey::transportPacketMagicHeader] = transportPacketMagicHeader;
}
obj[configKey::specialJunk1] = specialJunk1;
obj[configKey::specialJunk2] = specialJunk2;
obj[configKey::specialJunk3] = specialJunk3;
obj[configKey::specialJunk4] = specialJunk4;
obj[configKey::specialJunk5] = specialJunk5;
if (isObfuscationEnabled) {
obj[configKey::isObfuscationEnabled] = isObfuscationEnabled;
if (!specialJunk1.isEmpty()) {
obj[configKey::specialJunk1] = specialJunk1;
}
if (!specialJunk2.isEmpty()) {
obj[configKey::specialJunk2] = specialJunk2;
}
if (!specialJunk3.isEmpty()) {
obj[configKey::specialJunk3] = specialJunk3;
}
if (!specialJunk4.isEmpty()) {
obj[configKey::specialJunk4] = specialJunk4;
}
if (!specialJunk5.isEmpty()) {
obj[configKey::specialJunk5] = specialJunk5;
}
if (!headerProtectionKey.isEmpty()) {
obj[configKey::headerProtectionKey] = headerProtectionKey;
}
if (!contentPaddingAddition.isEmpty()) {
obj[configKey::contentPaddingAddition] = contentPaddingAddition;
}
if (!rekeyAfterTime.isEmpty()) {
obj[configKey::rekeyAfterTime] = rekeyAfterTime;
}
if (!rekeyTimeout.isEmpty()) {
obj[configKey::rekeyTimeout] = rekeyTimeout;
}
if (!rejectAfterTime.isEmpty()) {
obj[configKey::rejectAfterTime] = rejectAfterTime;
}
if (!keepaliveTimeout.isEmpty()) {
obj[configKey::keepaliveTimeout] = keepaliveTimeout;
}
if (!maxHandshakeAttempts.isEmpty()) {
obj[configKey::maxHandshakeAttempts] = maxHandshakeAttempts;
}
if (!randomTrailers.isEmpty()) {
obj[configKey::randomTrailers] = randomTrailers;
}
if (!disableCookies.isEmpty()) {
obj[configKey::disableCookies] = disableCookies;
}
return obj;
}
@@ -248,21 +372,28 @@ AwgClientConfig AwgClientConfig::fromJson(const QJsonObject& json)
config.specialJunk3 = json.value(configKey::specialJunk3).toString();
config.specialJunk4 = json.value(configKey::specialJunk4).toString();
config.specialJunk5 = json.value(configKey::specialJunk5).toString();
config.isObfuscationEnabled = json.value(configKey::isObfuscationEnabled).toBool(false);
config.headerProtectionKey = json.value(configKey::headerProtectionKey).toString();
config.contentPaddingAddition = json.value(configKey::contentPaddingAddition).toString();
config.rekeyAfterTime = json.value(configKey::rekeyAfterTime).toString();
config.rekeyTimeout = json.value(configKey::rekeyTimeout).toString();
config.rejectAfterTime = json.value(configKey::rejectAfterTime).toString();
config.keepaliveTimeout = json.value(configKey::keepaliveTimeout).toString();
config.maxHandshakeAttempts = json.value(configKey::maxHandshakeAttempts).toString();
config.randomTrailers = json.value(configKey::randomTrailers).toString();
config.disableCookies = json.value(configKey::disableCookies).toString();
return config;
}
QJsonObject AwgProtocolConfig::toJson() const
{
QJsonObject obj = serverConfig.toJson();
if (clientConfig.has_value()) {
QJsonObject clientJson = clientConfig->toJson();
obj[configKey::lastConfig] = QString::fromUtf8(QJsonDocument(clientJson).toJson(QJsonDocument::Compact));
}
return obj;
}
@@ -283,6 +414,31 @@ AwgProtocolConfig AwgProtocolConfig::fromJson(const QJsonObject& json)
return config;
}
QString AwgProtocolConfig::serverProtocolVersion() const
{
return awgVersionOf(serverConfig);
}
QString AwgProtocolConfig::clientProtocolVersion() const
{
return clientConfig.has_value() ? awgVersionOf(clientConfig.value()) : QString();
}
bool AwgProtocolConfig::isToggleEnabled(const QString &value)
{
const QString trimmedValue = value.trimmed();
return !trimmedValue.isEmpty()
&& trimmedValue.compare(QLatin1String(protocols::awg::awgBoolOff), Qt::CaseInsensitive) != 0;
}
QString AwgProtocolConfig::protocolVersionString(const QString &version)
{
if (version == protocols::awg::awgV3) return QObject::tr(" (version 3.1)");
if (version == protocols::awg::awgV2) return QObject::tr(" (version 2)");
if (version == protocols::awg::awgV1_5) return QObject::tr(" (version 1.5)");
return "";
}
bool AwgProtocolConfig::hasClientConfig() const
{
return clientConfig.has_value();
@@ -310,24 +466,37 @@ bool AwgServerConfig::hasEqualServerSettings(const AwgServerConfig& other) const
transportPacketMagicHeader != other.transportPacketMagicHeader ||
specialJunk1 != other.specialJunk1 || specialJunk2 != other.specialJunk2 ||
specialJunk3 != other.specialJunk3 || specialJunk4 != other.specialJunk4 ||
specialJunk5 != other.specialJunk5) {
specialJunk5 != other.specialJunk5 ||
cookieReplyPacketJunkSize != other.cookieReplyPacketJunkSize ||
transportPacketJunkSize != other.transportPacketJunkSize ||
headerProtectionKey != other.headerProtectionKey ||
contentPaddingAddition != other.contentPaddingAddition ||
rekeyAfterTime != other.rekeyAfterTime || rekeyTimeout != other.rekeyTimeout ||
rejectAfterTime != other.rejectAfterTime || keepaliveTimeout != other.keepaliveTimeout ||
maxHandshakeAttempts != other.maxHandshakeAttempts ||
randomTrailers != other.randomTrailers || disableCookies != other.disableCookies) {
return false;
}
bool isV2 = protocolVersion == protocols::awg::awgV2;
if (isV2) {
if (cookieReplyPacketJunkSize != other.cookieReplyPacketJunkSize ||
transportPacketJunkSize != other.transportPacketJunkSize) {
return false;
}
}
return true;
}
bool AwgServerConfig::hasAwg3Params() const
{
return hasAwg3Markers(*this);
}
bool AwgProtocolConfig::isHeadersEqual(const QString &h1, const QString &h2, const QString &h3, const QString &h4)
{
return (h1 == h2) || (h1 == h3) || (h1 == h4) || (h2 == h3) || (h2 == h4) || (h3 == h4);
QSet<QString> uniqueHeaders;
int filledHeaders = 0;
for (const QString &header : { h1, h2, h3, h4 }) {
if (!header.trimmed().isEmpty()) {
++filledHeaders;
uniqueHeaders.insert(header);
}
}
return uniqueHeaders.size() != filledHeaders;
}
bool AwgProtocolConfig::isPacketSizeEqual(int s1, int s2, int s3, int s4)
@@ -39,12 +39,22 @@ struct AwgServerConfig {
QString specialJunk3;
QString specialJunk4;
QString specialJunk5;
QString headerProtectionKey;
QString contentPaddingAddition;
QString rekeyAfterTime;
QString rekeyTimeout;
QString rejectAfterTime;
QString keepaliveTimeout;
QString maxHandshakeAttempts;
QString randomTrailers;
QString disableCookies;
bool isThirdPartyConfig = false;
QJsonObject toJson() const;
static AwgServerConfig fromJson(const QJsonObject& json);
bool hasEqualServerSettings(const AwgServerConfig& other) const;
bool hasAwg3Params() const;
};
struct AwgClientConfig {
@@ -76,8 +86,15 @@ struct AwgClientConfig {
QString specialJunk3;
QString specialJunk4;
QString specialJunk5;
bool isObfuscationEnabled = false;
QString headerProtectionKey;
QString contentPaddingAddition;
QString rekeyAfterTime;
QString rekeyTimeout;
QString rejectAfterTime;
QString keepaliveTimeout;
QString maxHandshakeAttempts;
QString randomTrailers;
QString disableCookies;
QJsonObject toJson() const;
static AwgClientConfig fromJson(const QJsonObject& json);
};
@@ -89,6 +106,11 @@ struct AwgProtocolConfig {
QJsonObject toJson() const;
static AwgProtocolConfig fromJson(const QJsonObject& json);
QString serverProtocolVersion() const;
QString clientProtocolVersion() const;
static QString protocolVersionString(const QString &version);
static bool isToggleEnabled(const QString &value);
bool hasClientConfig() const;
void setClientConfig(const AwgClientConfig& config);
void clearClientConfig();
@@ -0,0 +1,77 @@
#include "tProxyProtocolConfig.h"
#include "core/utils/constants/configKeys.h"
#include "core/utils/constants/protocolConstants.h"
using namespace amnezia;
QJsonObject TProxyProtocolConfig::toJson() const
{
QJsonObject obj;
if (!port.isEmpty()) {
obj[QString(configKey::port)] = port;
}
if (!httpPort.isEmpty()) {
obj[protocols::tProxy::httpPortKey] = httpPort;
}
if (!secret.isEmpty()) {
obj[protocols::tProxy::secretKey] = secret;
}
if (!hostname.isEmpty()) {
obj[protocols::tProxy::hostnameKey] = hostname;
}
if (!acmeEmail.isEmpty()) {
obj[protocols::tProxy::acmeEmailKey] = acmeEmail;
}
if (!carrierMode.isEmpty()) {
obj[protocols::tProxy::carrierModeKey] = carrierMode;
}
if (!workers.isEmpty()) {
obj[protocols::tProxy::workersKey] = workers;
}
if (!tgLink.isEmpty()) {
obj[protocols::tProxy::tgLinkKey] = tgLink;
}
if (!tmeLink.isEmpty()) {
obj[protocols::tProxy::tmeLinkKey] = tmeLink;
}
obj[protocols::tProxy::isEnabledKey] = isEnabled;
return obj;
}
TProxyProtocolConfig TProxyProtocolConfig::fromJson(const QJsonObject &json)
{
TProxyProtocolConfig c;
c.port = json.value(QString(configKey::port)).toString();
c.httpPort = json.value(protocols::tProxy::httpPortKey).toString();
c.secret = json.value(protocols::tProxy::secretKey).toString();
c.hostname = json.value(protocols::tProxy::hostnameKey).toString();
c.acmeEmail = json.value(protocols::tProxy::acmeEmailKey).toString();
c.carrierMode = json.value(protocols::tProxy::carrierModeKey).toString();
c.workers = json.value(protocols::tProxy::workersKey).toString();
c.tgLink = json.value(protocols::tProxy::tgLinkKey).toString();
c.tmeLink = json.value(protocols::tProxy::tmeLinkKey).toString();
c.isEnabled = json.value(protocols::tProxy::isEnabledKey).toBool(true);
return c;
}
bool TProxyProtocolConfig::equalsDockerDeploymentSettings(const TProxyProtocolConfig &other) const
{
const auto normCarrier = [](const QString &m) {
return m.isEmpty() ? QString(protocols::tProxy::carrierModeHttps) : m;
};
const auto normWorkers = [](const QString &w) {
return w.isEmpty() ? QString(protocols::tProxy::defaultWorkers) : w;
};
const auto normPort = [](const QString &p, const char *defaultPort) {
return p.isEmpty() ? QString(defaultPort) : p;
};
return hostname == other.hostname && secret == other.secret && acmeEmail == other.acmeEmail
&& normPort(port, protocols::tProxy::defaultPort) == normPort(other.port, protocols::tProxy::defaultPort)
&& normPort(httpPort, protocols::tProxy::defaultHttpPort)
== normPort(other.httpPort, protocols::tProxy::defaultHttpPort)
&& normCarrier(carrierMode) == normCarrier(other.carrierMode)
&& normWorkers(workers) == normWorkers(other.workers) && isEnabled == other.isEnabled;
}
@@ -0,0 +1,28 @@
#ifndef TPROXYPROTOCOLCONFIG_H
#define TPROXYPROTOCOLCONFIG_H
#include <QJsonObject>
#include <QString>
namespace amnezia {
struct TProxyProtocolConfig {
QString port;
QString httpPort;
QString secret;
QString hostname;
QString acmeEmail;
QString carrierMode;
QString workers;
QString tgLink;
QString tmeLink;
bool isEnabled = true;
QJsonObject toJson() const;
static TProxyProtocolConfig fromJson(const QJsonObject &json);
bool equalsDockerDeploymentSettings(const TProxyProtocolConfig &other) const;
};
} // namespace amnezia
#endif // TPROXYPROTOCOLCONFIG_H
@@ -99,9 +99,6 @@ bool TelemtProtocolConfig::equalsDockerDeploymentSettings(const TelemtProtocolCo
const auto normTransport = [](const QString &t) {
return t.isEmpty() ? QString(protocols::telemt::transportModeStandard) : t;
};
const auto normWorkersMode = [](const QString &m) {
return m.isEmpty() ? QString(protocols::telemt::workersModeAuto) : m;
};
if (normPort(port) != normPort(other.port)) {
return false;
@@ -133,18 +130,9 @@ bool TelemtProtocolConfig::equalsDockerDeploymentSettings(const TelemtProtocolCo
if (userName != other.userName) {
return false;
}
if (normWorkersMode(workersMode) != normWorkersMode(other.workersMode)) {
return false;
}
if (workers != other.workers) {
return false;
}
if (natEnabled != other.natEnabled) {
return false;
}
if (natInternalIp != other.natInternalIp) {
return false;
}
if (natExternalIp != other.natExternalIp) {
return false;
}
@@ -105,11 +105,17 @@ QJsonObject WireGuardClientConfig::toJson() const
if (!mtu.isEmpty()) {
obj[configKey::mtu] = mtu;
}
for (auto it = awgParams.constBegin(); it != awgParams.constEnd(); ++it) {
if (!it.value().isEmpty()) {
obj[it.key()] = it.value();
}
}
if (isObfuscationEnabled) {
obj[configKey::isObfuscationEnabled] = isObfuscationEnabled;
}
return obj;
}
@@ -133,9 +139,16 @@ WireGuardClientConfig WireGuardClientConfig::fromJson(const QJsonObject& json)
}
config.persistentKeepAlive = json.value(configKey::persistentKeepAlive).toString();
config.mtu = json.value(configKey::mtu).toString();
for (const QString &key : configKey::awgProtocolKeys()) {
const QString value = json.value(key).toString();
if (!value.isEmpty()) {
config.awgParams.insert(key, value);
}
}
config.isObfuscationEnabled = json.value(configKey::isObfuscationEnabled).toBool(false);
return config;
}
@@ -2,6 +2,7 @@
#define WIREGUARDPROTOCOLCONFIG_H
#include <QJsonObject>
#include <QMap>
#include <QString>
#include <QStringList>
#include <optional>
@@ -36,8 +37,10 @@ struct WireGuardClientConfig {
QStringList allowedIps;
QString persistentKeepAlive;
QString mtu;
QMap<QString, QString> awgParams;
bool isObfuscationEnabled = false;
QJsonObject toJson() const;
static WireGuardClientConfig fromJson(const QJsonObject& json);
};
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff. Show more