From 27629d3aa2c244c6ec407a59ddf7d5a1688cf329 Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Thu, 13 Aug 2026 11:55:11 +0300 Subject: [PATCH 1/5] fix: prevent direct traffic leaks during profile transitions --- CMakeLists.txt | 17 + cmake/windows/windows.cmake | 4 +- include/configs/generate.h | 3 + include/database/SettingsRepo.h | 1 + include/sys/KillSwitchController.hpp | 223 ++ .../sys/windows/WindowsWfpKillSwitchBackend.h | 113 ++ include/ui/mainWindow/TestRunner.h | 6 +- include/ui/mainwindow.h | 60 +- include/ui/setting/dialog_vpn_settings.ui | 39 + res/translations/fa_IR.ts | 24 + res/translations/ru_RU.ts | 24 + res/translations/zh_CN.ts | 24 + script/windows_installer.nsi | 22 + src/configs/generate.cpp | 253 ++- src/database/SettingsRepo.cpp | 1 + src/main.cpp | 179 +- src/sys/KillSwitchController.cpp | 745 +++++++ src/sys/Process.cpp | 6 + .../windows/WindowsWfpKillSwitchBackend.cpp | 1799 +++++++++++++++++ src/ui/mainWindow/TestRunner.cpp | 32 +- .../mainwindow_profile_lifecycle.cpp | 177 +- src/ui/mainWindow/mainwindow_setup.cpp | 23 +- src/ui/mainWindow/mainwindow_system.cpp | 56 +- src/ui/mainwindow_killswitch.cpp | 510 +++++ src/ui/setting/dialog_vpn_settings.cpp | 39 +- tests/KillSwitchControllerTest.cpp | 574 ++++++ 26 files changed, 4875 insertions(+), 79 deletions(-) create mode 100644 include/sys/KillSwitchController.hpp create mode 100644 include/sys/windows/WindowsWfpKillSwitchBackend.h create mode 100644 src/sys/KillSwitchController.cpp create mode 100644 src/sys/windows/WindowsWfpKillSwitchBackend.cpp create mode 100644 src/ui/mainwindow_killswitch.cpp create mode 100644 tests/KillSwitchControllerTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 340f60998..a9c3c0adf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,6 +136,8 @@ set(PROJECT_SOURCES include/sys/Process.hpp src/sys/Process.cpp + include/sys/KillSwitchController.hpp + src/sys/KillSwitchController.cpp include/sys/ProcessMetrics.hpp src/sys/ProcessMetrics.cpp @@ -162,6 +164,7 @@ set(PROJECT_SOURCES src/ui/mainWindow/TestRunner.cpp include/ui/mainWindow/MainWindowInternal.h include/ui/mainWindow/TestRunner.h + src/ui/mainwindow_killswitch.cpp include/ui/mainwindow.h include/ui/mainwindow.ui include/ui/widget/StartStopButton.hpp @@ -512,3 +515,17 @@ target_link_libraries(Throne PRIVATE ) qt_finalize_executable(Throne) + +include(CTest) +if (BUILD_TESTING) + add_executable(KillSwitchControllerTest + tests/KillSwitchControllerTest.cpp + include/sys/KillSwitchController.hpp + src/sys/KillSwitchController.cpp + ) + target_include_directories(KillSwitchControllerTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_libraries(KillSwitchControllerTest PRIVATE Qt6::Core) + add_test(NAME KillSwitchControllerTest COMMAND KillSwitchControllerTest) +endif () diff --git a/cmake/windows/windows.cmake b/cmake/windows/windows.cmake index b11625085..a04dac369 100644 --- a/cmake/windows/windows.cmake +++ b/cmake/windows/windows.cmake @@ -1,5 +1,5 @@ -set(PLATFORM_SOURCES 3rdparty/WinCommander.cpp src/sys/windows/guihelper.cpp src/sys/windows/MiniDump.cpp src/sys/windows/eventHandler.cpp src/sys/windows/WinVersion.cpp src/sys/windows/AutoRun.cpp src/sys/windows/UrlScheme.cpp) -set(PLATFORM_LIBRARIES wininet wsock32 ws2_32 user32 rasapi32 iphlpapi ntdll wbemuuid psapi shell32) +set(PLATFORM_SOURCES 3rdparty/WinCommander.cpp src/sys/windows/guihelper.cpp src/sys/windows/MiniDump.cpp src/sys/windows/eventHandler.cpp src/sys/windows/WinVersion.cpp src/sys/windows/AutoRun.cpp src/sys/windows/UrlScheme.cpp src/sys/windows/WindowsWfpKillSwitchBackend.cpp) +set(PLATFORM_LIBRARIES wininet wsock32 ws2_32 user32 rasapi32 iphlpapi ntdll wbemuuid psapi shell32 fwpuclnt uuid) include(cmake/windows/generate_product_version.cmake) generate_product_version( diff --git a/include/configs/generate.h b/include/configs/generate.h index bc13b5119..83af146ab 100644 --- a/include/configs/generate.h +++ b/include/configs/generate.h @@ -32,6 +32,9 @@ namespace Configs QJsonObject coreConfig; QString tunIPv4CIDR; bool isXrayNeeded = false; + // True when a child process or generated outbound can create network + // traffic whose routing and DNS behavior Throne cannot constrain. + bool hasUnverifiableNetworkConfig = false; QJsonObject xrayConfig; // Opaque full configs, one instance each; never merged into xrayConfig. QStringList xrayFullConfigs; diff --git a/include/database/SettingsRepo.h b/include/database/SettingsRepo.h index 346e8742f..42fa0002a 100644 --- a/include/database/SettingsRepo.h +++ b/include/database/SettingsRepo.h @@ -215,6 +215,7 @@ namespace Configs { int vpn_mtu = 1500; bool disable_private_range_bypass = false; bool vpn_ipv6 = false; + bool kill_switch_enabled = false; QString vpn_tun_ipv4_cidr = "172.19.0.1/24"; QString vpn_tun_ipv6_cidr = "fdfe:dcba:9876::1/96"; bool disable_privilege_req = false; diff --git a/include/sys/KillSwitchController.hpp b/include/sys/KillSwitchController.hpp new file mode 100644 index 000000000..a793b7f5f --- /dev/null +++ b/include/sys/KillSwitchController.hpp @@ -0,0 +1,223 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace Configs_sys { + +// The controller deliberately knows nothing about WFP, Windows Firewall, or +// another platform implementation. In particular, it cannot make a blocking +// policy safe by itself: backend operations which fail must leave the OS in the +// previous state or in a stricter state, never in a less restrictive state. +struct KillSwitchResult { + bool ok = false; + QString error; + + [[nodiscard]] static KillSwitchResult Success(); + [[nodiscard]] static KillSwitchResult Failure(QString error); + [[nodiscard]] explicit operator bool() const { return ok; } +}; + +struct KillSwitchTrustedCorePlan { + // The baseline deliberately exempts only these trusted core executables so + // they can establish and carry the tunnel. Current profile formats do not + // expose a complete, static endpoint set (custom cores, Tailscale/DERP and + // domain rotation are examples), so this is an application-scoped permit, + // not an endpoint-scoped one. Keep this list minimal and canonicalized. + QStringList executablePaths; + + [[nodiscard]] bool isValid() const; + friend bool operator==(const KillSwitchTrustedCorePlan &, + const KillSwitchTrustedCorePlan &) = default; +}; + +struct KillSwitchTunInterface { + QString name; + // Platform interface index, not a process-owned handle. A name is retained + // as a diagnostic/fallback identity for platforms without numeric indices. + quint64 interfaceIndex = 0; + bool ipv4 = true; + bool ipv6 = false; + + [[nodiscard]] bool isValid() const; + friend bool operator==(const KillSwitchTunInterface &, + const KillSwitchTunInterface &) = default; +}; + +struct KillSwitchBackendState { + bool baselineActive = false; + bool dynamicCoreActive = false; + bool tunAllowanceActive = false; + + [[nodiscard]] bool anyActive() const { + return baselineActive || dynamicCoreActive || tunAllowanceActive; + } +}; + +struct KillSwitchReconcileResult { + KillSwitchResult result; + KillSwitchBackendState state; +}; + +class KillSwitchBackend { +public: + virtual ~KillSwitchBackend() = default; + + // Discover/reconcile only Throne-owned state. The implementation must not + // touch unrelated firewall configuration. Any stale transient session or + // TUN allow should be made safe before returning its observed state. + [[nodiscard]] virtual KillSwitchReconcileResult reconcile() = 0; + + // Install or verify the persistent, dual-stack fail-closed baseline. + [[nodiscard]] virtual KillSwitchResult ensureBaseline() = 0; + + // Create or replace the trusted application permits needed by the core. + // This is normally installed once at application startup and remains valid + // across profile changes. The persistent baseline remains installed. + [[nodiscard]] virtual KillSwitchResult startDynamicCore( + const KillSwitchTrustedCorePlan &plan) = 0; + + // These calls are idempotent. removeTunAllowance must be safe when the TUN + // has already vanished, and addTunAllowance must never weaken the baseline + // for another interface. + [[nodiscard]] virtual KillSwitchResult removeTunAllowance() = 0; + [[nodiscard]] virtual KillSwitchResult addTunAllowance( + const KillSwitchTunInterface &tunInterface) = 0; + + // Remove only Throne-owned persistent and transient objects. + [[nodiscard]] virtual KillSwitchResult disable() = 0; +}; + +class KillSwitchController { +public: + enum class State { + Disabled, + Connecting, + Connected, + Switching, + Reconnecting, + Stopping, + Disconnected, + Error, + Exiting, + }; + + enum class StartIntent { + Connect, + Switch, + Reconnect, + }; + + struct PrepareResult { + // Callers must not stop a working profile unless this is true. + bool mayTearDownCurrentProfile = false; + quint64 operationId = 0; + State state = State::Disabled; + QString error; + + [[nodiscard]] explicit operator bool() const { + return mayTearDownCurrentProfile; + } + }; + + struct Snapshot { + bool initialized = false; + bool enabled = false; + bool recoveredStaleProtection = false; + State state = State::Disabled; + KillSwitchBackendState backend; + KillSwitchTunInterface allowedTun; + quint64 activeOperationId = 0; + QString lastError; + }; + + struct InitializationResult { + KillSwitchResult result; + bool enabled = false; + bool recoveredStaleProtection = false; + State state = State::Disabled; + + [[nodiscard]] explicit operator bool() const { + return static_cast(result); + } + }; + + explicit KillSwitchController(KillSwitchBackend &backend); + + // Must be called once after settings are loaded. Reconciliation runs even + // when shouldEnable is false. Discovered Throne protection is retained and + // promoted to enabled; only an explicit disable() removes persistent rules. + [[nodiscard]] InitializationResult initialize( + bool shouldEnable, KillSwitchTrustedCorePlan trustedCorePlan); + [[nodiscard]] KillSwitchResult enable(); + [[nodiscard]] KillSwitchResult disable(); + + // The successful return is the prepare-before-stop security boundary: + // baseline -> constrained core session -> remove old TUN allow. No caller + // may tear down the old profile before it receives success. + [[nodiscard]] PrepareResult prepareForProfileStart( + StartIntent intent); + + // operationId rejects late readiness/failure callbacks from an older start. + // System Proxy profiles pass std::nullopt; TUN profiles pass the ready + // interface including the IP families it carries. + [[nodiscard]] KillSwitchResult profileBecameReady( + quint64 operationId, + std::optional tunInterface = std::nullopt); + [[nodiscard]] KillSwitchResult profileStartFailed( + quint64 operationId, QString error); + + [[nodiscard]] PrepareResult prepareForProfileStop(); + [[nodiscard]] KillSwitchResult profileStopped(); + + // Rolls back a prepared stop when the stop RPC failed and the old profile + // is still operational. TUN profiles pass the still-live interface so its + // allowance can be restored; System Proxy profiles pass std::nullopt. A + // failed add is safe to retry and leaves the connection blocked meanwhile. + [[nodiscard]] KillSwitchResult profileStopFailed( + std::optional stillActiveTun = std::nullopt, + QString error = {}); + + // Called after an unplanned daemon/core exit. The baseline is re-verified + // and the obsolete TUN permission is removed; it is never disabled. + [[nodiscard]] KillSwitchResult coreTerminatedUnexpectedly( + bool reconnectPlanned); + + // With the kill switch enabled, normal application exit intentionally keeps + // the persistent baseline for fail-closed crash/exit semantics. + [[nodiscard]] PrepareResult prepareForExit(); + + [[nodiscard]] Snapshot snapshot() const; + [[nodiscard]] bool invariantHolds(QString *reason = nullptr) const; + [[nodiscard]] static QString stateName(State state); + +private: + [[nodiscard]] KillSwitchResult ensureBaselineLocked(); + [[nodiscard]] KillSwitchResult removeTunAllowanceLocked(); + [[nodiscard]] KillSwitchResult backendFailureLocked( + const QString &action, const KillSwitchResult &result); + [[nodiscard]] PrepareResult prepareFailureLocked( + State originalState, const QString &error) const; + [[nodiscard]] bool invariantHoldsLocked(QString *reason) const; + [[nodiscard]] bool startAllowedLocked(StartIntent intent) const; + [[nodiscard]] quint64 nextOperationIdLocked(); + + KillSwitchBackend &backend_; + mutable QMutex mutex_; + bool initialized_ = false; + bool enabled_ = false; + bool recoveredStaleProtection_ = false; + State state_ = State::Disabled; + KillSwitchBackendState backendState_; + KillSwitchTunInterface allowedTun_; + quint64 operationCounter_ = 0; + quint64 activeOperationId_ = 0; + KillSwitchTrustedCorePlan trustedCorePlan_; + QString lastError_; +}; + +} // namespace Configs_sys diff --git a/include/sys/windows/WindowsWfpKillSwitchBackend.h b/include/sys/windows/WindowsWfpKillSwitchBackend.h new file mode 100644 index 000000000..1f1c5d0e0 --- /dev/null +++ b/include/sys/windows/WindowsWfpKillSwitchBackend.h @@ -0,0 +1,113 @@ +#pragma once + +#include "include/sys/KillSwitchController.hpp" + +#include + +#include + +// Windows Filtering Platform implementation of Throne's fail-closed policy. +// +// The providerless baseline is persistent and intentionally independent of +// both Throne and ThroneCore. Its catch-all filters continue blocking new +// direct connections after either process exits, crashes, or BFE reloads. The +// core application and TUN interface exceptions live in a dynamic WFP session +// and are therefore removed automatically if Throne exits unexpectedly. +class WindowsWfpKillSwitchBackend final : public Configs_sys::KillSwitchBackend +{ +public: + enum class BaselineState + { + Absent, + Valid, + StaleOrPartial, + Error, + }; + + struct BaselineStatus + { + BaselineState state = BaselineState::Error; + QString detail; + + [[nodiscard]] bool isValid() const { return state == BaselineState::Valid; } + // Error means the backend could not prove absence. Callers must treat + // it as potentially active and must not authorize an unprotected + // profile transition from it. + [[nodiscard]] bool mayBeActive() const { return state != BaselineState::Absent; } + }; + + WindowsWfpKillSwitchBackend(); + ~WindowsWfpKillSwitchBackend(); + + WindowsWfpKillSwitchBackend(const WindowsWfpKillSwitchBackend &) = delete; + WindowsWfpKillSwitchBackend &operator=(const WindowsWfpKillSwitchBackend &) = delete; + + // Reports whether all deterministic Throne objects are absent, valid, or + // only partially present. No WFP state is changed. + [[nodiscard]] BaselineStatus queryBaseline() const; + + // Atomically migrates a marked provider-associated v1-v3 graph, or + // replaces Throne's exact providerless objects with the current schema. + // Foreign deterministic-GUID collisions are never removed. An active + // dynamic session must be stopped first. + bool reconcileBaseline(QString *error); + + // Idempotently installs the baseline, repairing stale/partial objects when + // necessary. + [[nodiscard]] Configs_sys::KillSwitchReconcileResult reconcile() override; + [[nodiscard]] Configs_sys::KillSwitchResult ensureBaseline() override; + + // Opens a dynamic WFP session and permits only the plan's exact core + // executable. The current implementation intentionally rejects multiple + // executable paths rather than broadening the trusted bootstrap boundary. + [[nodiscard]] Configs_sys::KillSwitchResult startDynamicCore( + const Configs_sys::KillSwitchTrustedCorePlan &plan) override; + + // Lower-level, detailed-error API used by startDynamicCore. Repeating the + // same canonical path is idempotent and preserves any active TUN allowance. + bool startCoreSession(const QString &absoluteCoreExecutablePath, QString *error); + + // Idempotently closes the dynamic session (and thereby all its permits). + bool stopCoreSession(QString *error); + + // Permits outbound flows whose actual departing next-hop interface is + // Throne's standard TUN adapter, and inbound flows that actually arrived + // on that adapter. The adapter must already exist; callers may retry while + // the core is bringing it up. + [[nodiscard]] Configs_sys::KillSwitchResult addTunAllowance( + const Configs_sys::KillSwitchTunInterface &tunInterface) override; + + bool addTunAllowance(QString *error); + + // Variant for tests and future configurable adapter names. IPv6 should + // only be disabled when the corresponding TUN family is deliberately off. + bool addTunAllowanceForInterface(const QString &interfaceAlias, + bool allowIPv4, + bool allowIPv6, + QString *error); + + // Removes the TUN permits in a transaction. This must happen before the + // core destroys the adapter so existing ALE flows are re-authorized while + // the persistent catch-all block is still present. + [[nodiscard]] Configs_sys::KillSwitchResult removeTunAllowance() override; + + // Closes the dynamic session and then atomically removes only Throne's + // deterministic persistent objects. No unrelated WFP/firewall policy is + // enumerated or changed. + [[nodiscard]] Configs_sys::KillSwitchResult disable() override; + + [[nodiscard]] bool coreSessionActive() const; + + static QString tunInterfaceAlias(); + +private: + bool addTunAllowanceForLuidValue(quint64 interfaceLuid, + bool allowIPv4, + bool allowIPv6, + QString *error); + bool removeTunAllowanceImpl(QString *error); + bool disableImpl(QString *error); + + class Impl; + std::unique_ptr impl_; +}; diff --git a/include/ui/mainWindow/TestRunner.h b/include/ui/mainWindow/TestRunner.h index 7d7407c1f..c53a92b48 100644 --- a/include/ui/mainWindow/TestRunner.h +++ b/include/ui/mainWindow/TestRunner.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -84,8 +85,9 @@ class TestRunner { MainWindow* mw_; - // Held for a whole sweep, so it must never double as a per-batch latch. - QMutex session_; + // Held for a whole sweep and released by its worker thread. QSemaphore is + // intentional: unlike QMutex it supports cross-thread release. + QSemaphore session_{1}; // A poll thread is not joined, so a late tick must not drain the next sweep. std::atomic sessionGen_ = 0; std::atomic stopRequested_ = false; diff --git a/include/ui/mainwindow.h b/include/ui/mainwindow.h index 18f4ede84..34a5e643f 100644 --- a/include/ui/mainwindow.h +++ b/include/ui/mainwindow.h @@ -34,6 +34,8 @@ #include #include #include +#include +#include #include "group/GroupSort.hpp" #include "include/global/GuiUtils.hpp" @@ -46,12 +48,16 @@ namespace Configs_sys { class CoreProcess; + class KillSwitchController; } class TrayProfileSelector; class TestRunner; namespace Qv2ray::ui { class SyntaxHighlighter; } +#ifdef Q_OS_WIN +class WindowsWfpKillSwitchBackend; +#endif QT_BEGIN_NAMESPACE namespace Ui { @@ -91,7 +97,7 @@ class MainWindow : public QMainWindow { qint64 GetCorePid(); QString GetRunningConfigName(); - void prepare_exit(); + bool prepare_exit(); void refresh_proxy_list(const QList &ids = {}, bool mayNeedReset = false, RefreshAnchor anchor = RefreshAnchor::KeepPlace); @@ -106,7 +112,10 @@ class MainWindow : public QMainWindow { void profile_start(int _id = -1); - void profile_stop(bool crash = false, bool block = false, bool manual = false); + // Returns false only when the stop was refused or, for a blocking call, + // failed. Asynchronous callers receive true once the protected stop was + // accepted. + bool profile_stop(bool crash = false, bool block = false, bool manual = false); int get_profile_to_start(); @@ -122,7 +131,17 @@ class MainWindow : public QMainWindow { void RegisterHotkey(bool unregister); - bool StopVPNProcess(); + // Force-stops ThroneCore only after fail-closed policy is prepared. When + // clearStartedProfile is true the previous profile is not reconnected. + bool StopVPNProcess(bool clearStartedProfile = false); + + // Applies the OS policy before changing the persisted preference. On + // failure the current protection state is retained and error is populated. + bool setKillSwitchEnabled(bool enable, QString *error = nullptr); + + // Called as soon as QProcess observes a core exit; persistent blocking is + // retained while obsolete TUN permissions are removed. + void killSwitchCoreTerminated(bool reconnectPlanned); void UpdateConnectionList(const QMap& toUpdate, const QMap& toAdd); @@ -231,12 +250,23 @@ private slots: // Shared by the test sweeps and the batch profile scans (remove-invalid). QThreadPool *parallelCoreCallPool = new QThreadPool(this); std::unique_ptr testRunner; + // Held across every TestRunner session. Kill-switch policy changes acquire + // the same gate so a test config cannot be built under one policy and run + // under another. QSemaphore permits acquisition and release on different + // worker/UI threads. + QSemaphore testActivityGate{1}; // Configs_sys::CoreProcess *core_process = nullptr; QMutex coreProcessMutex; // serializes core_process init (DS_cores) vs IPC newConnection (UI) QLocalServer *core_server = nullptr; bool rpc_started = false; qint64 vpn_pid = 0; +#ifdef Q_OS_WIN + std::unique_ptr killSwitchBackend; + std::unique_ptr killSwitchController; + bool killSwitchPreviousProfileUsedTun = false; + bool killSwitchPreviousTunIpv6 = false; +#endif // QTextDocument *qvLogDocument = new QTextDocument(this); // @@ -258,8 +288,8 @@ private slots: // int proxy_last_order = -1; bool select_mode = false; - QMutex mu_starting; - QMutex mu_stopping; + QSemaphore mu_starting{1}; + QSemaphore mu_stopping{1}; QMutex mu_exit; ExitReason exit_reason = ExitReason::None; // @@ -472,6 +502,26 @@ private slots: bool set_system_dns(bool set, bool save_set = true); + bool initializeKillSwitch(); + + [[nodiscard]] bool killSwitchActive() const; + + bool prepareKillSwitchProfileStart(bool switching, quint64 *operationId, + QString *error); + + bool finishKillSwitchProfileStart(quint64 operationId, QString *error); + + void failKillSwitchProfileStart(quint64 operationId, const QString &error, + bool coreInstanceMayBeRunning = false); + + bool prepareKillSwitchProfileStop(QString *error); + + void finishKillSwitchProfileStop(); + + void failKillSwitchProfileStop(const QString &error); + + bool prepareKillSwitchExit(QString *error); + void CheckUpdate(); void setupConnectionList(); diff --git a/include/ui/setting/dialog_vpn_settings.ui b/include/ui/setting/dialog_vpn_settings.ui index b9b34cffc..fca4d2c28 100644 --- a/include/ui/setting/dialog_vpn_settings.ui +++ b/include/ui/setting/dialog_vpn_settings.ui @@ -160,6 +160,44 @@ + + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Prevent direct IPv4 and IPv6 Internet access while the proxy is connecting, switching, disconnected, or unavailable. The block remains active until you disable the kill switch. Local network access is also blocked, except for loopback and DHCP. + + + Kill switch + + + + + + + Block direct Internet traffic whenever the proxy is unavailable, including after Throne exits. Local network access is blocked too. + + + true + + + + + + @@ -228,6 +266,7 @@ vpn_implementation vpn_mtu vpn_ipv6 + kill_switch tun_ipv4_cidr tun_ipv6_cidr restore_default_addresses diff --git a/res/translations/fa_IR.ts b/res/translations/fa_IR.ts index e46c62e72..a4625b9db 100644 --- a/res/translations/fa_IR.ts +++ b/res/translations/fa_IR.ts @@ -1166,6 +1166,30 @@ For more information, see the document "Configuration/DNS". Tun Enable IPv6 فعال کردن IPv6 برای Tun + + Kill switch + کلید قطع اضطراری + + + Block direct Internet traffic whenever the proxy is unavailable, including after Throne exits. Local network access is blocked too. + هرگاه پروکسی در دسترس نیست، از جمله پس از خروج Throne، ترافیک مستقیم اینترنت را مسدود کن. دسترسی به شبکه محلی نیز مسدود می‌شود. + + + Prevent direct IPv4 and IPv6 Internet access while the proxy is connecting, switching, disconnected, or unavailable. The block remains active until you disable the kill switch. Local network access is also blocked, except for loopback and DHCP. + هنگام اتصال، تعویض، قطع شدن یا در دسترس نبودن پروکسی، از دسترسی مستقیم IPv4 و IPv6 به اینترنت جلوگیری کن. این مسدودسازی تا زمانی که کلید قطع اضطراری را غیرفعال کنید فعال می‌ماند. دسترسی به شبکه محلی نیز به‌جز loopback و DHCP مسدود می‌شود. + + + Kill switch change failed + تغییر کلید قطع اضطراری ناموفق بود + + + The requested kill-switch change could not be completed safely. Throne retained the safest state it could verify. + +%1 + تغییر درخواستی کلید قطع اضطراری به‌طور امن تکمیل نشد. Throne امن‌ترین وضعیتی را که توانست تأیید کند حفظ کرد. + +%1 + Troubleshooting عیب یابی diff --git a/res/translations/ru_RU.ts b/res/translations/ru_RU.ts index addc2fd23..5169a7c69 100644 --- a/res/translations/ru_RU.ts +++ b/res/translations/ru_RU.ts @@ -2264,6 +2264,30 @@ For more information, see the document "Configuration/DNS". Tun Enable IPv6 Вкл. IPv6 в TUN + + Kill switch + Аварийная блокировка + + + Block direct Internet traffic whenever the proxy is unavailable, including after Throne exits. Local network access is blocked too. + Блокировать прямой интернет-трафик, когда прокси недоступен, в том числе после выхода из Throne. Доступ к локальной сети также блокируется. + + + Prevent direct IPv4 and IPv6 Internet access while the proxy is connecting, switching, disconnected, or unavailable. The block remains active until you disable the kill switch. Local network access is also blocked, except for loopback and DHCP. + Предотвращать прямой доступ в Интернет по IPv4 и IPv6 во время подключения, переключения, после отключения или при недоступности прокси. Блокировка остаётся активной, пока вы не отключите аварийную блокировку. Доступ к локальной сети также блокируется, кроме loopback и DHCP. + + + Kill switch change failed + Не удалось изменить аварийную блокировку + + + The requested kill-switch change could not be completed safely. Throne retained the safest state it could verify. + +%1 + Не удалось безопасно выполнить запрошенное изменение аварийной блокировки. Throne сохранил самое безопасное состояние, которое удалось подтвердить. + +%1 + Troubleshooting Диагностика diff --git a/res/translations/zh_CN.ts b/res/translations/zh_CN.ts index 155adf81d..da1767ceb 100644 --- a/res/translations/zh_CN.ts +++ b/res/translations/zh_CN.ts @@ -2222,6 +2222,30 @@ For more information, see the document "Configuration/DNS". Tun Enable IPv6 Tun 启用 IPv6 + + Kill switch + 紧急断网 + + + Block direct Internet traffic whenever the proxy is unavailable, including after Throne exits. Local network access is blocked too. + 代理不可用时(包括 Throne 退出后)阻止直接互联网流量,同时也会阻止本地网络访问。 + + + Prevent direct IPv4 and IPv6 Internet access while the proxy is connecting, switching, disconnected, or unavailable. The block remains active until you disable the kill switch. Local network access is also blocked, except for loopback and DHCP. + 在代理正在连接、切换、已断开或不可用时,防止通过 IPv4 和 IPv6 直接访问互联网。此阻止会一直保持,直到您禁用紧急断网。本地网络访问也会被阻止,回环和 DHCP 除外。 + + + Kill switch change failed + 紧急断网更改失败 + + + The requested kill-switch change could not be completed safely. Throne retained the safest state it could verify. + +%1 + 无法安全完成所请求的紧急断网更改。Throne 已保留其能够确认的最安全状态。 + +%1 + Troubleshooting 排除故障 diff --git a/script/windows_installer.nsi b/script/windows_installer.nsi index 2bf39dfae..07dd65ec2 100644 --- a/script/windows_installer.nsi +++ b/script/windows_installer.nsi @@ -348,6 +348,28 @@ FunctionEnd Section "Uninstall" !insertmacro AbortOnRunningApp "$INSTDIR\Throne.exe" + ; Persistent WFP filters deliberately survive a crash or ordinary exit. Ask + ; the application's ownership-scoped recovery command to remove them before + ; deleting the executable, otherwise uninstalling could strand the machine + ; in a fail-closed state with no recovery UI. + IfFileExists "$INSTDIR\Throne.exe" 0 KillSwitchCleanupMissing + ClearErrors + ExecWait '"$INSTDIR\Throne.exe" --disable-kill-switch --quiet' $0 + IfErrors KillSwitchCleanupFailed + ${If} $0 != 0 + Goto KillSwitchCleanupFailed + ${EndIf} + Goto KillSwitchCleanupDone + + KillSwitchCleanupMissing: + MessageBox MB_OK|MB_ICONSTOP "Throne.exe is missing, so the uninstaller cannot safely verify and remove Throne-owned kill-switch rules. Repair or reinstall Throne, disable the kill switch, and uninstall again." + Abort + + KillSwitchCleanupFailed: + MessageBox MB_OK|MB_ICONSTOP "Throne could not remove its kill-switch rules. Uninstall was stopped so the recovery command remains available." + Abort + KillSwitchCleanupDone: + Delete "$SMPROGRAMS\Throne.lnk" Delete "$DESKTOP\Throne.lnk" RMDir "$SMPROGRAMS\Throne" diff --git a/src/configs/generate.cpp b/src/configs/generate.cpp index 1b17811ba..6706b2274 100644 --- a/src/configs/generate.cpp +++ b/src/configs/generate.cpp @@ -65,6 +65,14 @@ namespace Configs { constexpr auto bridgePrefix = "bridge"; } + bool failClosedEnabled() { +#ifdef Q_OS_WIN + return dataManager->settingsRepo->kill_switch_enabled; +#else + return false; +#endif + } + QString hopTag(const QString &prefix, int index) { return prefix + "-" + Int2String(index); } // The sing-box inbound an xray chain re-enters sing-box through, named @@ -128,6 +136,10 @@ namespace Configs { // ---------------------------------------------------------- build state struct DNSDeps { + bool needBootstrapDnsRules = false; + // Only exact proxy/control-plane endpoint hostnames belong here. + // User "direct" domains must never inherit bootstrap DNS access. + QJsonArray bootstrapDomains; bool needDirectDnsRules = false; DomainSelectors direct; bool needProxyDnsRules = false; @@ -343,6 +355,30 @@ namespace Configs { } } + QJsonObject hardenFailClosedRouteRule(QJsonObject rule) { + if (rule.contains("rules")) { + QJsonArray nested; + for (const auto &entry : rule.value("rules").toArray()) + nested.append(hardenFailClosedRouteRule(entry.toObject())); + rule["rules"] = nested; + } + + const auto action = rule.value("action").toString(); + if (action == "resolve") { + rule["server"] = tags::dnsRemote; + } else if (action == "bypass" || rule.value("outbound").toString() == tags::direct) { + rule["action"] = "route"; + rule["outbound"] = tags::proxy; + } + return rule; + } + + QJsonObject hardenFailClosedRuleSet(QJsonObject ruleSet) { + if (ruleSet.value("type").toString() == "remote") + ruleSet["download_detour"] = tags::proxy; + return ruleSet; + } + QString genTunName() { auto tun_name = "throne-tun"; #ifdef Q_OS_MACOS @@ -362,6 +398,20 @@ namespace Configs { return profile->outbound != nullptr && profile->outbound->IsXrayFullConfig(); } + bool hasUnverifiableNetworkBehavior(const std::shared_ptr &profile) { + if (profile == nullptr) return true; + if (profile->type == "custom" || profile->type == "direct" || + profile->type == "tailscale" || profile->type == "autoselector") { + return true; + } + if (profile->type == "socks") { + const auto socks = profile->Socks(); + if (socks != nullptr && socks->version == 4) return true; + } + return profile->outbound != nullptr && + (profile->outbound->IsExtraCore() || profile->outbound->IsXrayFullConfig()); + } + bool usesXrayCore(const std::shared_ptr &profile) { return profile->outbound != nullptr && (profile->outbound->IsXray() || profile->outbound->IsXrayFullConfig()); @@ -528,11 +578,19 @@ namespace Configs { return; } - auto addDirectDomains = [&preReqs](const QStringList &addrs) { - for (const auto &addr : addrs) preReqs.dns.direct.domains << addr; - preReqs.dns.needDirectDnsRules = true; + auto addBootstrapDomains = [&preReqs](const QStringList &addrs) { + for (const auto &addr : addrs) { + if (!preReqs.dns.bootstrapDomains.contains(addr)) + preReqs.dns.bootstrapDomains << addr; + } + if (!addrs.isEmpty()) preReqs.dns.needBootstrapDnsRules = true; }; + // The optional WARP wrapper is synthesized rather than stored in + // ProfilesRepo, so include its endpoint explicitly. + if (settings.enable_warp) + addBootstrapDomains(outboundServerDomains(getWarpProfile())); + // Routing dependencies auto neededOutbounds = routeChain->get_used_outbounds(); auto neededRuleSets = routeChain->get_used_rule_sets(); @@ -570,10 +628,10 @@ namespace Configs { return; } if (usesXrayCore(hopEnt)) ctx.proxyUsesXray = true; - // Collect domains for DNS direct rules + // Collect exact endpoint hostnames for bootstrap DNS. if (auto addrs = getEntDomains({hopID}, ctx.error); !addrs.empty()) { if (!ctx.error.isEmpty()) return; - addDirectDomains(addrs); + addBootstrapDomains(addrs); } } // Map chain ID -> tag of the outermost (first-built) hop @@ -589,7 +647,7 @@ namespace Configs { if (auto entAddrs = getEntDomains({neededEnt->id}, ctx.error); !entAddrs.empty()) { if (!ctx.error.isEmpty()) return; - addDirectDomains(entAddrs); + addBootstrapDomains(entAddrs); } preReqs.routing.outboundMap[item] = hopTag(tags::routeChainPrefix, suffix++); preReqs.routing.routeOutboundGroups << RoutingDeps::RouteOutboundGroup{QList{item}, nullptr}; @@ -616,7 +674,7 @@ namespace Configs { if (auto entAddrs = getEntDomains({ctx.ent->id}, ctx.error); !entAddrs.isEmpty()) { if (!ctx.error.isEmpty()) return; - addDirectDomains(entAddrs); + addBootstrapDomains(entAddrs); } if (auto group = dataManager->groupsRepo->GetGroup(ctx.ent->gid); group != nullptr) { @@ -629,7 +687,7 @@ namespace Configs { } auto addrs = getEntDomains(groupEnts, ctx.error); if (!ctx.error.isEmpty()) return; - if (!addrs.isEmpty()) addDirectDomains(addrs); + addBootstrapDomains(addrs); } // Hijack @@ -678,7 +736,9 @@ namespace Configs { void buildNTPSection(BuildContext &ctx) { const auto &settings = *dataManager->settingsRepo; - if (!settings.enable_ntp) return; + // The trusted core exception must only carry traffic required to + // establish the selected proxy. NTP is optional control traffic. + if (!settings.enable_ntp || failClosedEnabled()) return; ctx.result->coreConfig["ntp"] = QJsonObject{ {"enabled", true}, {"server", settings.ntp_server_address}, @@ -750,10 +810,26 @@ namespace Configs { addr = addr.left(slashIndex); } } - if (addr.contains(":")) { - auto spl = addr.split(":"); - addr = spl[0]; - port = spl[1].toInt(); + if (addr.startsWith("[")) { + const auto closingBracket = addr.indexOf(']'); + if (closingBracket > 0) { + const auto portText = addr.mid(closingBracket + 1); + addr = addr.mid(1, closingBracket - 1); + if (portText.startsWith(':')) { + bool portOk = false; + const int parsedPort = portText.mid(1).toInt(&portOk); + if (portOk) port = parsedPort; + } + } + } else if (QHostAddress(addr).protocol() == QAbstractSocket::UnknownNetworkLayerProtocol && + addr.count(':') == 1) { + const auto separator = addr.lastIndexOf(':'); + bool portOk = false; + const int parsedPort = addr.mid(separator + 1).toInt(&portOk); + if (portOk) { + addr = addr.left(separator); + port = parsedPort; + } } QJsonObject res = { {"type", type}, @@ -794,6 +870,13 @@ namespace Configs { return; } + const bool failClosedDns = failClosedEnabled(); + if (failClosedDns && settings.use_dns_object && useDnsObj) { + ctx.error = QObject::tr( + "Custom DNS objects are not supported while the kill switch is active. " + "Configure Remote DNS as an encrypted resolver with a numeric IP address instead."); + return; + } if (settings.use_dns_object && useDnsObj) { ctx.result->coreConfig["dns"] = QString2QJsonObject(settings.dns_object); return; @@ -805,6 +888,26 @@ namespace Configs { bool independentCache = false; QJsonArray servers; QJsonArray rules; + QJsonObject bootstrapDnsObj; + if (failClosedDns) { + bootstrapDnsObj = buildDnsObj(ctx, settings.remote_dns); + const auto bootstrapType = bootstrapDnsObj.value("type").toString(); + const auto bootstrapAddress = bootstrapDnsObj.value("server").toString(); + const bool encrypted = bootstrapType == "tls" || bootstrapType == "https" || + bootstrapType == "quic" || bootstrapType == "h3"; + const bool numeric = QHostAddress(bootstrapAddress).protocol() != + QAbstractSocket::UnknownNetworkLayerProtocol; + if (!encrypted || !numeric) { + ctx.error = QObject::tr( + "Kill switch bootstrap requires Remote DNS to be an encrypted " + "resolver with a numeric IPv4 or IPv6 address (for example, " + "tls://8.8.8.8 or https://1.1.1.1/dns-query). Local, DHCP, plain " + "DNS, and resolver hostnames are blocked to prevent DNS leaks."); + return; + } + bootstrapDnsObj.remove("detour"); + bootstrapDnsObj.remove("domain_resolver"); + } // remote if (!ctx.forTest) { auto remoteDnsObj = buildDnsObj(ctx, settings.remote_dns); @@ -857,7 +960,7 @@ namespace Configs { } // direct - auto directDnsObj = buildDnsObj(ctx, settings.direct_dns); + auto directDnsObj = failClosedDns ? bootstrapDnsObj : buildDnsObj(ctx, settings.direct_dns); directDnsObj["tag"] = tags::dnsDirect; directDnsObj["domain_resolver"] = tags::dnsLocal; servers.append(directDnsObj); @@ -884,7 +987,7 @@ namespace Configs { // (wired via xray_outbound_dns_address). Those queries bootstrap the // chain itself, so they must never be routed over the proxy — that // deadlocks the chain before it can come up. - if (!ctx.forTest && ctx.proxyUsesXray) { + if (!ctx.forTest && ctx.proxyUsesXray && !failClosedDns) { rules += QJsonObject{ {"inbound", QJsonArray{tags::dnsIn}}, {"action", "route"}, @@ -893,7 +996,7 @@ namespace Configs { }; } - if (!ctx.forTest && !ctx.result->extraCoreData->path.isEmpty()) + if (!ctx.forTest && !failClosedDns && !ctx.result->extraCoreData->path.isEmpty()) { rules += QJsonObject{ {"process_path", extraCoreProcessPaths(ctx.result->extraCoreData->path)}, @@ -903,6 +1006,18 @@ namespace Configs { }; } + // Only exact proxy/control-plane endpoints may use the direct + // encrypted resolver. Ordinary queries, including user "direct" + // routing domains, continue through the established proxy. + if (dns.needBootstrapDnsRules) { + rules += QJsonObject{ + {"domain", dns.bootstrapDomains}, + {"action", "route"}, + {"strategy", settings.direct_dns_strategy}, + {"server", tags::dnsDirect}, + }; + } + // HijackRules if (settings.enable_dns_server && !ctx.forTest) { @@ -961,10 +1076,14 @@ namespace Configs { } if (dns.needDirectDnsRules) { - appendDnsRoutingRules(rules, dns.direct, settings.direct_dns_strategy, tags::dnsDirect); + appendDnsRoutingRules(rules, dns.direct, + failClosedDns ? settings.remote_dns_strategy + : settings.direct_dns_strategy, + failClosedDns ? tags::dnsRemote : tags::dnsDirect); } - const bool useDirectFinalDNS = settings.dns_final_out == tags::direct; + const bool useDirectFinalDNS = ctx.forTest || + (!failClosedDns && settings.dns_final_out == tags::direct); if (dns.needProxyDnsRules && useDirectFinalDNS) { appendDnsRoutingRules(rules, dns.proxy, settings.remote_dns_strategy, tags::dnsRemote); @@ -979,7 +1098,7 @@ namespace Configs { // Local auto dnsLocalAddress = settings.core_box_underlying_dns.isEmpty() ? "local" : settings.core_box_underlying_dns; - auto dnsLocalObj = buildDnsObj(ctx, dnsLocalAddress); + auto dnsLocalObj = failClosedDns ? bootstrapDnsObj : buildDnsObj(ctx, dnsLocalAddress); dnsLocalObj["tag"] = tags::dnsLocal; servers += dnsLocalObj; @@ -1030,7 +1149,14 @@ namespace Configs { inboundObj["auto_route"] = true; inboundObj["mtu"] = settings.vpn_mtu; inboundObj["stack"] = settings.vpn_implementation; +#ifdef Q_OS_WIN + // Persistent WFP policy owns fail-closed enforcement. sing-tun's + // dynamic strict-route session disappears with this adapter and + // would overlap the independently managed policy. + inboundObj["strict_route"] = settings.vpn_strict_route && !failClosedEnabled(); +#else inboundObj["strict_route"] = settings.vpn_strict_route; +#endif if (ctx.os == Linux && settings.vpn_auto_redirect) inboundObj["auto_redirect"] = true; const auto tunIPv4CIDR = settings.vpn_tun_ipv4_cidr; const auto tunIPv6CIDR = settings.vpn_tun_ipv6_cidr; @@ -1161,6 +1287,12 @@ namespace Configs { error = "Null proxy in chain, you may want to check your configs"; return; } + if (ent->outbound == nullptr) { + error = "Proxy in chain has no outbound configuration"; + return; + } + if (hasUnverifiableNetworkBehavior(ent)) + ctx.result->hasUnverifiableNetworkConfig = true; if (!inXray && ent->outbound->IsXray()) { ctx.singToXrayTransitioned = true; scan.coreTransitions++; @@ -1682,6 +1814,8 @@ namespace Configs { void buildOutboundsSection(BuildContext &ctx) { // First, our own ent + if (hasUnverifiableNetworkBehavior(ctx.ent)) + ctx.result->hasUnverifiableNetworkConfig = true; auto group = dataManager->groupsRepo->GetGroup(ctx.ent->gid); if (group == nullptr) { @@ -1760,11 +1894,15 @@ namespace Configs { } ctx.result->coreConfig["inbounds"] = inboundArr; - // Add the direct outbound - ctx.outbounds.append(QJsonObject{ - {"type", "direct"}, - {"tag", tags::direct} - }); + // In fail-closed mode no data-plane rule is allowed to select a + // direct outbound. Omitting it also prevents a Clash/API mode + // change from turning the trusted core exemption into a bypass. + if (!failClosedEnabled()) { + ctx.outbounds.append(QJsonObject{ + {"type", "direct"}, + {"tag", tags::direct}, + }); + } ctx.result->coreConfig["endpoints"] = ctx.endpoints; ctx.result->coreConfig["outbounds"] = ctx.outbounds; @@ -1776,32 +1914,38 @@ namespace Configs { QJsonArray ruleSetArray; for (const auto &item: ctx.prerequisites.routing.neededRuleSets) { if (auto url = QUrl(item); url.isValid() && url.fileName().contains(".srs")) { - ruleSetArray += QJsonObject{ + QJsonObject ruleSet{ {"type", "remote"}, {"tag", get_rule_set_name(item)}, {"format", "binary"}, {"url", item}, }; + if (failClosedEnabled()) ruleSet = hardenFailClosedRuleSet(ruleSet); + ruleSetArray += ruleSet; } else if (auto url = ruleSetUrl(item.toStdString()); !url.empty()) { - ruleSetArray += QJsonObject{ + QJsonObject ruleSet{ {"type", "remote"}, {"tag", item}, {"format", "binary"}, {"url", get_jsdelivr_link(QString::fromUtf8(url.data(), url.size()))}, }; + if (failClosedEnabled()) ruleSet = hardenFailClosedRuleSet(ruleSet); + ruleSetArray += ruleSet; } } // add block if (dataManager->settingsRepo->adblock_enable) { - ruleSetArray += QJsonObject{ + QJsonObject ruleSet{ {"type", "remote"}, {"tag", tags::adblockRuleSet}, {"format", "binary"}, {"url", get_jsdelivr_link("https://raw.githubusercontent.com/217heidai/adblockfilters/main/rules/adblocksingbox.srs")}, }; + if (failClosedEnabled()) ruleSet = hardenFailClosedRuleSet(ruleSet); + ruleSetArray += ruleSet; } return ruleSetArray; } @@ -1825,6 +1969,12 @@ namespace Configs { } rawRouteObj = RouteProfile::TranslateRawOutbounds(rawRouteObj, routeDeps.outboundMap); if (routeChain->preventModifications) { + if (failClosedEnabled()) { + ctx.error = QObject::tr( + "Raw routing profiles that prevent modifications are not supported " + "while the kill switch is active because direct fallback cannot be removed safely."); + return; + } ctx.result->coreConfig["route"] = rawRouteObj; return; } @@ -1846,6 +1996,7 @@ namespace Configs { {"action", "resolve"}, {"strategy", settings.resolve_domain_strategy}, }; + if (failClosedEnabled()) injected.resolve["server"] = tags::dnsRemote; } injected.dnsHijack = QJsonObject{ {"protocol", "dns"}, @@ -1868,9 +2019,15 @@ namespace Configs { auto profileRules = routeChain->isRaw ? rawRouteObj.value("rules").toArray() : routeChain->get_route_rules(false, routeDeps.outboundMap); + if (failClosedEnabled()) { + QJsonArray hardened; + for (const auto &entry : profileRules) + hardened.append(hardenFailClosedRouteRule(entry.toObject())); + profileRules = hardened; + } QJsonObject extraCoreDirect; - if (!ctx.result->extraCoreData->path.isEmpty()) + if (!failClosedEnabled() && !ctx.result->extraCoreData->path.isEmpty()) { extraCoreDirect = QJsonObject{ {"action", "route"}, @@ -1897,7 +2054,10 @@ namespace Configs { // raw profiles bring their own rule_set definitions; merge them after ours. if (routeChain->isRaw) { - for (const auto& rs : rawRouteObj.value("rule_set").toArray()) ruleSetArray.append(rs); + for (const auto& rs : rawRouteObj.value("rule_set").toArray()) { + if (failClosedEnabled()) ruleSetArray.append(hardenFailClosedRuleSet(rs.toObject())); + else ruleSetArray.append(rs); + } } // apply @@ -1920,7 +2080,9 @@ namespace Configs { QJsonObject route = routeChain->isRaw ? rawRouteObj : QJsonObject{}; route["rules"] = routeRules; route["rule_set"] = ruleSetArray; - if (routeChain->isRaw) { + if (failClosedEnabled()) { + route["final"] = tags::proxy; + } else if (routeChain->isRaw) { if (!route.contains("final")) route["final"] = tags::proxy; // user's final, else a safe default } else if (defOut == blockID) { route["final"] = tags::direct; @@ -1930,7 +2092,7 @@ namespace Configs { route["final"] = outboundIDToString(defOut); } if (settings.enable_stats && !route.contains("find_process")) route["find_process"] = true; - if (!route.contains("default_domain_resolver")) + if (failClosedEnabled() || !route.contains("default_domain_resolver")) route["default_domain_resolver"] = QJsonObject{ {"server", tags::dnsDirect}, {"strategy", settings.default_domain_strategy}}; @@ -2063,6 +2225,14 @@ namespace Configs { } if (custom->type == Custom::CustomFullConfig) { + res->hasUnverifiableNetworkConfig = true; + if (failClosedEnabled()) { + res->error = QObject::tr( + "Custom full configurations are not supported while the kill " + "switch is active because their direct-routing and DNS behavior " + "cannot be verified safely."); + return res; + } res->coreConfig = custom->Build().object; return res; } @@ -2092,6 +2262,13 @@ namespace Configs { buildOutboundsSection(ctx); if (failed()) return ctx.result; + if (failClosedEnabled() && ctx.result->hasUnverifiableNetworkConfig) { + ctx.error = QObject::tr( + "Direct, SOCKS4, Tailscale, ExtraCore, auto-selector, and custom " + "profiles are not supported while the kill switch is active because " + "their direct-routing or destination-DNS behavior cannot be constrained safely."); + if (failed()) return ctx.result; + } buildRouteSection(ctx); if (failed()) return ctx.result; @@ -2221,12 +2398,22 @@ namespace Configs { std::shared_ptr BuildTestConfig(const QList > &profiles) { auto res = std::make_shared(); + if (failClosedEnabled()) { + // A shared test box has no single established tunnel through which + // every candidate's destination DNS can be constrained safely. + res->error = QObject::tr( + "Profile connectivity tests are disabled while the kill switch is " + "active because their destination DNS cannot yet be constrained to " + "the individual tested tunnel safely."); + return res; + } BuildContext ctx; ctx.forTest = true; QList entIDs; for (const auto& proxy : profiles) entIDs << proxy->id; - ctx.prerequisites.dns.direct.domains = QListStr2QJsonArray(getEntDomains(entIDs, ctx.error)); - if (!ctx.prerequisites.dns.direct.domains.isEmpty()) ctx.prerequisites.dns.needDirectDnsRules = true; + ctx.prerequisites.dns.bootstrapDomains = QListStr2QJsonArray(getEntDomains(entIDs, ctx.error)); + if (!ctx.prerequisites.dns.bootstrapDomains.isEmpty()) + ctx.prerequisites.dns.needBootstrapDnsRules = true; buildDNSSection(ctx, false); if (!ctx.error.isEmpty()) { diff --git a/src/database/SettingsRepo.cpp b/src/database/SettingsRepo.cpp index 9af9406b7..2f5edf418 100644 --- a/src/database/SettingsRepo.cpp +++ b/src/database/SettingsRepo.cpp @@ -31,6 +31,7 @@ namespace Configs { {"vpn_ipv6", &vpn_ipv6}, {"vpn_strict_route", &vpn_strict_route}, {"vpn_auto_redirect", &vpn_auto_redirect}, + {"kill_switch_enabled", &kill_switch_enabled}, {"sub_clear", &sub_clear}, {"sub_show_change_popup", &sub_show_change_popup}, {"net_insecure", &net_insecure}, diff --git a/src/main.cpp b/src/main.cpp index 99d978ec8..f94341c98 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,7 +29,10 @@ #include "include/sys/windows/MiniDump.h" #include "include/sys/windows/eventHandler.h" #include "include/sys/windows/WinVersion.h" +#include "include/sys/windows/WindowsWfpKillSwitchBackend.h" +#include "include/sys/windows/guihelper.h" #include +#include #endif #ifdef Q_OS_LINUX #include @@ -66,8 +69,9 @@ class MacOpenEventFilter : public QObject { #endif void signal_handler(int signum) { - GetMainWindow()->prepare_exit(); - qApp->quit(); + if (GetMainWindow()->prepare_exit()) { + qApp->quit(); + } } QTranslator* trans = nullptr; @@ -217,6 +221,93 @@ int main(int argc, char* argv[]) { QFile::remove("updater.old"); } +#ifdef Q_OS_WIN + // An elevated replacement launched while enabling the kill switch waits + // here, before opening the settings database or checking the singleton. + // This avoids racing the original process's final settings save and local + // server teardown while the persistent WFP baseline is already active. + const int waitForProcessIndex = arguments.indexOf("--wait-for-process"); + if (waitForProcessIndex >= 0) { + if (waitForProcessIndex + 1 >= arguments.size()) { + QMessageBox::critical(nullptr, "Throne kill switch", + "Missing process ID for the protected restart."); + return 1; + } + bool processIdOk = false; + const DWORD processId = + arguments.at(waitForProcessIndex + 1).toULong(&processIdOk); + if (!processIdOk || processId == 0) { + QMessageBox::critical(nullptr, "Throne kill switch", + "Invalid process ID for the protected restart."); + return 1; + } + const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, processId); + if (process != nullptr) { + const DWORD waitResult = WaitForSingleObject(process, 60000); + CloseHandle(process); + if (waitResult != WAIT_OBJECT_0) { + QMessageBox::critical( + nullptr, "Throne kill switch", + "The previous Throne instance did not exit in time. Fail-closed " + "rules remain active; start Throne as Administrator to recover."); + return 1; + } + } else if (GetLastError() != ERROR_INVALID_PARAMETER) { + // ERROR_INVALID_PARAMETER means the original PID has already + // disappeared. Any other error leaves singleton/settings ordering + // unknown, so keep the persistent block and require recovery. + QMessageBox::critical( + nullptr, "Throne kill switch", + "The previous Throne instance could not be monitored. Fail-closed " + "rules remain active; start Throne as Administrator to recover."); + return 1; + } + arguments.removeAt(waitForProcessIndex + 1); + arguments.removeAt(waitForProcessIndex); + } + + const bool earlyDisableKillSwitch = + arguments.contains("--disable-kill-switch"); + const bool earlyPrepareKillSwitch = + arguments.contains("--prepare-kill-switch"); + if (earlyDisableKillSwitch && earlyPrepareKillSwitch) { + QMessageBox::critical(nullptr, "Throne kill switch", + "Conflicting kill-switch maintenance options."); + return 2; + } + + // Connectivity recovery must not depend on a healthy settings database. + // Remove only the marked Throne WFP objects first; a non-quiet invocation + // continues below and also persists the disabled preference if the DB can + // be opened. Quiet callers are the uninstaller or a parent Throne process, + // which either deletes the DB or performs that save itself. + if (earlyDisableKillSwitch) { + if (!Windows_IsInAdmin()) { + auto elevatedArguments = arguments; + elevatedArguments.removeFirst(); + const uint result = WinCommander::runProcessElevated( + QApplication::applicationFilePath(), elevatedArguments, + QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + return result == 0 ? 0 : 1; + } + WindowsWfpKillSwitchBackend recoveryBackend; + const auto recoveryResult = recoveryBackend.disable(); + if (!recoveryResult) { + qCritical() << "Failed to remove the Throne kill switch:" + << recoveryResult.error; + if (!arguments.contains("--quiet")) { + QMessageBox::critical(nullptr, "Throne kill-switch recovery", + "Failed to remove Throne's kill-switch rules.\n\n" + + recoveryResult.error); + } + return 1; + } + if (arguments.contains("--quiet")) { + qInfo() << "Throne kill-switch rules were removed."; + return 0; + } + } +#endif // dirs & clean auto wd = QDir(QApplication::applicationDirPath()); bool useAppdata = false; @@ -275,6 +366,90 @@ int main(int argc, char* argv[]) { Configs::dataManager->settingsRepo->flag_debug = true; #endif +#ifdef Q_OS_WIN + // Recovery runs before the core, instance server, or any network client. + // It removes only Throne's deterministic WFP objects and is also suitable + // for an unattended uninstaller invocation. + const bool disableKillSwitch = arguments.contains("--disable-kill-switch"); + const bool prepareKillSwitch = arguments.contains("--prepare-kill-switch"); + if (disableKillSwitch || prepareKillSwitch) { + if (disableKillSwitch && prepareKillSwitch) { + qCritical() << "Conflicting kill-switch maintenance options."; + return 2; + } + if (!Configs::IsAdmin()) { + auto elevatedArguments = arguments; + elevatedArguments.removeFirst(); + const uint result = WinCommander::runProcessElevated( + QApplication::applicationFilePath(), elevatedArguments, + QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + return result == 0 ? 0 : 1; + } + + WindowsWfpKillSwitchBackend recoveryBackend; + // A non-quiet disable was already performed before DB initialization, + // so only its persisted preference remains to be updated here. + const auto maintenanceResult = disableKillSwitch + ? Configs_sys::KillSwitchResult::Success() + : recoveryBackend.ensureBaseline(); + if (!maintenanceResult) { + qCritical() << "Failed to update the Throne kill switch:" + << maintenanceResult.error; + if (!arguments.contains("--quiet")) { + QMessageBox::critical(nullptr, "Throne kill-switch recovery", + "Failed to update Throne's kill-switch rules.\n\n" + + maintenanceResult.error); + } + return 1; + } + // Persist only after the requested WFP transaction was committed. + Configs::dataManager->settingsRepo->kill_switch_enabled = prepareKillSwitch; + Configs::dataManager->settingsRepo->Save(); + qInfo() << (disableKillSwitch + ? "Throne kill-switch rules were removed." + : "Throne kill-switch rules were installed."); + if (!arguments.contains("--quiet")) { + QMessageBox::information(nullptr, "Throne kill-switch recovery", + disableKillSwitch + ? "The Throne kill switch was disabled and its rules were removed." + : "The Throne kill switch was installed. Start Throne as Administrator to connect."); + } + return 0; + } + + // Persistent rules intentionally outlive both processes. Detect them even + // when the settings database was lost or a save was interrupted, and gain + // the rights required to retain/reconcile them before ThroneCore starts. + WindowsWfpKillSwitchBackend startupProbe; + const auto baselineStatus = startupProbe.queryBaseline(); + const bool persistentProtectionPresent = + baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::Valid || + baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::StaleOrPartial || + baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::Error; + const bool protectionRequested = + Configs::dataManager->settingsRepo->kill_switch_enabled || + persistentProtectionPresent; + + if (protectionRequested && arguments.contains("-many")) { + QMessageBox::critical(nullptr, "Throne kill switch", + "Multiple Throne instances are not supported while the kill switch is active."); + return 1; + } + if (protectionRequested && !Configs::IsAdmin()) { + auto elevatedArguments = arguments; + elevatedArguments.removeFirst(); + const uint result = WinCommander::runProcessElevated( + QApplication::applicationFilePath(), elevatedArguments, + QApplication::applicationDirPath(), WinCommander::SW_NORMAL, false); + if (result == static_cast(-1)) { + QMessageBox::critical(nullptr, "Throne kill switch", + "Administrator permission is required to restore fail-closed protection."); + return 1; + } + return 0; + } +#endif + #ifdef Q_OS_LINUX QApplication::addLibraryPath(QApplication::applicationDirPath() + "/usr/plugins"); #endif diff --git a/src/sys/KillSwitchController.cpp b/src/sys/KillSwitchController.cpp new file mode 100644 index 000000000..a709c09be --- /dev/null +++ b/src/sys/KillSwitchController.cpp @@ -0,0 +1,745 @@ +#include "include/sys/KillSwitchController.hpp" + +#include + +#include + +namespace Configs_sys { + +namespace { + +QString normalizedError(const QString &action, const KillSwitchResult &result) { + if (!result.error.trimmed().isEmpty()) { + return action + QStringLiteral(": ") + result.error.trimmed(); + } + return action + QStringLiteral(" failed"); +} + +} // namespace + +KillSwitchResult KillSwitchResult::Success() { + return {true, {}}; +} + +KillSwitchResult KillSwitchResult::Failure(QString error) { + return {false, std::move(error)}; +} + +bool KillSwitchTrustedCorePlan::isValid() const { + if (executablePaths.isEmpty()) { + return false; + } + for (const auto &path : executablePaths) { + if (path.trimmed().isEmpty()) { + return false; + } + } + return true; +} + +bool KillSwitchTunInterface::isValid() const { + return (interfaceIndex != 0 || !name.trimmed().isEmpty()) && (ipv4 || ipv6); +} + +KillSwitchController::KillSwitchController(KillSwitchBackend &backend) + : backend_(backend) { +} + +KillSwitchController::InitializationResult KillSwitchController::initialize( + const bool shouldEnable, KillSwitchTrustedCorePlan trustedCorePlan) { + QMutexLocker locker(&mutex_); + if (initialized_) { + return { + KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is already initialized")), + enabled_, + recoveredStaleProtection_, + state_, + }; + } + + initialized_ = true; + trustedCorePlan_ = std::move(trustedCorePlan); + + const auto reconciled = backend_.reconcile(); + backendState_ = reconciled.state; + if (!reconciled.result) { + lastError_ = normalizedError(QStringLiteral("reconcile kill switch state"), + reconciled.result); + state_ = State::Error; + // Unknown OS state is never assumed safe to remove or bypass. Latch + // protection on even if the failed query could not report any objects; + // every later transition must first prove/install the baseline. + enabled_ = true; + recoveredStaleProtection_ = !shouldEnable && backendState_.anyActive(); + return {KillSwitchResult::Failure(lastError_), enabled_, + recoveredStaleProtection_, state_}; + } + + const bool discoveredProtection = backendState_.anyActive(); + recoveredStaleProtection_ = !shouldEnable && discoveredProtection; + enabled_ = shouldEnable || discoveredProtection; + if (!enabled_) { + // An explicit disable is the only path which removes persistent state. + // Startup reconciliation must never silently discard crash protection. + enabled_ = false; + state_ = State::Disabled; + lastError_.clear(); + return {KillSwitchResult::Success(), false, false, state_}; + } + + if (!trustedCorePlan_.isValid()) { + lastError_ = QStringLiteral("No trusted core executable was provided"); + state_ = State::Error; + return {KillSwitchResult::Failure(lastError_), enabled_, + recoveredStaleProtection_, state_}; + } + + auto result = ensureBaselineLocked(); + if (!result) { + state_ = State::Error; + return {result, enabled_, recoveredStaleProtection_, state_}; + } + result = removeTunAllowanceLocked(); + if (!result) { + state_ = State::Error; + return {result, enabled_, recoveredStaleProtection_, state_}; + } + result = backend_.startDynamicCore(trustedCorePlan_); + if (!result) { + result = backendFailureLocked(QStringLiteral("start trusted core session"), + result); + state_ = State::Error; + return {result, enabled_, recoveredStaleProtection_, state_}; + } + backendState_.dynamicCoreActive = true; + + activeOperationId_ = 0; + state_ = State::Disconnected; + lastError_.clear(); + return {KillSwitchResult::Success(), enabled_, recoveredStaleProtection_, + state_}; +} + +KillSwitchResult KillSwitchController::enable() { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (state_ == State::Exiting) { + return KillSwitchResult::Failure( + QStringLiteral("Cannot enable kill switch while exiting")); + } + + const bool wasEnabled = enabled_; + enabled_ = true; + if (!trustedCorePlan_.isValid()) { + lastError_ = QStringLiteral("No trusted core executable was provided"); + state_ = State::Error; + return KillSwitchResult::Failure(lastError_); + } + auto result = ensureBaselineLocked(); + if (!result) { + state_ = State::Error; + return result; + } + + if (wasEnabled && state_ != State::Error && state_ != State::Disabled && + backendState_.dynamicCoreActive) { + lastError_.clear(); + return KillSwitchResult::Success(); + } + + // Enabling from Disabled/Error establishes the fail-closed disconnected + // state. Never inherit a stale per-interface permit. + result = removeTunAllowanceLocked(); + if (!result) { + state_ = State::Error; + return result; + } + result = backend_.startDynamicCore(trustedCorePlan_); + if (!result) { + result = backendFailureLocked(QStringLiteral("start trusted core session"), + result); + state_ = State::Error; + return result; + } + backendState_.dynamicCoreActive = true; + activeOperationId_ = 0; + state_ = State::Disconnected; + lastError_.clear(); + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::disable() { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + + const auto result = backend_.disable(); + if (!result) { + // A backend may have closed its dynamic session before an ownership or + // persistent-policy removal failed. Refresh the cached state so a + // later retry never mistakes a vanished core/TUN permit for an active + // one. Reconciliation is conservative: unknown OS state remains + // logically enabled and all transitions must re-prove the baseline. + const auto reconciled = backend_.reconcile(); + backendState_ = reconciled.state; + allowedTun_ = {}; + activeOperationId_ = 0; + enabled_ = true; + lastError_ = normalizedError(QStringLiteral("disable kill switch"), result); + if (!reconciled.result) { + lastError_ += QStringLiteral("; ") + + normalizedError(QStringLiteral("reconcile after failed disable"), + reconciled.result); + } + state_ = State::Error; + return KillSwitchResult::Failure(lastError_); + } + + backendState_ = {}; + allowedTun_ = {}; + activeOperationId_ = 0; + enabled_ = false; + recoveredStaleProtection_ = false; + state_ = State::Disabled; + lastError_.clear(); + return KillSwitchResult::Success(); +} + +KillSwitchController::PrepareResult KillSwitchController::prepareForProfileStart( + const StartIntent intent) { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return {false, 0, state_, + QStringLiteral("Kill switch controller is not initialized")}; + } + if (state_ == State::Exiting) { + return {false, 0, state_, + QStringLiteral("Cannot start a profile while exiting")}; + } + if (!enabled_) { + return {true, 0, State::Disabled, {}}; + } + if (activeOperationId_ != 0) { + return {false, activeOperationId_, state_, + QStringLiteral("Another protected profile transition is active")}; + } + if (!startAllowedLocked(intent)) { + return {false, 0, state_, + QStringLiteral("Cannot begin %1 from kill switch state %2") + .arg(intent == StartIntent::Connect + ? QStringLiteral("connect") + : intent == StartIntent::Switch + ? QStringLiteral("switch") + : QStringLiteral("reconnect"), + stateName(state_))}; + } + + const State originalState = state_; + auto result = ensureBaselineLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + + if (!backendState_.dynamicCoreActive) { + // A reconciled or restarted backend can lose its dynamic session. It is + // safe to reconstruct it here because the old profile remains up until + // this entire preparation succeeds. + result = backend_.startDynamicCore(trustedCorePlan_); + if (!result) { + const auto failure = backendFailureLocked( + QStringLiteral("start trusted core session"), result); + return prepareFailureLocked(originalState, failure.error); + } + backendState_.dynamicCoreActive = true; + } + + // This is deliberately last. Until all prerequisite protection is ready, + // the old TUN permission and working connection are left untouched. + result = removeTunAllowanceLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + + activeOperationId_ = nextOperationIdLocked(); + switch (intent) { + case StartIntent::Connect: + state_ = State::Connecting; + break; + case StartIntent::Switch: + state_ = State::Switching; + break; + case StartIntent::Reconnect: + state_ = State::Reconnecting; + break; + } + lastError_.clear(); + return {true, activeOperationId_, state_, {}}; +} + +KillSwitchResult KillSwitchController::profileBecameReady( + const quint64 operationId, + std::optional tunInterface) { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (!enabled_) { + return KillSwitchResult::Success(); + } + if (operationId == 0 || operationId != activeOperationId_) { + return KillSwitchResult::Failure( + QStringLiteral("Ignoring stale profile-ready notification")); + } + if (state_ != State::Connecting && state_ != State::Switching && + state_ != State::Reconnecting) { + return KillSwitchResult::Failure( + QStringLiteral("Profile cannot become ready from kill switch state %1") + .arg(stateName(state_))); + } + if (tunInterface.has_value() && !tunInterface->isValid()) { + return KillSwitchResult::Failure( + QStringLiteral("Cannot allow an unidentified TUN interface")); + } + + if (tunInterface.has_value()) { + const auto result = backend_.addTunAllowance(*tunInterface); + if (!result) { + // Retain the operation and transition state so readiness can be + // retried after a transient interface-enumeration/backend failure. + // The absent allowance keeps traffic fail-closed while retrying. + lastError_ = normalizedError( + QStringLiteral("allow ready TUN interface"), result); + return KillSwitchResult::Failure(lastError_); + } + + backendState_.tunAllowanceActive = true; + allowedTun_ = *tunInterface; + } else { + backendState_.tunAllowanceActive = false; + allowedTun_ = {}; + } + activeOperationId_ = 0; + state_ = State::Connected; + lastError_.clear(); + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::profileStartFailed( + const quint64 operationId, QString error) { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (!enabled_) { + return KillSwitchResult::Success(); + } + if (operationId == 0 || operationId != activeOperationId_) { + return KillSwitchResult::Failure( + QStringLiteral("Ignoring stale profile-failure notification")); + } + + auto protectionResult = removeTunAllowanceLocked(); + activeOperationId_ = 0; + state_ = State::Error; + + error = error.trimmed(); + if (error.isEmpty()) { + error = QStringLiteral("Profile failed to start"); + } + if (!protectionResult) { + error += QStringLiteral("; ") + protectionResult.error; + } + lastError_ = error; + + // The profile failed, but this result reports whether the controller handled + // that failure safely. Direct access remains blocked in either case; a + // backend failure is returned so the caller can surface the degraded state. + if (!protectionResult) { + return KillSwitchResult::Failure(lastError_); + } + return KillSwitchResult::Success(); +} + +KillSwitchController::PrepareResult KillSwitchController::prepareForProfileStop() { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return {false, 0, state_, + QStringLiteral("Kill switch controller is not initialized")}; + } + if (state_ == State::Exiting) { + // prepareForExit already removed the allowance. The ordinary profile + // stop path can now run without changing the Exiting state. + return {true, 0, state_, {}}; + } + if (!enabled_) { + return {true, 0, State::Disabled, {}}; + } + + const State originalState = state_; + auto result = ensureBaselineLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + result = removeTunAllowanceLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + + if ((state_ == State::Switching || state_ == State::Reconnecting) && + activeOperationId_ != 0) { + // profile_start() currently invokes the ordinary profile_stop() path + // between preparation and the next RPC Start. Preserve the enclosing + // operation so its eventual ready/failure callback remains valid. + lastError_.clear(); + return {true, activeOperationId_, state_, {}}; + } + + // A standalone stop invalidates any in-flight Start callback before the + // core is torn down. + activeOperationId_ = 0; + state_ = State::Stopping; + lastError_.clear(); + return {true, 0, state_, {}}; +} + +KillSwitchResult KillSwitchController::profileStopped() { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (!enabled_) { + return KillSwitchResult::Success(); + } + if (state_ == State::Exiting) { + return KillSwitchResult::Success(); + } + if ((state_ == State::Switching || state_ == State::Reconnecting) && + activeOperationId_ != 0) { + return KillSwitchResult::Success(); + } + if (state_ != State::Stopping) { + return KillSwitchResult::Failure( + QStringLiteral("Profile stopped without a protected stop preparation")); + } + + state_ = State::Disconnected; + lastError_.clear(); + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::profileStopFailed( + std::optional stillActiveTun, QString error) { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (!enabled_) { + return KillSwitchResult::Success(); + } + if (state_ != State::Stopping && + !((state_ == State::Switching || state_ == State::Reconnecting) && + activeOperationId_ != 0)) { + return KillSwitchResult::Failure( + QStringLiteral("Profile stop failed without a protected stop preparation")); + } + if (stillActiveTun.has_value() && !stillActiveTun->isValid()) { + return KillSwitchResult::Failure( + QStringLiteral("Cannot restore an unidentified TUN interface")); + } + + if (stillActiveTun.has_value()) { + const auto result = backend_.addTunAllowance(*stillActiveTun); + if (!result) { + // Retain Stopping/Switching and the absent allowance. This is + // deliberately retryable and remains fail-closed until it succeeds. + lastError_ = normalizedError( + QStringLiteral("restore TUN allowance after failed stop"), result); + return KillSwitchResult::Failure(lastError_); + } + backendState_.tunAllowanceActive = true; + allowedTun_ = *stillActiveTun; + } else { + backendState_.tunAllowanceActive = false; + allowedTun_ = {}; + } + + activeOperationId_ = 0; + state_ = State::Connected; + error = error.trimmed(); + lastError_ = error.isEmpty() ? QStringLiteral("Profile failed to stop") + : std::move(error); + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::coreTerminatedUnexpectedly( + const bool reconnectPlanned) { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return KillSwitchResult::Failure( + QStringLiteral("Kill switch controller is not initialized")); + } + if (!enabled_) { + return KillSwitchResult::Success(); + } + + const bool wasExiting = state_ == State::Exiting; + const auto baselineResult = ensureBaselineLocked(); + const auto baselineError = baselineResult.error; + const auto removalResult = removeTunAllowanceLocked(); + activeOperationId_ = 0; + if (!baselineResult || !removalResult) { + state_ = State::Error; + if (!baselineResult && !removalResult) { + lastError_ = baselineError + QStringLiteral("; ") + removalResult.error; + } else if (!baselineResult) { + lastError_ = baselineError; + } + return KillSwitchResult::Failure(lastError_); + } + + if (wasExiting) { + state_ = State::Exiting; + lastError_.clear(); + } else if (reconnectPlanned) { + state_ = State::Reconnecting; + lastError_.clear(); + } else { + state_ = State::Error; + lastError_ = QStringLiteral("Core terminated unexpectedly"); + } + return KillSwitchResult::Success(); +} + +KillSwitchController::PrepareResult KillSwitchController::prepareForExit() { + QMutexLocker locker(&mutex_); + if (!initialized_) { + return {false, 0, state_, + QStringLiteral("Kill switch controller is not initialized")}; + } + if (state_ == State::Exiting) { + return {true, 0, state_, {}}; + } + if (!enabled_) { + activeOperationId_ = 0; + state_ = State::Exiting; + lastError_.clear(); + return {true, 0, state_, {}}; + } + + const State originalState = state_; + auto result = ensureBaselineLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + result = removeTunAllowanceLocked(); + if (!result) { + return prepareFailureLocked(originalState, result.error); + } + + activeOperationId_ = 0; + state_ = State::Exiting; + lastError_.clear(); + return {true, 0, state_, {}}; +} + +KillSwitchController::Snapshot KillSwitchController::snapshot() const { + QMutexLocker locker(&mutex_); + return { + initialized_, + enabled_, + recoveredStaleProtection_, + state_, + backendState_, + allowedTun_, + activeOperationId_, + lastError_, + }; +} + +bool KillSwitchController::invariantHolds(QString *reason) const { + QMutexLocker locker(&mutex_); + return invariantHoldsLocked(reason); +} + +QString KillSwitchController::stateName(const State state) { + switch (state) { + case State::Disabled: + return QStringLiteral("Disabled"); + case State::Connecting: + return QStringLiteral("Connecting"); + case State::Connected: + return QStringLiteral("Connected"); + case State::Switching: + return QStringLiteral("Switching"); + case State::Reconnecting: + return QStringLiteral("Reconnecting"); + case State::Stopping: + return QStringLiteral("Stopping"); + case State::Disconnected: + return QStringLiteral("Disconnected"); + case State::Error: + return QStringLiteral("Error"); + case State::Exiting: + return QStringLiteral("Exiting"); + } + return QStringLiteral("Unknown"); +} + +KillSwitchResult KillSwitchController::ensureBaselineLocked() { + const auto result = backend_.ensureBaseline(); + if (!result) { + return backendFailureLocked(QStringLiteral("establish kill switch baseline"), + result); + } + backendState_.baselineActive = true; + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::removeTunAllowanceLocked() { + const auto result = backend_.removeTunAllowance(); + if (!result) { + return backendFailureLocked(QStringLiteral("remove TUN allowance"), result); + } + backendState_.tunAllowanceActive = false; + allowedTun_ = {}; + return KillSwitchResult::Success(); +} + +KillSwitchResult KillSwitchController::backendFailureLocked( + const QString &action, const KillSwitchResult &result) { + lastError_ = normalizedError(action, result); + return KillSwitchResult::Failure(lastError_); +} + +KillSwitchController::PrepareResult KillSwitchController::prepareFailureLocked( + const State originalState, const QString &error) const { + // originalState is retained so a prepare failure never claims that the + // caller may tear down a still-working connection. + return {false, 0, originalState, error}; +} + +bool KillSwitchController::invariantHoldsLocked(QString *reason) const { + const auto fail = [reason](const QString &message) { + if (reason != nullptr) { + *reason = message; + } + return false; + }; + + if (!initialized_) { + if (enabled_ || state_ != State::Disabled || backendState_.anyActive() || + activeOperationId_ != 0) { + return fail(QStringLiteral("Uninitialized controller has active state")); + } + return true; + } + + if (state_ == State::Disabled) { + if (enabled_ || backendState_.anyActive() || activeOperationId_ != 0) { + return fail(QStringLiteral("Disabled state still owns protection")); + } + return true; + } + + if (!enabled_) { + if (state_ == State::Exiting && !backendState_.anyActive() && + activeOperationId_ == 0) { + return true; + } + // Reconciliation/disable can fail while the persisted user setting is + // off. Error is the only non-enabled state allowed to represent that. + if (state_ == State::Error && activeOperationId_ == 0) { + return true; + } + return fail(QStringLiteral("Non-enabled controller is in an active state")); + } + + if (state_ != State::Error) { + if (!backendState_.baselineActive) { + return fail(QStringLiteral("Protected state has no fail-closed baseline")); + } + if (!backendState_.dynamicCoreActive) { + return fail(QStringLiteral("Protected state has no trusted-core allowance")); + } + } + if (backendState_.tunAllowanceActive) { + if (!backendState_.baselineActive || !backendState_.dynamicCoreActive) { + return fail(QStringLiteral("TUN allowance exists without its prerequisites")); + } + if (!allowedTun_.isValid()) { + return fail(QStringLiteral("TUN allowance has no interface identity")); + } + } else if (allowedTun_.isValid()) { + return fail(QStringLiteral("Inactive TUN allowance retains an interface identity")); + } + + const bool hasOperation = activeOperationId_ != 0; + switch (state_) { + case State::Disabled: + return fail(QStringLiteral("Enabled controller reports Disabled")); + case State::Connecting: + case State::Switching: + if (!hasOperation || !backendState_.dynamicCoreActive || + backendState_.tunAllowanceActive) { + return fail(QStringLiteral("Connect/switch transition is not fail-closed")); + } + break; + case State::Connected: + if (hasOperation) { + return fail(QStringLiteral("Connected state retains a start operation")); + } + break; + case State::Reconnecting: + if (backendState_.tunAllowanceActive || + (hasOperation && !backendState_.dynamicCoreActive)) { + return fail(QStringLiteral("Reconnect transition is not fail-closed")); + } + break; + case State::Stopping: + case State::Disconnected: + case State::Exiting: + if (hasOperation || backendState_.tunAllowanceActive) { + return fail(QStringLiteral("Non-connected state still allows a TUN")); + } + break; + case State::Error: + if (hasOperation) { + return fail(QStringLiteral("Error state retains an active operation")); + } + break; + } + return true; +} + +bool KillSwitchController::startAllowedLocked(const StartIntent intent) const { + switch (intent) { + case StartIntent::Connect: + return state_ == State::Disconnected || state_ == State::Error; + case StartIntent::Switch: + return state_ == State::Connected; + case StartIntent::Reconnect: + return state_ == State::Connected || state_ == State::Disconnected || + state_ == State::Reconnecting || state_ == State::Error; + } + return false; +} + +quint64 KillSwitchController::nextOperationIdLocked() { + ++operationCounter_; + if (operationCounter_ == 0) { + ++operationCounter_; + } + return operationCounter_; +} + +} // namespace Configs_sys diff --git a/src/sys/Process.cpp b/src/sys/Process.cpp index 33e799928..bab95b859 100644 --- a/src/sys/Process.cpp +++ b/src/sys/Process.cpp @@ -56,6 +56,12 @@ namespace Configs_sys { if (state == NotRunning) { Configs::dataManager->settingsRepo->core_running = false; qDebug() << "Core stated changed to not running"; + const bool reconnectPlanned = + !Configs::dataManager->settingsRepo->prepare_exit && + !failed_to_start; + if (GetMainWindow() != nullptr) { + GetMainWindow()->killSwitchCoreTerminated(reconnectPlanned); + } } if (!Configs::dataManager->settingsRepo->prepare_exit && state == NotRunning) { diff --git a/src/sys/windows/WindowsWfpKillSwitchBackend.cpp b/src/sys/windows/WindowsWfpKillSwitchBackend.cpp new file mode 100644 index 000000000..fedbca029 --- /dev/null +++ b/src/sys/windows/WindowsWfpKillSwitchBackend.cpp @@ -0,0 +1,1799 @@ +#include "include/sys/windows/WindowsWfpKillSwitchBackend.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr wchar_t kSubLayerName[] = L"Throne kill switch policy"; +constexpr wchar_t kSubLayerDescription[] = L"Blocks direct connections while allowing only controlled bootstrap traffic"; +constexpr wchar_t kSessionName[] = L"Throne kill switch runtime allowances"; +constexpr wchar_t kSessionDescription[] = L"Dynamic core and TUN allowances; removed when Throne exits"; +constexpr std::array kPolicySchemaPrefix = { + 't', 'h', 'r', 'o', 'n', 'e', '-', 'k', 'i', 'l', 'l', '-', 's', 'w', 'i', 't', 'c', 'h', '-', +}; +constexpr std::array kPolicySchema = { + 't', 'h', 'r', 'o', 'n', 'e', '-', 'k', 'i', 'l', 'l', '-', 's', 'w', 'i', 't', 'c', 'h', '-', '4', +}; +constexpr UINT32 kDhcpV4BroadcastAddress = 0xffffffff; +constexpr std::array kDhcpV6ServersMulticastAddress = { + 0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, +}; +constexpr std::array kIpv6LinkLocalPrefix = { + 0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; +// NDP, router solicitation/advertisement, and DAD use link-local-scope +// multicast. Deliberately exclude multicast with broader scopes. +constexpr std::array kIpv6LinkLocalMulticastPrefix = { + 0xff, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; +constexpr std::array kIpv6UnspecifiedAddress = {}; +constexpr UINT8 kIpv6LinkLocalPrefixLength = 10; +constexpr UINT8 kIpv6LinkLocalMulticastPrefixLength = 16; + +// Throne's Windows build still advertises _WIN32_WINNT=0x0600, which makes +// current SDK headers hide the arrival-interface (Vista SP1+) and next-hop +// interface (Windows 7+) identifiers. Keep the documented keys local instead +// of raising the target for the entire application. Unsupported systems fail +// closed when BFE rejects the dynamic filter. +constexpr GUID kIpArrivalInterfaceConditionKey = + {0x618a9b6d, 0x386b, 0x4136, {0xad, 0x6e, 0xb5, 0x15, 0x87, 0xcf, 0xb1, 0xcd}}; +constexpr GUID kIpNextHopInterfaceConditionKey = + {0x93ae8f5b, 0x7f6f, 0x4719, {0x98, 0xc8, 0x14, 0xe9, 0x74, 0x29, 0xef, 0x04}}; + +// Retained only to identify and safely migrate the provider-associated v1-v3 +// policy. The v4 policy never creates or references a provider object. +constexpr GUID kLegacyProviderKey = + {0x3c2bc3c2, 0x3bd8, 0x492b, {0x9d, 0x92, 0xe7, 0xff, 0xf9, 0x7a, 0xce, 0x76}}; +constexpr GUID kSubLayerKey = + {0xecc81b21, 0x2471, 0x41f9, {0xae, 0x7c, 0xab, 0x23, 0x28, 0x77, 0x4f, 0x94}}; + +constexpr GUID kLoopbackV4FilterKey = + {0xa4547a78, 0x8878, 0x4215, {0x80, 0x43, 0x93, 0x3a, 0x44, 0x03, 0x52, 0xe4}}; +constexpr GUID kLoopbackV6FilterKey = + {0x48d0e937, 0xf81a, 0x4545, {0xb5, 0xba, 0x93, 0xd7, 0x2c, 0x1a, 0x2e, 0x82}}; +constexpr GUID kDhcpV4FilterKey = + {0x05d67b17, 0x7098, 0x4074, {0x86, 0x76, 0x6e, 0xea, 0xe3, 0x1e, 0x76, 0x8b}}; +constexpr GUID kDhcpV6FilterKey = + {0xbe3e2d94, 0xe626, 0x4404, {0x82, 0x0f, 0x66, 0x2e, 0x55, 0xd1, 0xcd, 0xf2}}; +constexpr GUID kIcmpV6ConnectLinkLocalFilterKey = + {0x213e71d7, 0xe9d6, 0x4e62, {0xb7, 0xbf, 0x06, 0x4f, 0xc8, 0x78, 0xe6, 0x02}}; +constexpr GUID kIcmpV6ConnectMulticastFilterKey = + {0x446f078e, 0x52df, 0x455b, {0xa7, 0x88, 0x92, 0x2b, 0x0c, 0x5e, 0xb7, 0x25}}; +constexpr GUID kIcmpV6ReceiveLinkLocalFilterKey = + {0x0beeea16, 0xaa6d, 0x4685, {0x94, 0x24, 0x55, 0x40, 0x96, 0x9c, 0xfa, 0x67}}; +constexpr GUID kIcmpV6ReceiveDadFilterKey = + {0x4d6209a5, 0x2511, 0x4f4b, {0x9b, 0x35, 0xc2, 0xd2, 0x74, 0xec, 0xa3, 0x8d}}; +constexpr GUID kBlockV4FilterKey = + {0x24741cf9, 0x29a7, 0x4f08, {0x97, 0x8b, 0x9f, 0x03, 0x1c, 0xe3, 0x29, 0x27}}; +constexpr GUID kBlockV6FilterKey = + {0x522c4565, 0x7eca, 0x4933, {0x8b, 0x5b, 0x6d, 0x48, 0x00, 0x29, 0x43, 0x0f}}; +constexpr GUID kReceiveLoopbackV4FilterKey = + {0x0afdfeb1, 0x71a8, 0x4030, {0x88, 0x72, 0x5f, 0x82, 0xac, 0x8e, 0xda, 0x02}}; +constexpr GUID kReceiveLoopbackV6FilterKey = + {0xaf1d5811, 0x8e2e, 0x410d, {0x89, 0x7a, 0xa8, 0x7b, 0x53, 0xcb, 0x48, 0xe5}}; +constexpr GUID kReceiveBlockV4FilterKey = + {0xf77be552, 0xfeac, 0x45dc, {0x98, 0xd3, 0x0a, 0xde, 0x8b, 0x26, 0x76, 0x20}}; +constexpr GUID kReceiveBlockV6FilterKey = + {0xf9612efc, 0x03e2, 0x40a8, {0xaf, 0x6e, 0x9d, 0x67, 0x5a, 0xe9, 0xe6, 0xc8}}; + +constexpr GUID kCoreV4FilterKey = + {0xe19b3249, 0xd1d5, 0x4261, {0xae, 0x15, 0xd3, 0xfe, 0x34, 0xae, 0x36, 0x79}}; +constexpr GUID kCoreV6FilterKey = + {0xaced7286, 0x73e0, 0x4282, {0xac, 0x72, 0x66, 0x48, 0xe0, 0xa5, 0xbf, 0x0b}}; +constexpr GUID kTunV4FilterKey = + {0xa45ec644, 0x96b6, 0x4079, {0xb7, 0x8a, 0x2d, 0x99, 0x8d, 0xe9, 0x6e, 0xfc}}; +constexpr GUID kTunV6FilterKey = + {0x257d8918, 0x1116, 0x4883, {0xbb, 0x5c, 0x83, 0x74, 0xf5, 0x62, 0x63, 0xe3}}; +constexpr GUID kReceiveTunV4FilterKey = + {0x4dff6e48, 0xa5ca, 0x40ba, {0xb2, 0xf9, 0xf6, 0xbc, 0x75, 0x94, 0x86, 0x25}}; +constexpr GUID kReceiveTunV6FilterKey = + {0xb7f016d4, 0x25f7, 0x447d, {0xb5, 0xb1, 0x3b, 0xed, 0xda, 0x1a, 0x37, 0x56}}; + +constexpr std::array kPersistentFilterKeys = { + &kLoopbackV4FilterKey, + &kLoopbackV6FilterKey, + &kDhcpV4FilterKey, + &kDhcpV6FilterKey, + &kIcmpV6ConnectLinkLocalFilterKey, + &kIcmpV6ConnectMulticastFilterKey, + &kIcmpV6ReceiveLinkLocalFilterKey, + &kIcmpV6ReceiveDadFilterKey, + &kBlockV4FilterKey, + &kBlockV6FilterKey, + &kReceiveLoopbackV4FilterKey, + &kReceiveLoopbackV6FilterKey, + &kReceiveBlockV4FilterKey, + &kReceiveBlockV6FilterKey, +}; + +constexpr std::array kDynamicFilterKeys = { + &kCoreV4FilterKey, + &kCoreV6FilterKey, + &kTunV4FilterKey, + &kTunV6FilterKey, + &kReceiveTunV4FilterKey, + &kReceiveTunV6FilterKey, +}; + +constexpr UINT8 kBlockWeight = 0; +constexpr UINT8 kPermitWeight = 15; + +void setError(QString *error, const QString &message) +{ + if (error != nullptr) { + *error = message; + } +} + +QString systemErrorMessage(DWORD code) +{ + LPWSTR buffer = nullptr; + const DWORD length = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | + FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + code, + 0, + reinterpret_cast(&buffer), + 0, + nullptr); + QString message; + if (length != 0 && buffer != nullptr) { + message = QString::fromWCharArray(buffer, static_cast(length)).trimmed(); + } + if (buffer != nullptr) { + LocalFree(buffer); + } + return message; +} + +QString operationError(const QString &operation, DWORD code) +{ + const QString numericCode = QStringLiteral("0x%1").arg(static_cast(code), 8, 16, QLatin1Char('0')); + const QString description = systemErrorMessage(code); + if (description.isEmpty()) { + return QStringLiteral("%1 failed (%2)").arg(operation, numericCode); + } + return QStringLiteral("%1 failed (%2): %3").arg(operation, numericCode, description); +} + +wchar_t *mutableText(const wchar_t *text) +{ + // The WFP structures predate const-correct Windows APIs. Fwpm*Add does not + // modify the supplied display data and copies it before returning. + return const_cast(text); +} + +class EngineHandle final +{ +public: + EngineHandle() = default; + explicit EngineHandle(HANDLE handle) : handle_(handle) {} + ~EngineHandle() { reset(); } + + EngineHandle(const EngineHandle &) = delete; + EngineHandle &operator=(const EngineHandle &) = delete; + + EngineHandle(EngineHandle &&other) noexcept : handle_(std::exchange(other.handle_, nullptr)) {} + EngineHandle &operator=(EngineHandle &&other) noexcept + { + if (this != &other) { + reset(); + handle_ = std::exchange(other.handle_, nullptr); + } + return *this; + } + + [[nodiscard]] HANDLE get() const { return handle_; } + [[nodiscard]] bool valid() const { return handle_ != nullptr; } + + DWORD reset(HANDLE handle = nullptr) + { + DWORD result = ERROR_SUCCESS; + if (handle_ != nullptr) { + result = FwpmEngineClose0(handle_); + } + handle_ = handle; + return result; + } + +private: + HANDLE handle_ = nullptr; +}; + +class WfpMemory final +{ +public: + WfpMemory() = default; + explicit WfpMemory(void *memory) : memory_(memory) {} + ~WfpMemory() { reset(); } + + WfpMemory(const WfpMemory &) = delete; + WfpMemory &operator=(const WfpMemory &) = delete; + + WfpMemory(WfpMemory &&other) noexcept : memory_(std::exchange(other.memory_, nullptr)) {} + WfpMemory &operator=(WfpMemory &&other) noexcept + { + if (this != &other) { + reset(); + memory_ = std::exchange(other.memory_, nullptr); + } + return *this; + } + + void reset(void *memory = nullptr) + { + if (memory_ != nullptr) { + void *toFree = memory_; + FwpmFreeMemory0(&toFree); + } + memory_ = memory; + } + +private: + void *memory_ = nullptr; +}; + +class Transaction final +{ +public: + explicit Transaction(HANDLE engine) : engine_(engine) {} + ~Transaction() + { + if (active_) { + FwpmTransactionAbort0(engine_); + } + } + + DWORD begin() + { + const DWORD result = FwpmTransactionBegin0(engine_, 0); + active_ = result == ERROR_SUCCESS; + return result; + } + + DWORD commit() + { + const DWORD result = FwpmTransactionCommit0(engine_); + if (result == ERROR_SUCCESS) { + active_ = false; + } + return result; + } + +private: + HANDLE engine_; + bool active_ = false; +}; + +DWORD openEngine(bool dynamic, EngineHandle *engine) +{ + FWPM_SESSION0 session{}; + const FWPM_SESSION0 *sessionPointer = nullptr; + if (dynamic) { + session.displayData.name = mutableText(kSessionName); + session.displayData.description = mutableText(kSessionDescription); + session.flags = FWPM_SESSION_FLAG_DYNAMIC; + session.txnWaitTimeoutInMSec = 5000; + sessionPointer = &session; + } + + HANDLE handle = nullptr; + const DWORD result = FwpmEngineOpen0(nullptr, RPC_C_AUTHN_WINNT, nullptr, sessionPointer, &handle); + if (result == ERROR_SUCCESS) { + engine->reset(handle); + } + return result; +} + +enum class Presence +{ + Missing, + Present, + Failed, +}; + +template +Presence getObjectPresence(HANDLE engine, + const GUID &key, + Getter getter, + Validator validator, + bool *matchesSchema, + DWORD notFound, + QString *error, + const QString &name) +{ + Object *object = nullptr; + const DWORD result = getter(engine, &key, &object); + WfpMemory memory(object); + if (result == ERROR_SUCCESS) { + if (matchesSchema != nullptr) { + *matchesSchema = object != nullptr && validator(*object); + } + return Presence::Present; + } + if (result == notFound) { + return Presence::Missing; + } + setError(error, operationError(QStringLiteral("Query %1").arg(name), result)); + return Presence::Failed; +} + +bool equalGuid(const GUID &left, const GUID &right) +{ + return InlineIsEqualGUID(left, right) != FALSE; +} + +bool blobMatchesCurrentSchema(const FWP_BYTE_BLOB &data) +{ + return data.size == kPolicySchema.size() && data.data != nullptr && + std::equal(kPolicySchema.begin(), kPolicySchema.end(), data.data); +} + +void setCurrentSchema(FWP_BYTE_BLOB *data) +{ + data->size = static_cast(kPolicySchema.size()); + data->data = const_cast(kPolicySchema.data()); +} + +bool providerHasKnownLegacyThroneOwnershipMarker(const FWPM_PROVIDER0 &provider) +{ + // DISABLED is a read-only status bit which BFE may add when returning an + // older provider after service initialization. It cannot be supplied by an + // object creator, so accepting it does not broaden the ownership marker. + constexpr UINT32 allowedFlags = FWPM_PROVIDER_FLAG_PERSISTENT | + FWPM_PROVIDER_FLAG_DISABLED; + if ((provider.flags & FWPM_PROVIDER_FLAG_PERSISTENT) == 0 || + (provider.flags & ~allowedFlags) != 0 || provider.serviceName != nullptr || + provider.providerData.size != kPolicySchema.size() || provider.providerData.data == nullptr || + !std::equal(kPolicySchemaPrefix.begin(), + kPolicySchemaPrefix.end(), + provider.providerData.data)) { + return false; + } + + // Recognizing only this closed set permits safe in-place migration without + // treating an arbitrary deterministic-GUID collision as ours. + const UINT8 version = provider.providerData.data[kPolicySchemaPrefix.size()]; + return version == '1' || version == '2' || version == '3'; +} + +enum class MutationOwnership +{ + Empty, + OwnedByThrone, + Foreign, + Error, +}; + +struct MutationOwnershipResult +{ + MutationOwnership ownership = MutationOwnership::Error; + QString detail; +}; + +MutationOwnershipResult verifyMutationOwnership(HANDLE engine) +{ + bool anyObjectPresent = false; + bool legacyProviderPresent = false; + + FWPM_PROVIDER0 *provider = nullptr; + DWORD result = FwpmProviderGetByKey0(engine, &kLegacyProviderKey, &provider); + WfpMemory providerMemory(provider); + if (result == ERROR_SUCCESS) { + anyObjectPresent = true; + legacyProviderPresent = provider != nullptr && + providerHasKnownLegacyThroneOwnershipMarker(*provider); + if (!legacyProviderPresent) { + return { + MutationOwnership::Foreign, + QStringLiteral("Refusing to modify the deterministic legacy WFP provider because it lacks a known Throne v1-v3 ownership marker"), + }; + } + } else if (result != FWP_E_PROVIDER_NOT_FOUND) { + return { + MutationOwnership::Error, + operationError(QStringLiteral("Verify ownership of the legacy Throne WFP provider"), result), + }; + } + + const auto legacyObjectDataIsEmpty = [](const FWP_BYTE_BLOB &data) { + // RPC unmarshalling is allowed to return an arbitrary pointer for a + // zero-length blob. The legacy v1-v3 objects never carried object + // data; ownership is instead proven by their marked provider link. + return data.size == 0; + }; + + FWPM_SUBLAYER0 *subLayer = nullptr; + result = FwpmSubLayerGetByKey0(engine, &kSubLayerKey, &subLayer); + WfpMemory subLayerMemory(subLayer); + if (result == ERROR_SUCCESS) { + anyObjectPresent = true; + const bool owned = subLayer != nullptr && + (legacyProviderPresent + ? (subLayer->providerKey != nullptr && + equalGuid(*subLayer->providerKey, kLegacyProviderKey) && + legacyObjectDataIsEmpty(subLayer->providerData)) + : (subLayer->providerKey == nullptr && + blobMatchesCurrentSchema(subLayer->providerData))); + if (!owned) { + return { + MutationOwnership::Foreign, + QStringLiteral("Refusing to modify the deterministic WFP sublayer because neither its v4 object marker nor a marked legacy provider proves Throne ownership"), + }; + } + } else if (result != FWP_E_SUBLAYER_NOT_FOUND) { + return { + MutationOwnership::Error, + operationError(QStringLiteral("Verify ownership of the Throne WFP sublayer"), result), + }; + } + + const auto verifyFilter = [&](const GUID &key) -> MutationOwnershipResult { + FWPM_FILTER0 *filter = nullptr; + const DWORD filterResult = FwpmFilterGetByKey0(engine, &key, &filter); + WfpMemory filterMemory(filter); + if (filterResult == FWP_E_FILTER_NOT_FOUND) { + return {MutationOwnership::Empty, {}}; + } + if (filterResult != ERROR_SUCCESS) { + return { + MutationOwnership::Error, + operationError(QStringLiteral("Verify ownership of a deterministic Throne WFP filter"), + filterResult), + }; + } + + anyObjectPresent = true; + const bool owned = filter != nullptr && equalGuid(filter->subLayerKey, kSubLayerKey) && + (legacyProviderPresent + ? (filter->providerKey != nullptr && + equalGuid(*filter->providerKey, kLegacyProviderKey) && + legacyObjectDataIsEmpty(filter->providerData)) + : (filter->providerKey == nullptr && + blobMatchesCurrentSchema(filter->providerData))); + if (!owned) { + return { + MutationOwnership::Foreign, + QStringLiteral("Refusing to modify a deterministic WFP filter because neither its v4 object marker nor a marked legacy provider proves Throne ownership"), + }; + } + return {MutationOwnership::OwnedByThrone, {}}; + }; + + for (const GUID *key : kPersistentFilterKeys) { + const MutationOwnershipResult filterOwnership = verifyFilter(*key); + if (filterOwnership.ownership == MutationOwnership::Foreign || + filterOwnership.ownership == MutationOwnership::Error) { + return filterOwnership; + } + } + for (const GUID *key : kDynamicFilterKeys) { + const MutationOwnershipResult filterOwnership = verifyFilter(*key); + if (filterOwnership.ownership == MutationOwnership::Foreign || + filterOwnership.ownership == MutationOwnership::Error) { + return filterOwnership; + } + } + + return {anyObjectPresent ? MutationOwnership::OwnedByThrone + : MutationOwnership::Empty, + {}}; +} + +bool subLayerMatchesSchema(const FWPM_SUBLAYER0 &subLayer) +{ + return subLayer.flags == FWPM_SUBLAYER_FLAG_PERSISTENT && + subLayer.providerKey == nullptr && + blobMatchesCurrentSchema(subLayer.providerData) && + subLayer.weight == 0xffff; +} + +bool conditionMatches(const FWPM_FILTER_CONDITION0 &condition, + const GUID &field, + FWP_DATA_TYPE type, + UINT32 value) +{ + if (!equalGuid(condition.fieldKey, field) || condition.matchType != FWP_MATCH_EQUAL || + condition.conditionValue.type != type) { + return false; + } + switch (type) { + case FWP_UINT8: + return condition.conditionValue.uint8 == value; + case FWP_UINT16: + return condition.conditionValue.uint16 == value; + case FWP_UINT32: + return condition.conditionValue.uint32 == value; + default: + return false; + } +} + +bool ipv6MaskConditionMatches(const FWPM_FILTER_CONDITION0 &condition, + const GUID &field, + const std::array &address, + UINT8 prefixLength) +{ + return equalGuid(condition.fieldKey, field) && + condition.matchType == FWP_MATCH_EQUAL && + condition.conditionValue.type == FWP_V6_ADDR_MASK && + condition.conditionValue.v6AddrMask != nullptr && + condition.conditionValue.v6AddrMask->prefixLength == prefixLength && + std::equal(address.begin(), + address.end(), + condition.conditionValue.v6AddrMask->addr); +} + +bool commonFilterMatches(const FWPM_FILTER0 &filter, + const GUID &layer, + FWP_ACTION_TYPE action, + UINT8 weight, + UINT32 expectedFlags) +{ + return filter.flags == expectedFlags && + filter.providerKey == nullptr && blobMatchesCurrentSchema(filter.providerData) && + equalGuid(filter.layerKey, layer) && equalGuid(filter.subLayerKey, kSubLayerKey) && + filter.weight.type == FWP_UINT8 && filter.weight.uint8 == weight && + filter.action.type == action; +} + +const GUID &tunInterfaceCondition(bool receive) +{ + // Match the actual directional interface, not merely the interface owning + // the selected local address. NEXTHOP is the last interface an outbound + // packet traverses after weak-host/forwarding decisions; ARRIVAL is the + // interface on which inbound traffic entered. Thus neither direction can + // use a physical interface while satisfying a TUN allowance. + return receive ? kIpArrivalInterfaceConditionKey + : kIpNextHopInterfaceConditionKey; +} + +bool persistentFilterMatchesSchema(const GUID &key, const FWPM_FILTER0 &filter) +{ + const bool receive = equalGuid(key, kReceiveLoopbackV4FilterKey) || + equalGuid(key, kReceiveLoopbackV6FilterKey) || + equalGuid(key, kIcmpV6ReceiveLinkLocalFilterKey) || + equalGuid(key, kIcmpV6ReceiveDadFilterKey) || + equalGuid(key, kReceiveBlockV4FilterKey) || + equalGuid(key, kReceiveBlockV6FilterKey); + const bool ipv6 = equalGuid(key, kLoopbackV6FilterKey) || + equalGuid(key, kDhcpV6FilterKey) || + equalGuid(key, kBlockV6FilterKey) || + equalGuid(key, kIcmpV6ConnectLinkLocalFilterKey) || + equalGuid(key, kIcmpV6ConnectMulticastFilterKey) || + equalGuid(key, kIcmpV6ReceiveLinkLocalFilterKey) || + equalGuid(key, kIcmpV6ReceiveDadFilterKey) || + equalGuid(key, kReceiveLoopbackV6FilterKey) || + equalGuid(key, kReceiveBlockV6FilterKey); + const bool block = equalGuid(key, kBlockV4FilterKey) || + equalGuid(key, kBlockV6FilterKey) || + equalGuid(key, kReceiveBlockV4FilterKey) || + equalGuid(key, kReceiveBlockV6FilterKey); + const bool loopback = equalGuid(key, kLoopbackV4FilterKey) || + equalGuid(key, kLoopbackV6FilterKey) || + equalGuid(key, kReceiveLoopbackV4FilterKey) || + equalGuid(key, kReceiveLoopbackV6FilterKey); + const bool dhcp = equalGuid(key, kDhcpV4FilterKey) || equalGuid(key, kDhcpV6FilterKey); + const bool icmpV6 = equalGuid(key, kIcmpV6ConnectLinkLocalFilterKey) || + equalGuid(key, kIcmpV6ConnectMulticastFilterKey) || + equalGuid(key, kIcmpV6ReceiveLinkLocalFilterKey) || + equalGuid(key, kIcmpV6ReceiveDadFilterKey); + if (!block && !loopback && !dhcp && !icmpV6) { + return false; + } + + const GUID &layer = receive + ? (ipv6 ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4) + : (ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V4); + + if (block) { + return commonFilterMatches(filter, + layer, + FWP_ACTION_BLOCK, + kBlockWeight, + FWPM_FILTER_FLAG_PERSISTENT | FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT) && + filter.numFilterConditions == 0; + } + + if (!commonFilterMatches(filter, + layer, + FWP_ACTION_PERMIT, + kPermitWeight, + FWPM_FILTER_FLAG_PERSISTENT)) { + return false; + } + + if (loopback) { + if (filter.numFilterConditions != 1 || filter.filterCondition == nullptr) { + return false; + } + const FWPM_FILTER_CONDITION0 &condition = filter.filterCondition[0]; + return equalGuid(condition.fieldKey, FWPM_CONDITION_FLAGS) && + condition.matchType == FWP_MATCH_FLAGS_ALL_SET && + condition.conditionValue.type == FWP_UINT32 && + condition.conditionValue.uint32 == FWP_CONDITION_FLAG_IS_LOOPBACK; + } + + if (icmpV6) { + const bool dad = equalGuid(key, kIcmpV6ReceiveDadFilterKey); + const UINT32 expectedConditionCount = dad ? 4 : 2; + if (filter.numFilterConditions != expectedConditionCount || + filter.filterCondition == nullptr) { + return false; + } + + const bool multicast = equalGuid(key, kIcmpV6ConnectMulticastFilterKey); + bool protocolFound = false; + bool remoteAddressFound = false; + bool localAddressFound = !dad; + bool icmpTypeFound = !dad; + for (UINT32 index = 0; index < filter.numFilterConditions; ++index) { + const FWPM_FILTER_CONDITION0 &condition = filter.filterCondition[index]; + protocolFound |= conditionMatches(condition, + FWPM_CONDITION_IP_PROTOCOL, + FWP_UINT8, + IPPROTO_ICMPV6); + if (dad) { + remoteAddressFound |= ipv6MaskConditionMatches(condition, + FWPM_CONDITION_IP_REMOTE_ADDRESS, + kIpv6UnspecifiedAddress, + 128); + localAddressFound |= ipv6MaskConditionMatches(condition, + FWPM_CONDITION_IP_LOCAL_ADDRESS, + kIpv6LinkLocalMulticastPrefix, + kIpv6LinkLocalMulticastPrefixLength); + icmpTypeFound |= conditionMatches(condition, + FWPM_CONDITION_ICMP_TYPE, + FWP_UINT16, + 135); + } else { + const auto &address = multicast ? kIpv6LinkLocalMulticastPrefix + : kIpv6LinkLocalPrefix; + const UINT8 prefixLength = multicast ? kIpv6LinkLocalMulticastPrefixLength + : kIpv6LinkLocalPrefixLength; + remoteAddressFound |= ipv6MaskConditionMatches(condition, + FWPM_CONDITION_IP_REMOTE_ADDRESS, + address, + prefixLength); + } + } + return protocolFound && remoteAddressFound && localAddressFound && + icmpTypeFound; + } + + if (filter.numFilterConditions != 4 || filter.filterCondition == nullptr) { + return false; + } + const UINT16 localPort = ipv6 ? 546 : 68; + const UINT16 remotePort = ipv6 ? 547 : 67; + bool protocolFound = false; + bool localPortFound = false; + bool remotePortFound = false; + bool remoteAddressFound = false; + for (UINT32 index = 0; index < filter.numFilterConditions; ++index) { + const FWPM_FILTER_CONDITION0 &condition = filter.filterCondition[index]; + protocolFound |= conditionMatches(condition, FWPM_CONDITION_IP_PROTOCOL, FWP_UINT8, IPPROTO_UDP); + localPortFound |= conditionMatches(condition, FWPM_CONDITION_IP_LOCAL_PORT, FWP_UINT16, localPort); + remotePortFound |= conditionMatches(condition, FWPM_CONDITION_IP_REMOTE_PORT, FWP_UINT16, remotePort); + if (ipv6) { + remoteAddressFound |= equalGuid(condition.fieldKey, FWPM_CONDITION_IP_REMOTE_ADDRESS) && + condition.matchType == FWP_MATCH_EQUAL && + condition.conditionValue.type == FWP_BYTE_ARRAY16_TYPE && + condition.conditionValue.byteArray16 != nullptr && + std::equal(kDhcpV6ServersMulticastAddress.begin(), + kDhcpV6ServersMulticastAddress.end(), + condition.conditionValue.byteArray16->byteArray16); + } else { + remoteAddressFound |= conditionMatches(condition, + FWPM_CONDITION_IP_REMOTE_ADDRESS, + FWP_UINT32, + kDhcpV4BroadcastAddress); + } + } + return protocolFound && localPortFound && remotePortFound && remoteAddressFound; +} + +bool dynamicFilterMatchesSchema(const GUID &key, const FWPM_FILTER0 &filter) +{ + const bool core = equalGuid(key, kCoreV4FilterKey) || + equalGuid(key, kCoreV6FilterKey); + const bool tun = equalGuid(key, kTunV4FilterKey) || + equalGuid(key, kTunV6FilterKey) || + equalGuid(key, kReceiveTunV4FilterKey) || + equalGuid(key, kReceiveTunV6FilterKey); + if (!core && !tun) { + return false; + } + + const bool receive = equalGuid(key, kReceiveTunV4FilterKey) || + equalGuid(key, kReceiveTunV6FilterKey); + const bool ipv6 = equalGuid(key, kCoreV6FilterKey) || + equalGuid(key, kTunV6FilterKey) || + equalGuid(key, kReceiveTunV6FilterKey); + const GUID &layer = receive + ? (ipv6 ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4) + : (ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V4); + if (!commonFilterMatches(filter, + layer, + FWP_ACTION_PERMIT, + kPermitWeight, + 0) || + filter.numFilterConditions != 1 || filter.filterCondition == nullptr) { + return false; + } + + const FWPM_FILTER_CONDITION0 &condition = filter.filterCondition[0]; + if (core) { + return equalGuid(condition.fieldKey, FWPM_CONDITION_ALE_APP_ID) && + condition.matchType == FWP_MATCH_EQUAL && + condition.conditionValue.type == FWP_BYTE_BLOB_TYPE && + condition.conditionValue.byteBlob != nullptr && + condition.conditionValue.byteBlob->size != 0 && + condition.conditionValue.byteBlob->data != nullptr; + } + return equalGuid(condition.fieldKey, tunInterfaceCondition(receive)) && + condition.matchType == FWP_MATCH_EQUAL && + condition.conditionValue.type == FWP_UINT64 && + condition.conditionValue.uint64 != nullptr; +} + +DWORD deleteFilterIfPresent(HANDLE engine, const GUID &key) +{ + const DWORD result = FwpmFilterDeleteByKey0(engine, &key); + return result == FWP_E_FILTER_NOT_FOUND ? ERROR_SUCCESS : result; +} + +DWORD deleteSubLayerIfPresent(HANDLE engine) +{ + const DWORD result = FwpmSubLayerDeleteByKey0(engine, &kSubLayerKey); + return result == FWP_E_SUBLAYER_NOT_FOUND ? ERROR_SUCCESS : result; +} + +DWORD deleteLegacyProviderIfPresent(HANDLE engine) +{ + const DWORD result = FwpmProviderDeleteByKey0(engine, &kLegacyProviderKey); + return result == FWP_E_PROVIDER_NOT_FOUND ? ERROR_SUCCESS : result; +} + +DWORD deletePersistentObjects(HANDLE engine, QString *operation) +{ + // Dynamic filters normally disappear with their owning WFP session. Delete + // our exact keys as well so a legacy/non-dynamic implementation cannot + // leave an exception behind during startup recovery or explicit disable. + for (const GUID *key : kDynamicFilterKeys) { + const DWORD result = deleteFilterIfPresent(engine, *key); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete a stale Throne dynamic filter"); + } + return result; + } + } + for (const GUID *key : kPersistentFilterKeys) { + const DWORD result = deleteFilterIfPresent(engine, *key); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete a Throne persistent filter"); + } + return result; + } + } + + DWORD result = deleteSubLayerIfPresent(engine); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete the Throne WFP sublayer"); + } + return result; + } + + result = deleteLegacyProviderIfPresent(engine); + if (result != ERROR_SUCCESS && operation != nullptr) { + *operation = QStringLiteral("Delete the marked legacy Throne WFP provider"); + } + return result; +} + +DWORD addSubLayer(HANDLE engine, QString *operation) +{ + FWPM_SUBLAYER0 subLayer{}; + subLayer.subLayerKey = kSubLayerKey; + subLayer.displayData.name = mutableText(kSubLayerName); + subLayer.displayData.description = mutableText(kSubLayerDescription); + subLayer.flags = FWPM_SUBLAYER_FLAG_PERSISTENT; + // Providerless persistent objects are explicitly restored by BFE after a + // service restart. Association with an ordinary provider would instead + // require a serviceName before Windows re-enumerates the provider's policy. + setCurrentSchema(&subLayer.providerData); + // Highest ordinary sublayer weight. Windows security policy may still use + // its reserved higher range and our soft permits cannot override its blocks. + subLayer.weight = 0xffff; + + const DWORD result = FwpmSubLayerAdd0(engine, &subLayer, nullptr); + if (result != ERROR_SUCCESS && operation != nullptr) { + *operation = QStringLiteral("Add the Throne WFP sublayer"); + } + return result; +} + +FWPM_FILTER0 makeFilter(const GUID &key, + const GUID &layer, + const wchar_t *name, + FWP_ACTION_TYPE action, + UINT8 weight, + bool persistent, + FWPM_FILTER_CONDITION0 *conditions = nullptr, + UINT32 conditionCount = 0) +{ + FWPM_FILTER0 filter{}; + filter.filterKey = key; + filter.displayData.name = mutableText(name); + filter.flags = persistent ? FWPM_FILTER_FLAG_PERSISTENT : 0; + // Each deterministic object carries its own ownership marker. This keeps + // both persistent and dynamic filters providerless and makes exact-object + // validation possible before any repair or cleanup mutation. + setCurrentSchema(&filter.providerData); + filter.layerKey = layer; + filter.subLayerKey = kSubLayerKey; + filter.weight.type = FWP_UINT8; + filter.weight.uint8 = weight; + filter.numFilterConditions = conditionCount; + filter.filterCondition = conditions; + filter.action.type = action; + return filter; +} + +DWORD addFilter(HANDLE engine, FWPM_FILTER0 *filter, QString *operation) +{ + const DWORD result = FwpmFilterAdd0(engine, filter, nullptr, nullptr); + if (result != ERROR_SUCCESS && operation != nullptr) { + *operation = QStringLiteral("Add WFP filter '%1'").arg(QString::fromWCharArray(filter->displayData.name)); + } + return result; +} + +DWORD addLoopbackFilter(HANDLE engine, bool ipv6, bool receive, QString *operation) +{ + FWPM_FILTER_CONDITION0 condition{}; + condition.fieldKey = FWPM_CONDITION_FLAGS; + condition.matchType = FWP_MATCH_FLAGS_ALL_SET; + condition.conditionValue.type = FWP_UINT32; + condition.conditionValue.uint32 = FWP_CONDITION_FLAG_IS_LOOPBACK; + + const GUID &key = receive + ? (ipv6 ? kReceiveLoopbackV6FilterKey : kReceiveLoopbackV4FilterKey) + : (ipv6 ? kLoopbackV6FilterKey : kLoopbackV4FilterKey); + const GUID &layer = receive + ? (ipv6 ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4) + : (ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V4); + const wchar_t *name = receive + ? (ipv6 ? L"Throne permit received IPv6 loopback" + : L"Throne permit received IPv4 loopback") + : (ipv6 ? L"Throne permit IPv6 loopback" + : L"Throne permit IPv4 loopback"); + FWPM_FILTER0 filter = makeFilter(key, + layer, + name, + FWP_ACTION_PERMIT, + kPermitWeight, + true, + &condition, + 1); + return addFilter(engine, &filter, operation); +} + +DWORD addDhcpFilter(HANDLE engine, bool ipv6, QString *operation) +{ + std::array conditions{}; + conditions[0].fieldKey = FWPM_CONDITION_IP_PROTOCOL; + conditions[0].matchType = FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_UINT8; + conditions[0].conditionValue.uint8 = IPPROTO_UDP; + + conditions[1].fieldKey = FWPM_CONDITION_IP_LOCAL_PORT; + conditions[1].matchType = FWP_MATCH_EQUAL; + conditions[1].conditionValue.type = FWP_UINT16; + conditions[1].conditionValue.uint16 = ipv6 ? 546 : 68; + + conditions[2].fieldKey = FWPM_CONDITION_IP_REMOTE_PORT; + conditions[2].matchType = FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_UINT16; + conditions[2].conditionValue.uint16 = ipv6 ? 547 : 67; + + // Keep address configuration working without creating a generic UDP + // escape hatch. DHCPv4 is limited to the all-hosts broadcast, and DHCPv6 + // to the link-local All_DHCP_Relay_Agents_and_Servers multicast group. + // ALE gives multicast/broadcast request-response state a short lifetime + // (four seconds by default), enough for the replies. Unicast lease renewal + // remains blocked and Windows can fall back to broadcast/multicast rebind. + FWP_BYTE_ARRAY16 dhcpV6Address{}; + conditions[3].fieldKey = FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[3].matchType = FWP_MATCH_EQUAL; + if (ipv6) { + std::copy(kDhcpV6ServersMulticastAddress.begin(), + kDhcpV6ServersMulticastAddress.end(), + dhcpV6Address.byteArray16); + conditions[3].conditionValue.type = FWP_BYTE_ARRAY16_TYPE; + conditions[3].conditionValue.byteArray16 = &dhcpV6Address; + } else { + conditions[3].conditionValue.type = FWP_UINT32; + conditions[3].conditionValue.uint32 = kDhcpV4BroadcastAddress; + } + + FWPM_FILTER0 filter = makeFilter(ipv6 ? kDhcpV6FilterKey : kDhcpV4FilterKey, + ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 : FWPM_LAYER_ALE_AUTH_CONNECT_V4, + ipv6 ? L"Throne permit DHCPv6 client" : L"Throne permit DHCPv4 client", + FWP_ACTION_PERMIT, + kPermitWeight, + true, + conditions.data(), + static_cast(conditions.size())); + return addFilter(engine, &filter, operation); +} + +enum class IcmpV6ControlPlanePermit +{ + ConnectLinkLocal, + ConnectMulticast, + ReceiveLinkLocal, + ReceiveDad, +}; + +DWORD addIcmpV6ControlPlaneFilter(HANDLE engine, + IcmpV6ControlPlanePermit permit, + QString *operation) +{ + const bool receive = permit == IcmpV6ControlPlanePermit::ReceiveLinkLocal || + permit == IcmpV6ControlPlanePermit::ReceiveDad; + const bool dad = permit == IcmpV6ControlPlanePermit::ReceiveDad; + const bool multicast = permit == IcmpV6ControlPlanePermit::ConnectMulticast; + + std::array conditions{}; + conditions[0].fieldKey = FWPM_CONDITION_IP_PROTOCOL; + conditions[0].matchType = FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_UINT8; + conditions[0].conditionValue.uint8 = IPPROTO_ICMPV6; + + FWP_V6_ADDR_AND_MASK remoteAddress{}; + const auto &remotePrefix = dad ? kIpv6UnspecifiedAddress + : (multicast ? kIpv6LinkLocalMulticastPrefix + : kIpv6LinkLocalPrefix); + std::copy(remotePrefix.begin(), remotePrefix.end(), remoteAddress.addr); + remoteAddress.prefixLength = dad ? 128 + : (multicast ? kIpv6LinkLocalMulticastPrefixLength + : kIpv6LinkLocalPrefixLength); + conditions[1].fieldKey = FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[1].matchType = FWP_MATCH_EQUAL; + conditions[1].conditionValue.type = FWP_V6_ADDR_MASK; + conditions[1].conditionValue.v6AddrMask = &remoteAddress; + + UINT32 conditionCount = 2; + FWP_V6_ADDR_AND_MASK localMulticastAddress{}; + if (dad) { + // Duplicate Address Detection is the only accepted unspecified-source + // receive case: Neighbor Solicitation (type 135) to IPv6 multicast. + std::copy(kIpv6LinkLocalMulticastPrefix.begin(), + kIpv6LinkLocalMulticastPrefix.end(), + localMulticastAddress.addr); + localMulticastAddress.prefixLength = kIpv6LinkLocalMulticastPrefixLength; + conditions[2].fieldKey = FWPM_CONDITION_IP_LOCAL_ADDRESS; + conditions[2].matchType = FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_V6_ADDR_MASK; + conditions[2].conditionValue.v6AddrMask = &localMulticastAddress; + + conditions[3].fieldKey = FWPM_CONDITION_ICMP_TYPE; + conditions[3].matchType = FWP_MATCH_EQUAL; + conditions[3].conditionValue.type = FWP_UINT16; + conditions[3].conditionValue.uint16 = 135; + conditionCount = 4; + } + + const GUID *key = nullptr; + const wchar_t *name = nullptr; + switch (permit) { + case IcmpV6ControlPlanePermit::ConnectLinkLocal: + key = &kIcmpV6ConnectLinkLocalFilterKey; + name = L"Throne permit link-local ICMPv6 control traffic"; + break; + case IcmpV6ControlPlanePermit::ConnectMulticast: + key = &kIcmpV6ConnectMulticastFilterKey; + name = L"Throne permit multicast ICMPv6 control traffic"; + break; + case IcmpV6ControlPlanePermit::ReceiveLinkLocal: + key = &kIcmpV6ReceiveLinkLocalFilterKey; + name = L"Throne permit received link-local ICMPv6 control traffic"; + break; + case IcmpV6ControlPlanePermit::ReceiveDad: + key = &kIcmpV6ReceiveDadFilterKey; + name = L"Throne permit received ICMPv6 duplicate-address detection"; + break; + } + + FWPM_FILTER0 filter = makeFilter(*key, + receive ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V6, + name, + FWP_ACTION_PERMIT, + kPermitWeight, + true, + conditions.data(), + conditionCount); + return addFilter(engine, &filter, operation); +} + +DWORD addBlockFilter(HANDLE engine, bool ipv6, bool receive, QString *operation) +{ + const GUID &key = receive + ? (ipv6 ? kReceiveBlockV6FilterKey : kReceiveBlockV4FilterKey) + : (ipv6 ? kBlockV6FilterKey : kBlockV4FilterKey); + const GUID &layer = receive + ? (ipv6 ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4) + : (ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V4); + const wchar_t *name = receive + ? (ipv6 ? L"Throne block received direct IPv6" + : L"Throne block received direct IPv4") + : (ipv6 ? L"Throne block direct IPv6" + : L"Throne block direct IPv4"); + FWPM_FILTER0 filter = makeFilter(key, + layer, + name, + FWP_ACTION_BLOCK, + kBlockWeight, + true); + // A hard block prevents lower-priority providers from changing the action. + filter.flags |= FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT; + return addFilter(engine, &filter, operation); +} + +DWORD addPersistentFilters(HANDLE engine, QString *operation) +{ + for (const bool ipv6 : {false, true}) { + DWORD result = addLoopbackFilter(engine, ipv6, false, operation); + if (result != ERROR_SUCCESS) { + return result; + } + result = addDhcpFilter(engine, ipv6, operation); + if (result != ERROR_SUCCESS) { + return result; + } + if (ipv6) { + result = addIcmpV6ControlPlaneFilter( + engine, IcmpV6ControlPlanePermit::ConnectLinkLocal, operation); + if (result != ERROR_SUCCESS) { + return result; + } + result = addIcmpV6ControlPlaneFilter( + engine, IcmpV6ControlPlanePermit::ConnectMulticast, operation); + if (result != ERROR_SUCCESS) { + return result; + } + } + result = addBlockFilter(engine, ipv6, false, operation); + if (result != ERROR_SUCCESS) { + return result; + } + result = addLoopbackFilter(engine, ipv6, true, operation); + if (result != ERROR_SUCCESS) { + return result; + } + if (ipv6) { + result = addIcmpV6ControlPlaneFilter( + engine, IcmpV6ControlPlanePermit::ReceiveLinkLocal, operation); + if (result != ERROR_SUCCESS) { + return result; + } + result = addIcmpV6ControlPlaneFilter( + engine, IcmpV6ControlPlanePermit::ReceiveDad, operation); + if (result != ERROR_SUCCESS) { + return result; + } + } + result = addBlockFilter(engine, ipv6, true, operation); + if (result != ERROR_SUCCESS) { + return result; + } + } + return ERROR_SUCCESS; +} + +DWORD addCoreFilter(HANDLE engine, bool ipv6, FWP_BYTE_BLOB *appId, QString *operation) +{ + FWPM_FILTER_CONDITION0 condition{}; + condition.fieldKey = FWPM_CONDITION_ALE_APP_ID; + condition.matchType = FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_BYTE_BLOB_TYPE; + condition.conditionValue.byteBlob = appId; + + FWPM_FILTER0 filter = makeFilter(ipv6 ? kCoreV6FilterKey : kCoreV4FilterKey, + ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 : FWPM_LAYER_ALE_AUTH_CONNECT_V4, + ipv6 ? L"Throne permit core IPv6" : L"Throne permit core IPv4", + FWP_ACTION_PERMIT, + kPermitWeight, + false, + &condition, + 1); + return addFilter(engine, &filter, operation); +} + +DWORD addTunFilter(HANDLE engine, bool ipv6, bool receive, UINT64 *luid, QString *operation) +{ + FWPM_FILTER_CONDITION0 condition{}; + condition.fieldKey = tunInterfaceCondition(receive); + condition.matchType = FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_UINT64; + condition.conditionValue.uint64 = luid; + + const GUID &key = receive + ? (ipv6 ? kReceiveTunV6FilterKey : kReceiveTunV4FilterKey) + : (ipv6 ? kTunV6FilterKey : kTunV4FilterKey); + const GUID &layer = receive + ? (ipv6 ? FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6 + : FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4) + : (ipv6 ? FWPM_LAYER_ALE_AUTH_CONNECT_V6 + : FWPM_LAYER_ALE_AUTH_CONNECT_V4); + const wchar_t *name = receive + ? (ipv6 ? L"Throne permit received TUN IPv6" + : L"Throne permit received TUN IPv4") + : (ipv6 ? L"Throne permit TUN IPv6" + : L"Throne permit TUN IPv4"); + FWPM_FILTER0 filter = makeFilter(key, + layer, + name, + FWP_ACTION_PERMIT, + kPermitWeight, + false, + &condition, + 1); + return addFilter(engine, &filter, operation); +} + +DWORD deleteTunFilters(HANDLE engine, QString *operation) +{ + DWORD result = deleteFilterIfPresent(engine, kTunV4FilterKey); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete the Throne IPv4 TUN allowance"); + } + return result; + } + result = deleteFilterIfPresent(engine, kTunV6FilterKey); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete the Throne IPv6 TUN allowance"); + } + return result; + } + result = deleteFilterIfPresent(engine, kReceiveTunV4FilterKey); + if (result != ERROR_SUCCESS) { + if (operation != nullptr) { + *operation = QStringLiteral("Delete the Throne received IPv4 TUN allowance"); + } + return result; + } + result = deleteFilterIfPresent(engine, kReceiveTunV6FilterKey); + if (result != ERROR_SUCCESS && operation != nullptr) { + *operation = QStringLiteral("Delete the Throne received IPv6 TUN allowance"); + } + return result; +} + +} // namespace + +class WindowsWfpKillSwitchBackend::Impl +{ +public: + mutable QMutex mutex; + EngineHandle dynamicEngine; + QString coreExecutablePath; +}; + +WindowsWfpKillSwitchBackend::WindowsWfpKillSwitchBackend() : impl_(std::make_unique()) {} + +WindowsWfpKillSwitchBackend::~WindowsWfpKillSwitchBackend() = default; + +WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBaseline() const +{ + QMutexLocker lock(&impl_->mutex); + EngineHandle engine; + DWORD result = openEngine(false, &engine); + if (result != ERROR_SUCCESS) { + return {BaselineState::Error, operationError(QStringLiteral("Open Windows Filtering Platform"), result)}; + } + + bool legacyProviderPresent = false; + QString error; + FWPM_PROVIDER0 *legacyProvider = nullptr; + result = FwpmProviderGetByKey0(engine.get(), &kLegacyProviderKey, &legacyProvider); + WfpMemory legacyProviderMemory(legacyProvider); + if (result == ERROR_SUCCESS) { + legacyProviderPresent = true; + if (legacyProvider == nullptr || + !providerHasKnownLegacyThroneOwnershipMarker(*legacyProvider)) { + return { + BaselineState::Error, + QStringLiteral("A deterministic WFP provider collision is present; Throne will not modify it because it lacks the exact v1-v3 ownership marker"), + }; + } + } else if (result != FWP_E_PROVIDER_NOT_FOUND) { + return { + BaselineState::Error, + operationError(QStringLiteral("Query the legacy Throne WFP provider"), result), + }; + } + + int presentCount = 0; + constexpr int expectedCount = 1 + static_cast(kPersistentFilterKeys.size()); + bool schemaMatches = true; + bool staleDynamicFilterPresent = false; + bool dynamicFilterPresent = false; + bool objectMatches = false; + + objectMatches = false; + const Presence subLayer = getObjectPresence(engine.get(), + kSubLayerKey, + FwpmSubLayerGetByKey0, + subLayerMatchesSchema, + &objectMatches, + FWP_E_SUBLAYER_NOT_FOUND, + &error, + QStringLiteral("Throne WFP sublayer")); + if (subLayer == Presence::Failed) { + return {BaselineState::Error, error}; + } + presentCount += subLayer == Presence::Present ? 1 : 0; + schemaMatches &= subLayer != Presence::Present || objectMatches; + + for (const GUID *key : kPersistentFilterKeys) { + objectMatches = false; + const Presence filter = getObjectPresence(engine.get(), + *key, + FwpmFilterGetByKey0, + [key](const FWPM_FILTER0 &object) { + return persistentFilterMatchesSchema(*key, object); + }, + &objectMatches, + FWP_E_FILTER_NOT_FOUND, + &error, + QStringLiteral("Throne WFP filter")); + if (filter == Presence::Failed) { + return {BaselineState::Error, error}; + } + presentCount += filter == Presence::Present ? 1 : 0; + schemaMatches &= filter != Presence::Present || objectMatches; + } + + for (const GUID *key : kDynamicFilterKeys) { + objectMatches = false; + const Presence filter = getObjectPresence(engine.get(), + *key, + FwpmFilterGetByKey0, + [key](const FWPM_FILTER0 &object) { + return dynamicFilterMatchesSchema(*key, object); + }, + &objectMatches, + FWP_E_FILTER_NOT_FOUND, + &error, + QStringLiteral("Throne dynamic WFP filter")); + if (filter == Presence::Failed) { + return {BaselineState::Error, error}; + } + dynamicFilterPresent |= filter == Presence::Present; + schemaMatches &= filter != Presence::Present || objectMatches; + } + staleDynamicFilterPresent = dynamicFilterPresent && !impl_->dynamicEngine.valid(); + + const bool anyObjectPresent = legacyProviderPresent || presentCount != 0 || + dynamicFilterPresent; + if (anyObjectPresent) { + const MutationOwnershipResult ownership = verifyMutationOwnership(engine.get()); + if (ownership.ownership == MutationOwnership::Foreign || + ownership.ownership == MutationOwnership::Error) { + return {BaselineState::Error, ownership.detail}; + } + } + + if (!anyObjectPresent) { + return {BaselineState::Absent, QStringLiteral("No Throne kill-switch objects are installed")}; + } + if (!legacyProviderPresent && presentCount == expectedCount && schemaMatches && + !staleDynamicFilterPresent) { + return { + BaselineState::Valid, + QStringLiteral("The providerless Throne kill-switch v4 baseline is installed"), + }; + } + if (legacyProviderPresent) { + return { + BaselineState::StaleOrPartial, + QStringLiteral("A marked provider-associated Throne v1-v3 policy requires migration to providerless schema v4"), + }; + } + if (presentCount == expectedCount && schemaMatches && staleDynamicFilterPresent) { + return {BaselineState::StaleOrPartial, + QStringLiteral("The baseline is valid, but stale Throne runtime allowances are present")}; + } + if (presentCount == expectedCount) { + return {BaselineState::StaleOrPartial, + QStringLiteral("All Throne kill-switch objects exist, but at least one uses an obsolete or invalid schema")}; + } + return {BaselineState::StaleOrPartial, + QStringLiteral("Only %1 of %2 expected Throne kill-switch objects are present") + .arg(presentCount) + .arg(expectedCount)}; +} + +bool WindowsWfpKillSwitchBackend::reconcileBaseline(QString *error) +{ + QMutexLocker lock(&impl_->mutex); + if (impl_->dynamicEngine.valid()) { + setError(error, QStringLiteral("Stop the active kill-switch core session before reconciling persistent rules")); + return false; + } + + EngineHandle engine; + DWORD result = openEngine(false, &engine); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Open Windows Filtering Platform"), result)); + return false; + } + + Transaction transaction(engine.get()); + result = transaction.begin(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Begin the kill-switch transaction"), result)); + return false; + } + + const MutationOwnershipResult ownership = verifyMutationOwnership(engine.get()); + if (ownership.ownership == MutationOwnership::Foreign || + ownership.ownership == MutationOwnership::Error) { + setError(error, ownership.detail); + return false; + } + + QString operation; + result = deletePersistentObjects(engine.get(), &operation); + if (result == ERROR_SUCCESS) { + result = addSubLayer(engine.get(), &operation); + } + if (result == ERROR_SUCCESS) { + result = addPersistentFilters(engine.get(), &operation); + } + if (result != ERROR_SUCCESS) { + setError(error, operationError(operation, result)); + return false; + } + + result = transaction.commit(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Commit the kill-switch baseline"), result)); + return false; + } + setError(error, {}); + return true; +} + +Configs_sys::KillSwitchReconcileResult WindowsWfpKillSwitchBackend::reconcile() +{ + const BaselineStatus status = queryBaseline(); + if (status.state == BaselineState::Valid) { + return {Configs_sys::KillSwitchResult::Success(), {true, false, false}}; + } + if (status.state == BaselineState::Error) { + // Failure to query BFE cannot prove that Throne's persistent objects + // are absent. Conservatively report protection as active so the + // controller remains enabled and refuses an unverified transition. + return {Configs_sys::KillSwitchResult::Failure(status.detail), {true, false, false}}; + } + if (status.state == BaselineState::Absent) { + return {Configs_sys::KillSwitchResult::Success(), {}}; + } + + QString error; + if (!reconcileBaseline(&error)) { + // Some persistent Throne objects were observed. Report conservative + // active state even if their exact effectiveness could not be proven. + return {Configs_sys::KillSwitchResult::Failure(error), {true, false, false}}; + } + return {Configs_sys::KillSwitchResult::Success(), {true, false, false}}; +} + +Configs_sys::KillSwitchResult WindowsWfpKillSwitchBackend::ensureBaseline() +{ + const BaselineStatus status = queryBaseline(); + if (status.state == BaselineState::Valid) { + return Configs_sys::KillSwitchResult::Success(); + } + if (status.state == BaselineState::Error) { + return Configs_sys::KillSwitchResult::Failure(status.detail); + } + + QString error; + if (!reconcileBaseline(&error)) { + return Configs_sys::KillSwitchResult::Failure(error); + } + return Configs_sys::KillSwitchResult::Success(); +} + +Configs_sys::KillSwitchResult WindowsWfpKillSwitchBackend::startDynamicCore( + const Configs_sys::KillSwitchTrustedCorePlan &plan) +{ + if (!plan.isValid()) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The trusted core executable plan is empty or invalid")); + } + if (plan.executablePaths.size() != 1) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The Windows kill switch currently supports exactly one trusted core executable")); + } + + QString error; + if (!startCoreSession(plan.executablePaths.constFirst(), &error)) { + return Configs_sys::KillSwitchResult::Failure(error); + } + return Configs_sys::KillSwitchResult::Success(); +} + +bool WindowsWfpKillSwitchBackend::startCoreSession(const QString &absoluteCoreExecutablePath, QString *error) +{ + QMutexLocker lock(&impl_->mutex); + + const QFileInfo executable(absoluteCoreExecutablePath); + if (!executable.isAbsolute() || !executable.exists() || !executable.isFile()) { + setError(error, QStringLiteral("The core executable path is not an existing absolute file: %1") + .arg(absoluteCoreExecutablePath)); + return false; + } + + const QString canonicalPath = executable.canonicalFilePath(); + if (canonicalPath.isEmpty()) { + setError(error, QStringLiteral("The core executable path cannot be canonicalized: %1") + .arg(absoluteCoreExecutablePath)); + return false; + } + if (impl_->dynamicEngine.valid() && + QString::compare(impl_->coreExecutablePath, canonicalPath, Qt::CaseInsensitive) == 0) { + setError(error, {}); + return true; + } + + const QString nativePath = QDir::toNativeSeparators(canonicalPath); + FWP_BYTE_BLOB *appId = nullptr; + DWORD result = FwpmGetAppIdFromFileName0(reinterpret_cast(nativePath.utf16()), &appId); + WfpMemory appIdMemory(appId); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Resolve the WFP application ID for ThroneCore"), result)); + return false; + } + + // For a different trusted executable, close the old exception only after + // the new path has been validated and converted to a WFP app ID. The + // persistent baseline remains active, so a subsequent failure is closed. + if (impl_->dynamicEngine.valid()) { + const DWORD closeResult = impl_->dynamicEngine.reset(); + impl_->coreExecutablePath.clear(); + if (closeResult != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Close the previous kill-switch session"), closeResult)); + return false; + } + } + + EngineHandle dynamicEngine; + result = openEngine(true, &dynamicEngine); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Open the dynamic kill-switch session"), result)); + return false; + } + + Transaction transaction(dynamicEngine.get()); + result = transaction.begin(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Begin the core-allowance transaction"), result)); + return false; + } + + QString operation; + result = addCoreFilter(dynamicEngine.get(), false, appId, &operation); + if (result == ERROR_SUCCESS) { + result = addCoreFilter(dynamicEngine.get(), true, appId, &operation); + } + if (result != ERROR_SUCCESS) { + setError(error, operationError(operation, result)); + return false; + } + + result = transaction.commit(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Commit the core allowances"), result)); + return false; + } + + impl_->dynamicEngine = std::move(dynamicEngine); + impl_->coreExecutablePath = canonicalPath; + setError(error, {}); + return true; +} + +bool WindowsWfpKillSwitchBackend::stopCoreSession(QString *error) +{ + QMutexLocker lock(&impl_->mutex); + if (!impl_->dynamicEngine.valid()) { + setError(error, {}); + return true; + } + const DWORD result = impl_->dynamicEngine.reset(); + impl_->coreExecutablePath.clear(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Close the dynamic kill-switch session"), result)); + return false; + } + setError(error, {}); + return true; +} + +bool WindowsWfpKillSwitchBackend::addTunAllowance(QString *error) +{ + return addTunAllowanceForInterface(tunInterfaceAlias(), true, true, error); +} + +Configs_sys::KillSwitchResult WindowsWfpKillSwitchBackend::addTunAllowance( + const Configs_sys::KillSwitchTunInterface &tunInterface) +{ + if (!tunInterface.isValid()) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The TUN interface identity or address families are invalid")); + } + + NET_LUID interfaceLuid{}; + bool haveLuid = false; + if (tunInterface.interfaceIndex != 0) { + if (tunInterface.interfaceIndex > (std::numeric_limits::max)()) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The TUN interface index is outside the Windows NET_IFINDEX range")); + } + const NETIO_STATUS result = ConvertInterfaceIndexToLuid( + static_cast(tunInterface.interfaceIndex), &interfaceLuid); + if (result != NO_ERROR) { + return Configs_sys::KillSwitchResult::Failure( + operationError(QStringLiteral("Resolve TUN interface index %1").arg(tunInterface.interfaceIndex), + result)); + } + haveLuid = true; + } + + if (!tunInterface.name.trimmed().isEmpty()) { + NET_LUID namedLuid{}; + const std::wstring alias = tunInterface.name.toStdWString(); + const NETIO_STATUS result = ConvertInterfaceAliasToLuid(alias.c_str(), &namedLuid); + if (result != NO_ERROR) { + return Configs_sys::KillSwitchResult::Failure( + operationError(QStringLiteral("Resolve TUN interface '%1'").arg(tunInterface.name), result)); + } + if (haveLuid && namedLuid.Value != interfaceLuid.Value) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The TUN interface name and index identify different Windows interfaces")); + } + interfaceLuid = namedLuid; + haveLuid = true; + } + + if (!haveLuid) { + return Configs_sys::KillSwitchResult::Failure( + QStringLiteral("The TUN interface could not be resolved to a Windows LUID")); + } + + QString error; + if (!addTunAllowanceForLuidValue(interfaceLuid.Value, + tunInterface.ipv4, + tunInterface.ipv6, + &error)) { + return Configs_sys::KillSwitchResult::Failure(error); + } + return Configs_sys::KillSwitchResult::Success(); +} + +bool WindowsWfpKillSwitchBackend::addTunAllowanceForInterface(const QString &interfaceAlias, + bool allowIPv4, + bool allowIPv6, + QString *error) +{ + if (interfaceAlias.trimmed().isEmpty()) { + setError(error, QStringLiteral("The TUN interface alias is empty")); + return false; + } + + NET_LUID interfaceLuid{}; + const std::wstring alias = interfaceAlias.toStdWString(); + const NETIO_STATUS luidResult = ConvertInterfaceAliasToLuid(alias.c_str(), &interfaceLuid); + if (luidResult != NO_ERROR) { + setError(error, operationError(QStringLiteral("Resolve TUN interface '%1'").arg(interfaceAlias), luidResult)); + return false; + } + + return addTunAllowanceForLuidValue(interfaceLuid.Value, allowIPv4, allowIPv6, error); +} + +bool WindowsWfpKillSwitchBackend::addTunAllowanceForLuidValue(quint64 interfaceLuid, + bool allowIPv4, + bool allowIPv6, + QString *error) +{ + QMutexLocker lock(&impl_->mutex); + if (!impl_->dynamicEngine.valid()) { + setError(error, QStringLiteral("The dynamic kill-switch core session is not active")); + return false; + } + if (!allowIPv4 && !allowIPv6) { + setError(error, QStringLiteral("At least one TUN address family must be allowed")); + return false; + } + + Transaction transaction(impl_->dynamicEngine.get()); + DWORD result = transaction.begin(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Begin the TUN-allowance transaction"), result)); + return false; + } + + QString operation; + result = deleteTunFilters(impl_->dynamicEngine.get(), &operation); + UINT64 luidValue = interfaceLuid; + if (result == ERROR_SUCCESS && allowIPv4) { + result = addTunFilter(impl_->dynamicEngine.get(), false, false, &luidValue, &operation); + } + if (result == ERROR_SUCCESS && allowIPv4) { + result = addTunFilter(impl_->dynamicEngine.get(), false, true, &luidValue, &operation); + } + if (result == ERROR_SUCCESS && allowIPv6) { + result = addTunFilter(impl_->dynamicEngine.get(), true, false, &luidValue, &operation); + } + if (result == ERROR_SUCCESS && allowIPv6) { + result = addTunFilter(impl_->dynamicEngine.get(), true, true, &luidValue, &operation); + } + if (result != ERROR_SUCCESS) { + setError(error, operationError(operation, result)); + return false; + } + + result = transaction.commit(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Commit the TUN allowances"), result)); + return false; + } + setError(error, {}); + return true; +} + +bool WindowsWfpKillSwitchBackend::removeTunAllowanceImpl(QString *error) +{ + QMutexLocker lock(&impl_->mutex); + if (!impl_->dynamicEngine.valid()) { + setError(error, {}); + return true; + } + + Transaction transaction(impl_->dynamicEngine.get()); + DWORD result = transaction.begin(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Begin removal of TUN allowances"), result)); + return false; + } + + QString operation; + result = deleteTunFilters(impl_->dynamicEngine.get(), &operation); + if (result != ERROR_SUCCESS) { + setError(error, operationError(operation, result)); + return false; + } + + result = transaction.commit(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Commit removal of TUN allowances"), result)); + return false; + } + setError(error, {}); + return true; +} + +Configs_sys::KillSwitchResult WindowsWfpKillSwitchBackend::removeTunAllowance() +{ + QString error; + if (!removeTunAllowanceImpl(&error)) { + return Configs_sys::KillSwitchResult::Failure(error); + } + return Configs_sys::KillSwitchResult::Success(); +} + +bool WindowsWfpKillSwitchBackend::disableImpl(QString *error) +{ + QMutexLocker lock(&impl_->mutex); + if (impl_->dynamicEngine.valid()) { + const DWORD closeResult = impl_->dynamicEngine.reset(); + impl_->coreExecutablePath.clear(); + if (closeResult != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Close the dynamic kill-switch session"), closeResult)); + return false; + } + } + + EngineHandle engine; + DWORD result = openEngine(false, &engine); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Open Windows Filtering Platform"), result)); + return false; + } + + Transaction transaction(engine.get()); + result = transaction.begin(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Begin removal of the kill-switch baseline"), result)); + return false; + } + + const MutationOwnershipResult ownership = verifyMutationOwnership(engine.get()); + if (ownership.ownership == MutationOwnership::Foreign || + ownership.ownership == MutationOwnership::Error) { + setError(error, ownership.detail); + return false; + } + + QString operation; + result = deletePersistentObjects(engine.get(), &operation); + if (result != ERROR_SUCCESS) { + setError(error, operationError(operation, result)); + return false; + } + + result = transaction.commit(); + if (result != ERROR_SUCCESS) { + setError(error, operationError(QStringLiteral("Commit removal of the kill-switch baseline"), result)); + return false; + } + setError(error, {}); + return true; +} + +Configs_sys::KillSwitchResult WindowsWfpKillSwitchBackend::disable() +{ + QString error; + if (!disableImpl(&error)) { + return Configs_sys::KillSwitchResult::Failure(error); + } + return Configs_sys::KillSwitchResult::Success(); +} + +bool WindowsWfpKillSwitchBackend::coreSessionActive() const +{ + QMutexLocker lock(&impl_->mutex); + return impl_->dynamicEngine.valid(); +} + +QString WindowsWfpKillSwitchBackend::tunInterfaceAlias() +{ + return QStringLiteral("throne-tun"); +} diff --git a/src/ui/mainWindow/TestRunner.cpp b/src/ui/mainWindow/TestRunner.cpp index aad247db6..233c98447 100644 --- a/src/ui/mainWindow/TestRunner.cpp +++ b/src/ui/mainWindow/TestRunner.cpp @@ -84,8 +84,8 @@ namespace { } bool TestRunner::isRunning() { - if (!session_.tryLock()) return true; - session_.unlock(); + if (!session_.tryAcquire()) return true; + session_.release(); return false; } @@ -280,7 +280,18 @@ void TestRunner::runLatencyGroup(LatencyKind kind, const QList& requestedID finish(); return; } - if (!session_.tryLock()) { + // A kill-switch setting change owns this same gate while it updates the OS + // policy and persisted preference. Hold it from before any test config is + // built until the final RPC completes, so tests cannot straddle policies. + if (!mw_->testActivityGate.tryAcquire()) { + MessageBoxWarning( + software_name, + MainWindow::tr("Wait for the current kill-switch policy change or connectivity test to finish.")); + finish(); + return; + } + if (!session_.tryAcquire()) { + mw_->testActivityGate.release(); MessageBoxWarning(software_name, isUrl ? MainWindow::tr("The last url test did not exit completely, please wait. If it persists, please restart the program.") : MainWindow::tr("The last test did not exit completely, please wait. If it persists, please restart the program.")); @@ -353,7 +364,8 @@ void TestRunner::runLatencyGroup(LatencyKind kind, const QList& requestedID mw_->dataViewHtmlGenerator_.clearTestSections(); mw_->UpdateDataView(true); - session_.unlock(); + session_.release(); + mw_->testActivityGate.release(); // Signalled with the session free so a waiter can start work of its own. finish(); @@ -376,7 +388,14 @@ void TestRunner::runSpeedTests(const QList& requestedIDs, bool testCurrent) if (profileIDs.isEmpty() && !testCurrent) { return; } - if (!session_.tryLock()) { + if (!mw_->testActivityGate.tryAcquire()) { + MessageBoxWarning( + software_name, + MainWindow::tr("Wait for the current kill-switch policy change or connectivity test to finish.")); + return; + } + if (!session_.tryAcquire()) { + mw_->testActivityGate.release(); MessageBoxWarning(software_name, MainWindow::tr("The last test did not finish completely, please wait. If it persists, please restart the program.")); return; } @@ -435,7 +454,8 @@ void TestRunner::runSpeedTests(const QList& requestedIDs, bool testCurrent) } mw_->dataViewHtmlGenerator_.clearTestSections(); mw_->UpdateDataView(true); - session_.unlock(); + session_.release(); + mw_->testActivityGate.release(); runOnUiThread([=,this]{ mw_->refresh_proxy_list(profileIDs); MW_show_log(MainWindow::tr("Speedtest finished!")); diff --git a/src/ui/mainWindow/mainwindow_profile_lifecycle.cpp b/src/ui/mainWindow/mainwindow_profile_lifecycle.cpp index 162ac26db..3b2eb8c30 100644 --- a/src/ui/mainWindow/mainwindow_profile_lifecycle.cpp +++ b/src/ui/mainWindow/mainwindow_profile_lifecycle.cpp @@ -225,12 +225,73 @@ void MainWindow::profile_start(int _id) { } auto_selector_ranked = false; + // Own the complete build-to-ready interval. Kill-switch changes acquire + // this same gate, so a config cannot be generated with the old DNS/route + // policy and become active after fail-closed protection changes. + if (!mu_starting.tryAcquire()) { + MessageBoxWarning(software_name, tr("Another profile is starting...")); + return; + } + +#ifdef Q_OS_WIN + if (killSwitchActive()) { + const auto isOpaqueProfile = [](const std::shared_ptr &profile) { + if (profile == nullptr || profile->outbound == nullptr) return false; + if (profile->outbound->IsExtraCore() || profile->outbound->IsXrayFullConfig()) { + return true; + } + const auto custom = profile->Custom(); + return custom != nullptr && custom->type == Configs::Custom::CustomFullConfig; + }; + const auto containsOpaqueProfile = [&](const std::shared_ptr &profile) { + if (isOpaqueProfile(profile)) return true; + const auto chain = profile != nullptr ? profile->Chain() : nullptr; + if (chain == nullptr) return false; + for (const int profileId : chain->list) { + if (isOpaqueProfile(Configs::dataManager->profilesRepo->GetProfile(profileId))) { + return true; + } + } + return false; + }; + if (containsOpaqueProfile(ent)) { + MessageBoxWarning( + tr("Kill switch blocked profile"), + tr("ExtraCore and custom full-config profiles are not supported while " + "the kill switch is active because their direct-routing and DNS " + "behavior cannot be verified safely.")); + mu_starting.release(); + return; + } + } +#endif + const auto result = Configs::BuildSingBoxConfig(ent); if (!result->error.isEmpty()) { MessageBoxWarning(tr("BuildConfig return error"), result->error); + mu_starting.release(); + return; + } + + // ExtraCore is an arbitrary child executable. Granting it unrestricted + // physical-network access would defeat the trusted-core boundary, while + // its destinations cannot be derived safely from opaque arguments/config. + if (killSwitchActive() && + (result->hasUnverifiableNetworkConfig || + !result->extraCoreData->path.isEmpty())) { + MessageBoxWarning( + tr("Kill switch blocked profile"), + tr("Direct, SOCKS4, Tailscale, ExtraCore, and custom profiles are not " + "supported while the kill switch is active because their direct-routing " + "or destination-DNS behavior cannot be constrained safely.")); + mu_starting.release(); return; } + const auto killSwitchOperationId = std::make_shared(0); + const auto profileStartFailure = std::make_shared(); + const auto unusableProfileMayBeRunning = std::make_shared(false); + auto profile_start_stage2 = [=, this] { libcore::LoadConfigReq req; req.core_config = QJsonObject2QString(result->coreConfig, true).toStdString(); @@ -265,9 +326,11 @@ void MainWindow::profile_start(int _id) { bool rpcOK; const QString error = defaultClient->Start(&rpcOK, req); if (!rpcOK) { + *profileStartFailure = tr("ThroneCore did not answer the profile start request."); return false; } if (!error.isEmpty()) { + *profileStartFailure = error; // Fail now and let handleXrayGeoAssetError prompt asynchronously; blocking to // download would trip the "no response" restart prompt. Starting again picks them up. if (handleXrayGeoAssetError(error, ent->outbound->DisplayTypeAndName())) { @@ -309,6 +372,24 @@ void MainWindow::profile_start(int _id) { runOnUiThread([=, this] { MessageBoxWarning("LoadConfig return error", error); }); return false; } + + QString readyError; + if (!finishKillSwitchProfileStart(*killSwitchOperationId, &readyError)) { + // RPC Start created a Box, but without the narrowly scoped TUN + // allowance the connection must not be published as ready. Stop it + // while the persistent block remains installed. + bool stopRpcOK = false; + const QString stopError = defaultClient->Stop(&stopRpcOK); + *unusableProfileMayBeRunning = !stopRpcOK || !stopError.isEmpty(); + *profileStartFailure = readyError; + if (!stopRpcOK || !stopError.isEmpty()) { + *profileStartFailure += + tr("; failed to stop the unusable profile: %1") + .arg(stopRpcOK ? stopError + : tr("ThroneCore did not answer")); + } + return false; + } Stats::trafficLooper->SetChainGroups(result->chainGroups); Stats::trafficLooper->loop_enabled = true; Stats::connection_lister->suspend = false; @@ -362,16 +443,12 @@ void MainWindow::profile_start(int _id) { return true; }; - if (!mu_starting.tryLock()) { - MessageBoxWarning(software_name, tr("Another profile is starting...")); - return; - } - if (!mu_stopping.tryLock()) { + if (!mu_stopping.tryAcquire()) { MessageBoxWarning(software_name, tr("Another profile is stopping...")); - mu_starting.unlock(); + mu_starting.release(); return; } - mu_stopping.unlock(); + mu_stopping.release(); // check core state if (!Configs::dataManager->settingsRepo->core_running) { @@ -382,10 +459,22 @@ void MainWindow::profile_start(int _id) { core_process->Restart(); }, DS_cores); - mu_starting.unlock(); + mu_starting.release(); return; // let CoreProcess call profile_start when core is up } + QString killSwitchError; + const bool switchingProfile = running != nullptr; + if (!prepareKillSwitchProfileStart(switchingProfile, + killSwitchOperationId.get(), + &killSwitchError)) { + runOnUiThread([=, this] { + MessageBoxWarning(tr("Kill switch blocked transition"), killSwitchError); + }); + mu_starting.release(); + return; + } + // timeout message const auto restartMsgbox = new QMessageBox(QMessageBox::Question, software_name, tr("If there is no response for a long time, it is recommended to restart the software."), QMessageBox::Yes | QMessageBox::No, this); @@ -402,15 +491,36 @@ void MainWindow::profile_start(int _id) { // stop current running if (running != nullptr) { profile_stop(false, false, true); - mu_stopping.lock(); - mu_stopping.unlock(); + mu_stopping.acquire(); + mu_stopping.release(); + if (running != nullptr) { + MW_show_log("<<<<<<<< " + + tr("Profile switch cancelled because the current profile could not be stopped safely.")); + failKillSwitchProfileStart( + *killSwitchOperationId, + tr("Current profile could not be stopped safely during the switch.")); + mu_starting.release(); + runOnUiThread([=, this] { + restartMsgboxTimer->cancel(); + restartMsgboxTimer->deleteLater(); + restartMsgbox->deleteLater(); + m_profileConnecting = false; + refresh_startstop_button(); + }); + return; + } } // do start MW_show_log(">>>>>>>> " + tr("Starting profile %1").arg(ent->outbound->DisplayTypeAndName())); if (!profile_start_stage2()) { + failKillSwitchProfileStart(*killSwitchOperationId, + profileStartFailure->isEmpty() + ? tr("Profile failed to start") + : *profileStartFailure, + *unusableProfileMayBeRunning); MW_show_log("<<<<<<<< " + tr("Failed to start profile %1").arg(ent->outbound->DisplayTypeAndName())); } - mu_starting.unlock(); + mu_starting.release(); // cancel timeout runOnUiThread([=, this] { restartMsgboxTimer->cancel(); @@ -423,11 +533,12 @@ void MainWindow::profile_start(int _id) { }); } -void MainWindow::profile_stop(bool crash, bool block, bool manual) { +bool MainWindow::profile_stop(bool crash, bool block, bool manual) { if (running == nullptr) { - return; + return true; } const auto id = running->id; + const auto profileStopFailure = std::make_shared(); auto profile_stop_stage2 = [=,this] { if (testRunner->isTestingCurrent()) { @@ -439,9 +550,12 @@ void MainWindow::profile_stop(bool crash, bool block, bool manual) { bool rpcOK; const QString error = defaultClient->Stop(&rpcOK); if (rpcOK && !error.isEmpty()) { + *profileStopFailure = error; runOnUiThread([=,this] { MessageBoxWarning(tr("Stop return error"), error); }); return false; } else if (!rpcOK) { + *profileStopFailure = + tr("ThroneCore did not answer the profile stop request."); return false; } } @@ -449,10 +563,21 @@ void MainWindow::profile_stop(bool crash, bool block, bool manual) { return true; }; - if (!mu_stopping.tryLock()) { - return; + if (!mu_stopping.tryAcquire()) { + return false; } + QString killSwitchError; + if (!prepareKillSwitchProfileStop(&killSwitchError)) { + mu_stopping.release(); + runOnUiThread([=, this] { + MessageBoxWarning(tr("Kill switch blocked disconnect"), killSwitchError); + }); + return false; + } + + const auto stopSucceeded = std::make_shared(false); + UpdateConnectionListWithRecreate({}); // Show a "Disconnecting" spinner immediately; the stop itself can lag. @@ -492,12 +617,21 @@ void MainWindow::profile_stop(bool crash, bool block, bool manual) { if (stopping != nullptr) { MW_show_log(">>>>>>>> " + tr("Stopping profile %1").arg(stopping->outbound->DisplayTypeAndName())); } - if (!profile_stop_stage2()) { + const bool stopped = profile_stop_stage2(); + stopSucceeded->store(stopped); + if (!stopped) { + failKillSwitchProfileStop( + profileStopFailure->isEmpty() ? tr("Profile failed to stop") + : *profileStopFailure); MW_show_log("<<<<<<<< " + tr("Failed to stop, please restart the program.")); + } else { + finishKillSwitchProfileStop(); } - if (manual) Configs::dataManager->settingsRepo->UpdateStartedId(Configs::NoProfileId); - running = nullptr; + if (stopped) { + if (manual) Configs::dataManager->settingsRepo->UpdateStartedId(Configs::NoProfileId); + running = nullptr; + } runOnUiThread([=, this, &restartMsgboxTimer, &restartMsgbox] { if (restartMsgboxTimer != nullptr) { @@ -510,7 +644,12 @@ void MainWindow::profile_stop(bool crash, bool block, bool manual) { refresh_status(); refresh_proxy_list({id}); - mu_stopping.unlock(); + mu_stopping.release(); }, true); }, block); + + // For asynchronous callers, successful preparation/queueing is the only + // result available here. Blocking force-reset callers also receive the + // actual stop result and must not kill the core when it is false. + return !block || stopSucceeded->load(); } diff --git a/src/ui/mainWindow/mainwindow_setup.cpp b/src/ui/mainWindow/mainwindow_setup.cpp index 7bec18767..4dcb57e56 100644 --- a/src/ui/mainWindow/mainwindow_setup.cpp +++ b/src/ui/mainWindow/mainwindow_setup.cpp @@ -15,6 +15,10 @@ #include "include/sys/Process.hpp" #include "include/sys/AutoRun.hpp" #include "include/sys/UrlScheme.hpp" +#ifdef Q_OS_WIN +#include "include/sys/KillSwitchController.hpp" +#include "include/sys/windows/WindowsWfpKillSwitchBackend.h" +#endif #include "include/ui/setting/ThemeManager.hpp" #include "include/ui/setting/Icon.hpp" @@ -197,6 +201,15 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi Logging::WriteUserLog(log); }; + // Reconcile persistent fail-closed policy before ThroneCore or any profile + // can create direct sockets. If requested or stale policy cannot be + // reconciled, leave any persistent block intact and exit into the explicit + // recovery flow instead of launching the trusted core unaudited. + if (!initializeKillSwitch()) { + QTimer::singleShot(0, [] { QCoreApplication::quit(); }); + return; + } + // Listen port if random if (Configs::dataManager->settingsRepo->random_inbound_port) { @@ -753,10 +766,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi connect(ui->actionHide_window, &QAction::triggered, this, [=, this](){ HideWindow(this); }); connect(ui->menu_open_config_folder, &QAction::triggered, this, [=,this] { QDesktopServices::openUrl(QUrl::fromLocalFile(QDir::currentPath())); }); connect(ui->actionRestart_Proxy, &QAction::triggered, this, [=,this] { - runOnThread([=, this] { - profile_stop(true, true, true); - core_process->Kill(); - }, DS_cores); + if (!StopVPNProcess(true)) { + MessageBoxWarning( + tr("Kill switch blocked core restart"), + tr("Throne did not stop the core because the current profile " + "transition or fail-closed preparation could not be completed safely.")); + } }); connect(ui->actionRestart_Program, &QAction::triggered, this, [=,this] { MW_dialog_message(MwMessage::RestartProgram, {}); }); connect(ui->actionShow_window, &QAction::triggered, this, [=,this] { ActivateWindow(this); }); diff --git a/src/ui/mainWindow/mainwindow_system.cpp b/src/ui/mainWindow/mainwindow_system.cpp index f1a7504b0..591d225e0 100644 --- a/src/ui/mainWindow/mainwindow_system.cpp +++ b/src/ui/mainWindow/mainwindow_system.cpp @@ -105,7 +105,7 @@ void MainWindow::on_commitDataRequest() { qDebug() << "End of data save"; } -void MainWindow::prepare_exit() +bool MainWindow::prepare_exit() { qDebug() << "prepare for exit..."; mu_exit.lock(); @@ -113,7 +113,20 @@ void MainWindow::prepare_exit() { qDebug() << "prepare exit had already succeeded, ignoring..."; mu_exit.unlock(); - return; + return true; + } + + QString killSwitchError; + if (!prepareKillSwitchExit(&killSwitchError)) { + mu_exit.unlock(); + MW_show_log(tr("Exit cancelled because fail-closed protection could not be prepared: %1") + .arg(killSwitchError)); + MessageBoxWarning( + tr("Kill switch blocked exit"), + tr("Throne could not safely remove the active TUN allowance before exit. " + "The application will stay open.\n\n%1") + .arg(killSwitchError)); + return false; } Configs::dataManager->settingsRepo->prepare_exit = true; LOG_INFO("prepare_exit started, tearing down proxy/tun/core"); @@ -137,10 +150,11 @@ void MainWindow::prepare_exit() mu_exit.unlock(); qDebug() << "prepare exit done!"; + return true; } void MainWindow::on_menu_exit_triggered() { - prepare_exit(); + if (!prepare_exit()) return; // if (exit_reason == ExitReason::RunUpdater) { QDir::setCurrent(QApplication::applicationDirPath()); @@ -304,12 +318,46 @@ void MainWindow::set_spmode_vpn(bool enable, bool save) { if (Configs::dataManager->settingsRepo->started_id >= 0) profile_start(Configs::dataManager->settingsRepo->started_id); } -bool MainWindow::StopVPNProcess() { +bool MainWindow::StopVPNProcess(const bool clearStartedProfile) { + // Serialize a force-reset with the complete profile build-to-ready + // interval. Otherwise a core could be killed after a config was built but + // before its protected operation acquired the TUN allowance. + if (!mu_starting.tryAcquire()) { + MW_show_log(tr("Core reset refused while a profile is starting.")); + return false; + } + + bool safelyPrepared = true; + if (running != nullptr) { + // A crash-style stop avoids relying on an unresponsive core RPC, but + // still verifies the persistent baseline and removes the TUN allowance + // before the process owning the adapter is terminated. + safelyPrepared = profile_stop(true, true, clearStartedProfile); + } else { + QString killSwitchError; + safelyPrepared = prepareKillSwitchProfileStop(&killSwitchError); + if (safelyPrepared) { + finishKillSwitchProfileStop(); + if (clearStartedProfile) { + Configs::dataManager->settingsRepo->UpdateStartedId(Configs::NoProfileId); + } + } else { + MW_show_log(tr("Core reset refused by the kill switch: %1") + .arg(killSwitchError)); + } + } + + if (!safelyPrepared) { + mu_starting.release(); + return false; + } + runOnThread([=, this] { core_process->Kill(); }, DS_cores, true); + mu_starting.release(); return true; } diff --git a/src/ui/mainwindow_killswitch.cpp b/src/ui/mainwindow_killswitch.cpp new file mode 100644 index 000000000..502475c1c --- /dev/null +++ b/src/ui/mainwindow_killswitch.cpp @@ -0,0 +1,510 @@ +#include "include/ui/mainwindow.h" + +#include "include/global/Configs.hpp" +#include "include/sys/KillSwitchController.hpp" +#include "include/sys/Process.hpp" + +#ifdef Q_OS_WIN +#include "3rdparty/WinCommander.hpp" +#include "include/sys/windows/WindowsWfpKillSwitchBackend.h" +#endif + +#include +#include +#include +#include + +#include +#include + +namespace { + +class ActivityLockGuard final +{ +public: + ~ActivityLockGuard() + { + for (auto iterator = locked_.rbegin(); iterator != locked_.rend(); ++iterator) { + (*iterator)->release(); + } + } + + bool tryLock(QSemaphore &semaphore) + { + if (!semaphore.tryAcquire()) { + return false; + } + locked_.push_back(&semaphore); + return true; + } + +private: + std::vector locked_; +}; + +void assignError(QString *target, const QString &error) +{ + if (target != nullptr) { + *target = error; + } +} + +#ifdef Q_OS_WIN +std::optional currentTunInterface() +{ + if (!Configs::dataManager->settingsRepo->spmode_vpn) { + return std::nullopt; + } + return Configs_sys::KillSwitchTunInterface{ + WindowsWfpKillSwitchBackend::tunInterfaceAlias(), + 0, + true, + Configs::dataManager->settingsRepo->vpn_ipv6, + }; +} +#endif + +} // namespace + +bool MainWindow::initializeKillSwitch() +{ +#ifdef Q_OS_WIN + killSwitchBackend = std::make_unique(); + killSwitchController = + std::make_unique(*killSwitchBackend); + + Configs_sys::KillSwitchTrustedCorePlan corePlan; + corePlan.executablePaths << Configs::FindCoreRealPath(); + const auto initialized = killSwitchController->initialize( + Configs::dataManager->settingsRepo->kill_switch_enabled, + std::move(corePlan)); + + if (initialized && initialized.enabled && + !Configs::dataManager->settingsRepo->kill_switch_enabled) { + Configs::dataManager->settingsRepo->kill_switch_enabled = true; + Configs::dataManager->settingsRepo->Save(); + } + if (initialized.recoveredStaleProtection) { + MW_show_log(tr("Recovered persistent Throne kill-switch state.")); + } + if (!initialized) { + MW_show_log(tr("Failed to initialize kill switch: %1") + .arg(initialized.result.error)); + QMessageBox::critical( + this, + tr("Kill switch unavailable"), + tr("Throne could not establish or recover fail-closed protection. " + "Throne will exit before starting its core.\n\n%1\n\n" + "Run Throne as Administrator and try again, or use " + "--disable-kill-switch to recover connectivity.") + .arg(initialized.result.error)); + return false; + } + if (initialized.enabled) { + MW_show_log(tr("Kill switch enabled (persistent IPv4 and IPv6 blocking active).")); + } +#endif + return true; +} + +bool MainWindow::killSwitchActive() const +{ +#ifdef Q_OS_WIN + return killSwitchController && killSwitchController->snapshot().enabled; +#else + return false; +#endif +} + +bool MainWindow::setKillSwitchEnabled(const bool enable, QString *error) +{ +#ifdef Q_OS_WIN + if (!killSwitchController) { + assignError(error, tr("The kill-switch manager is not initialized.")); + return false; + } + if (enable && Configs::dataManager->settingsRepo->flag_many) { + assignError(error, + tr("The kill switch cannot be enabled in multiple-instance mode. " + "Restart Throne without -many first.")); + return false; + } + + // A profile/test config built before this preference changes may contain + // direct routes or local DNS that are forbidden under fail-closed policy. + // Hold every activity gate across the OS transaction and settings update, + // so such a config cannot become active after the policy changes (and a + // disable cannot remove protection midway through a transition). + ActivityLockGuard activityLocks; + if (!activityLocks.tryLock(mu_starting) || + !activityLocks.tryLock(mu_stopping) || + !activityLocks.tryLock(testActivityGate)) { + assignError( + error, + tr("Wait for the current profile transition or connectivity test to " + "finish before changing the kill switch.")); + return false; + } + if (enable && running != nullptr) { + assignError( + error, + tr("Disconnect the current profile before enabling the kill switch. " + "The profile must be rebuilt under fail-closed DNS and routing rules.")); + return false; + } + + const auto runMaintenanceHelper = [](const QString &operation) { + auto helperArguments = Configs::dataManager->settingsRepo->argv; + if (!helperArguments.isEmpty()) { + helperArguments.removeFirst(); + } + helperArguments.removeAll("--disable-kill-switch"); + helperArguments.removeAll("--prepare-kill-switch"); + helperArguments.removeAll("--quiet"); + helperArguments << operation << "--quiet"; + return WinCommander::runProcessElevated( + QApplication::applicationFilePath(), helperArguments, + QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + }; + + if (enable && !Configs::IsAdmin()) { + const uint helperResult = runMaintenanceHelper("--prepare-kill-switch"); + if (helperResult != 0) { + assignError(error, + tr("Administrator permission is required to install the kill switch.")); + return false; + } + + // The helper installed the baseline before persisting true. From this + // point until the elevated replacement starts, losing connectivity is + // intentional fail-closed behavior. + Configs::dataManager->settingsRepo->kill_switch_enabled = true; + Configs::dataManager->settingsRepo->Save(); + + auto restartArguments = Configs::dataManager->settingsRepo->argv; + if (!restartArguments.isEmpty()) { + restartArguments.removeFirst(); + } + restartArguments.removeAll("--disable-kill-switch"); + restartArguments.removeAll("--prepare-kill-switch"); + restartArguments.removeAll("--quiet"); + restartArguments << "--wait-for-process" + << QString::number(QCoreApplication::applicationPid()); + const uint restartResult = WinCommander::runProcessElevated( + QApplication::applicationFilePath(), restartArguments, + QApplication::applicationDirPath(), WinCommander::SW_NORMAL, false); + if (restartResult == static_cast(-1)) { + const uint rollbackResult = + runMaintenanceHelper("--disable-kill-switch"); + if (rollbackResult == 0) { + Configs::dataManager->settingsRepo->kill_switch_enabled = false; + Configs::dataManager->settingsRepo->Save(); + } + assignError( + error, + rollbackResult == 0 + ? tr("The elevated Throne restart failed; the newly installed kill " + "switch was rolled back safely.") + : tr("Fail-closed rules are active, but the elevated Throne restart " + "and automatic rollback failed. Start Throne as Administrator " + "or run --disable-kill-switch.")); + return false; + } + + MW_show_log(tr("Kill switch enabled; restarting Throne with Administrator privileges.")); + // Let the settings dialog finish applying and saving all fields first. + // The elevated replacement waits for this process before reading them. + QTimer::singleShot(0, this, [this] { + if (prepare_exit()) { + QCoreApplication::quit(); + } + }); + return true; + } + + if (!enable && !Configs::IsAdmin()) { + const uint helperResult = runMaintenanceHelper("--disable-kill-switch"); + if (helperResult != 0) { + assignError(error, + tr("Administrator permission is required to remove the kill switch.")); + return false; + } + Configs::dataManager->settingsRepo->kill_switch_enabled = false; + Configs::dataManager->settingsRepo->Save(); + MW_show_log(tr("Kill switch disabled.")); + assignError(error, {}); + return true; + } + + if (enable) { + const auto enabled = killSwitchController->enable(); + if (!enabled) { + const auto snapshot = killSwitchController->snapshot(); + if (snapshot.backend.baselineActive) { + // The OS is already blocking. Persist that security-relevant + // reality so the next launch reconciles it instead of assuming + // the user's network is unprotected. + Configs::dataManager->settingsRepo->kill_switch_enabled = true; + Configs::dataManager->settingsRepo->Save(); + } else { + const auto rolledBack = killSwitchController->disable(); + if (!rolledBack) { + // A failed rollback means the backend can no longer prove + // that no Throne policy remains. Keep the preference in + // sync with that conservative controller state so the + // checkbox and next startup both expose a recovery path. + Configs::dataManager->settingsRepo->kill_switch_enabled = true; + Configs::dataManager->settingsRepo->Save(); + assignError(error, + tr("%1; automatic rollback also failed: %2") + .arg(enabled.error, rolledBack.error)); + return false; + } + } + assignError(error, enabled.error); + return false; + } + + Configs::dataManager->settingsRepo->kill_switch_enabled = true; + Configs::dataManager->settingsRepo->Save(); + + MW_show_log(tr("Kill switch enabled.")); + assignError(error, {}); + return true; + } + + const auto disabled = killSwitchController->disable(); + if (!disabled) { + assignError(error, disabled.error); + MW_show_log(tr("Failed to disable kill switch: %1").arg(disabled.error)); + return false; + } + Configs::dataManager->settingsRepo->kill_switch_enabled = false; + Configs::dataManager->settingsRepo->Save(); + MW_show_log(tr("Kill switch disabled.")); + assignError(error, {}); + return true; +#else + Q_UNUSED(enable) + assignError(error, tr("Kill switch is currently supported on Windows only.")); + return false; +#endif +} + +bool MainWindow::prepareKillSwitchProfileStart(const bool switching, + quint64 *operationId, + QString *error) +{ + if (operationId != nullptr) { + *operationId = 0; + } +#ifdef Q_OS_WIN + if (!killSwitchController || !killSwitchController->snapshot().enabled) { + return true; + } + + const auto snapshot = killSwitchController->snapshot(); + killSwitchPreviousProfileUsedTun = snapshot.backend.tunAllowanceActive; + killSwitchPreviousTunIpv6 = snapshot.allowedTun.ipv6; + auto intent = switching ? Configs_sys::KillSwitchController::StartIntent::Switch + : Configs_sys::KillSwitchController::StartIntent::Connect; + // CoreProcess::Restart keeps the logical running profile until the fresh + // core is ready. Treat that path as a reconnect even though `running` is + // still non-null; the nested stop will simply clear the stale instance. + if (snapshot.state == + Configs_sys::KillSwitchController::State::Reconnecting) { + intent = Configs_sys::KillSwitchController::StartIntent::Reconnect; + } + const auto prepared = killSwitchController->prepareForProfileStart(intent); + if (!prepared) { + assignError(error, prepared.error); + MW_show_log(tr("Kill switch refused an unsafe profile transition: %1") + .arg(prepared.error)); + return false; + } + if (operationId != nullptr) { + *operationId = prepared.operationId; + } + MW_show_log(switching + ? tr("Switching profile while kill switch remains active.") + : tr("Connecting while kill switch blocks direct traffic.")); +#else + Q_UNUSED(switching) +#endif + assignError(error, {}); + return true; +} + +bool MainWindow::finishKillSwitchProfileStart(const quint64 operationId, + QString *error) +{ +#ifdef Q_OS_WIN + if (!killSwitchController || operationId == 0) { + return true; + } + + Configs_sys::KillSwitchResult ready; + // RPC Start returns after the Box and TUN startup, but interface discovery + // can lag very briefly. Retry without ever removing the persistent block. + for (int attempt = 0; attempt < 40; ++attempt) { + ready = killSwitchController->profileBecameReady( + operationId, currentTunInterface()); + if (ready) { + killSwitchPreviousProfileUsedTun = false; + MW_show_log(tr("Proxy became ready; kill switch remains active.")); + assignError(error, {}); + return true; + } + if (!Configs::dataManager->settingsRepo->spmode_vpn || + !ready.error.contains("interface", Qt::CaseInsensitive)) { + break; + } + QThread::msleep(50); + } + assignError(error, ready.error); + return false; +#else + Q_UNUSED(operationId) + assignError(error, {}); + return true; +#endif +} + +void MainWindow::failKillSwitchProfileStart(const quint64 operationId, + const QString &error, + const bool coreInstanceMayBeRunning) +{ +#ifdef Q_OS_WIN + if (!killSwitchController || operationId == 0) { + return; + } + if (killSwitchController->snapshot().activeOperationId != operationId) { + // A failed Stop may already have restored the previous profile and + // cancelled the enclosing switch. Do not turn that successful rollback + // into a spurious stale-notification error. + return; + } + const auto handled = + killSwitchController->profileStartFailed(operationId, error); + if (coreInstanceMayBeRunning) { + MW_show_log(tr("Stopping an unusable core instance while kill-switch blocking remains active.")); + runOnThread( + [this] { + core_process->start_profile_when_core_is_up = -1; + core_process->Restart(); + }, + DS_cores); + } + killSwitchPreviousProfileUsedTun = false; + if (!handled) { + MW_show_log(tr("Kill-switch error after profile start failure: %1") + .arg(handled.error)); + } else { + MW_show_log(tr("Profile failed; direct Internet remains blocked.")); + } +#else + Q_UNUSED(operationId) + Q_UNUSED(error) +#endif +} + +bool MainWindow::prepareKillSwitchProfileStop(QString *error) +{ +#ifdef Q_OS_WIN + if (!killSwitchController || !killSwitchController->snapshot().enabled) { + return true; + } + const auto before = killSwitchController->snapshot(); + if (before.backend.tunAllowanceActive) { + killSwitchPreviousProfileUsedTun = true; + killSwitchPreviousTunIpv6 = before.allowedTun.ipv6; + } + const auto prepared = killSwitchController->prepareForProfileStop(); + if (!prepared) { + assignError(error, prepared.error); + MW_show_log(tr("Kill switch refused an unsafe profile stop: %1") + .arg(prepared.error)); + return false; + } +#endif + assignError(error, {}); + return true; +} + +void MainWindow::finishKillSwitchProfileStop() +{ +#ifdef Q_OS_WIN + if (!killSwitchController || !killSwitchController->snapshot().enabled) { + return; + } + const auto stopped = killSwitchController->profileStopped(); + killSwitchPreviousProfileUsedTun = false; + if (!stopped) { + MW_show_log(tr("Kill-switch stop-state error: %1").arg(stopped.error)); + } else { + MW_show_log(tr("Profile stopped; direct Internet remains blocked.")); + } +#endif +} + +void MainWindow::failKillSwitchProfileStop(const QString &error) +{ +#ifdef Q_OS_WIN + if (!killSwitchController || !killSwitchController->snapshot().enabled) { + return; + } + std::optional previousTun; + if (killSwitchPreviousProfileUsedTun) { + previousTun = Configs_sys::KillSwitchTunInterface{ + WindowsWfpKillSwitchBackend::tunInterfaceAlias(), 0, true, + killSwitchPreviousTunIpv6}; + } + const auto restored = + killSwitchController->profileStopFailed(previousTun, error); + if (restored) { + killSwitchPreviousProfileUsedTun = false; + } + if (!restored) { + MW_show_log(tr("Profile stop failed; traffic remains fail-closed: %1") + .arg(restored.error)); + } +#else + Q_UNUSED(error) +#endif +} + +void MainWindow::killSwitchCoreTerminated(const bool reconnectPlanned) +{ +#ifdef Q_OS_WIN + if (!killSwitchController || !killSwitchController->snapshot().enabled) { + return; + } + const auto handled = + killSwitchController->coreTerminatedUnexpectedly(reconnectPlanned); + killSwitchPreviousProfileUsedTun = false; + if (!handled) { + MW_show_log(tr("Kill switch failed to reconcile after core exit: %1") + .arg(handled.error)); + } else { + MW_show_log(tr("Core exited; persistent kill switch is still blocking direct traffic.")); + } +#else + Q_UNUSED(reconnectPlanned) +#endif +} + +bool MainWindow::prepareKillSwitchExit(QString *error) +{ +#ifdef Q_OS_WIN + if (!killSwitchController) { + return true; + } + const auto prepared = killSwitchController->prepareForExit(); + if (!prepared) { + assignError(error, prepared.error); + return false; + } +#endif + assignError(error, {}); + return true; +} diff --git a/src/ui/setting/dialog_vpn_settings.cpp b/src/ui/setting/dialog_vpn_settings.cpp index 59869bd63..5218eac67 100644 --- a/src/ui/setting/dialog_vpn_settings.cpp +++ b/src/ui/setting/dialog_vpn_settings.cpp @@ -57,6 +57,11 @@ DialogVPNSettings::DialogVPNSettings(QWidget *parent) : QDialog(parent), ui(new ui->vpn_ipv6->setChecked(Configs::dataManager->settingsRepo->vpn_ipv6); ui->strict_route->setChecked(Configs::dataManager->settingsRepo->vpn_strict_route); ui->tun_routing->setChecked(Configs::dataManager->settingsRepo->enable_tun_routing); +#ifdef Q_OS_WIN + ui->kill_switch->setChecked(Configs::dataManager->settingsRepo->kill_switch_enabled); +#else + ui->kill_switch_widget->hide(); +#endif ui->tun_ipv4_cidr->setText(Configs::dataManager->settingsRepo->vpn_tun_ipv4_cidr); ui->tun_ipv6_cidr->setText(Configs::dataManager->settingsRepo->vpn_tun_ipv6_cidr); ui->disable_priv_range->setChecked(Configs::dataManager->settingsRepo->disable_private_range_bypass); @@ -95,8 +100,38 @@ void DialogVPNSettings::accept() { Configs::dataManager->settingsRepo->vpn_tun_ipv6_cidr = tunIPv6CIDR; Configs::dataManager->settingsRepo->disable_private_range_bypass = ui->disable_priv_range->isChecked(); Configs::dataManager->settingsRepo->vpn_auto_redirect = ui->auto_redirect->isChecked(); - // - MW_dialog_message(MwMessage::UpdateSettings, {MwArg::Vpn}); + bool protectedRestartWillApplySettings = false; +#ifdef Q_OS_WIN + const bool requestedKillSwitch = ui->kill_switch->isChecked(); + if (requestedKillSwitch != + Configs::dataManager->settingsRepo->kill_switch_enabled) { + // A non-elevated first enable restarts the whole application. Persist + // all ordinary fields before launching that replacement, and skip the + // normal profile-restart prompt (which would otherwise run before the + // WFP baseline is installed). + if (requestedKillSwitch && !Configs::IsAdmin()) { + Configs::dataManager->settingsRepo->Save(); + protectedRestartWillApplySettings = true; + } + QString error; + if (!GetMainWindow()->setKillSwitchEnabled(requestedKillSwitch, &error)) { + QMessageBox::critical( + this, + tr("Kill switch change failed"), + tr("The requested kill-switch change could not be completed safely. " + "Throne retained the safest state it could verify.\n\n%1") + .arg(error)); + ui->kill_switch->setChecked( + Configs::dataManager->settingsRepo->kill_switch_enabled); + return; + } + } +#endif + + if (!protectedRestartWillApplySettings) { + MW_dialog_message(MwMessage::UpdateSettings, {MwArg::Vpn}); + } + QDialog::accept(); } diff --git a/tests/KillSwitchControllerTest.cpp b/tests/KillSwitchControllerTest.cpp new file mode 100644 index 000000000..06a539ab7 --- /dev/null +++ b/tests/KillSwitchControllerTest.cpp @@ -0,0 +1,574 @@ +#include "include/sys/KillSwitchController.hpp" + +#include +#include +#include +#include +#include + +using Configs_sys::KillSwitchBackend; +using Configs_sys::KillSwitchBackendState; +using Configs_sys::KillSwitchController; +using Configs_sys::KillSwitchReconcileResult; +using Configs_sys::KillSwitchResult; +using Configs_sys::KillSwitchTrustedCorePlan; +using Configs_sys::KillSwitchTunInterface; + +namespace { + +class TestFailure final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +void require(const bool condition, const char *expression, const int line) { + if (!condition) { + throw TestFailure("line " + std::to_string(line) + ": " + expression); + } +} + +#define REQUIRE(expression) require(static_cast(expression), #expression, __LINE__) + +class FakeBackend final : public KillSwitchBackend { +public: + KillSwitchBackendState state; + KillSwitchTrustedCorePlan lastPlan; + KillSwitchTunInterface lastTun; + std::vector calls; + bool failReconcile = false; + bool failEnsureBaseline = false; + bool failStartDynamicCore = false; + bool failRemoveTun = false; + bool failAddTun = false; + bool failDisable = false; + bool failedDisableDropsDynamicState = false; + int disableCalls = 0; + + [[nodiscard]] KillSwitchReconcileResult reconcile() override { + calls.emplace_back("reconcile"); + if (failReconcile) { + return {KillSwitchResult::Failure(QStringLiteral("reconcile failure")), + state}; + } + return {KillSwitchResult::Success(), state}; + } + + [[nodiscard]] KillSwitchResult ensureBaseline() override { + calls.emplace_back("ensure-baseline"); + if (failEnsureBaseline) { + return KillSwitchResult::Failure(QStringLiteral("baseline failure")); + } + state.baselineActive = true; + return KillSwitchResult::Success(); + } + + [[nodiscard]] KillSwitchResult startDynamicCore( + const KillSwitchTrustedCorePlan &plan) override { + calls.emplace_back("start-core-policy"); + lastPlan = plan; + if (failStartDynamicCore) { + return KillSwitchResult::Failure(QStringLiteral("core policy failure")); + } + state.dynamicCoreActive = true; + return KillSwitchResult::Success(); + } + + [[nodiscard]] KillSwitchResult removeTunAllowance() override { + calls.emplace_back("remove-tun"); + if (failRemoveTun) { + return KillSwitchResult::Failure(QStringLiteral("remove TUN failure")); + } + state.tunAllowanceActive = false; + lastTun = {}; + return KillSwitchResult::Success(); + } + + [[nodiscard]] KillSwitchResult addTunAllowance( + const KillSwitchTunInterface &tunInterface) override { + calls.emplace_back("add-tun"); + if (failAddTun) { + return KillSwitchResult::Failure(QStringLiteral("add TUN failure")); + } + state.tunAllowanceActive = true; + lastTun = tunInterface; + return KillSwitchResult::Success(); + } + + [[nodiscard]] KillSwitchResult disable() override { + calls.emplace_back("disable"); + ++disableCalls; + if (failDisable) { + if (failedDisableDropsDynamicState) { + state.dynamicCoreActive = false; + state.tunAllowanceActive = false; + lastTun = {}; + } + return KillSwitchResult::Failure(QStringLiteral("disable failure")); + } + state = {}; + lastTun = {}; + return KillSwitchResult::Success(); + } +}; + +KillSwitchTrustedCorePlan trustedCorePlan() { + return {{QStringLiteral("C:/Program Files/Throne/ThroneCore.exe")}}; +} + +KillSwitchTunInterface dualStackTun() { + return {QStringLiteral("throne-tun"), 42, true, true}; +} + +void requireInvariant(const KillSwitchController &controller) { + QString reason; + if (!controller.invariantHolds(&reason)) { + throw TestFailure("controller invariant failed: " + reason.toStdString()); + } +} + +quint64 connectTun(KillSwitchController &controller, + const KillSwitchTunInterface &tun = dualStackTun()) { + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(prepared); + REQUIRE(prepared.operationId != 0); + REQUIRE(controller.profileBecameReady(prepared.operationId, tun)); + requireInvariant(controller); + return prepared.operationId; +} + +void disabledPreservesExistingBehavior() { + FakeBackend backend; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(false, trustedCorePlan()); + REQUIRE(initialized); + REQUIRE(!initialized.enabled); + REQUIRE(!initialized.recoveredStaleProtection); + REQUIRE(initialized.state == KillSwitchController::State::Disabled); + REQUIRE(backend.disableCalls == 0); + REQUIRE(backend.calls.size() == 1); + REQUIRE(backend.calls.front() == "reconcile"); + + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(prepared); + REQUIRE(prepared.operationId == 0); + REQUIRE(controller.profileBecameReady(0)); + requireInvariant(controller); +} + +void staleProtectionIsRecoveredNotDeleted() { + FakeBackend backend; + backend.state = {true, false, true}; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(false, trustedCorePlan()); + REQUIRE(initialized); + REQUIRE(initialized.enabled); + REQUIRE(initialized.recoveredStaleProtection); + REQUIRE(initialized.state == KillSwitchController::State::Disconnected); + REQUIRE(backend.disableCalls == 0); + REQUIRE(backend.state.baselineActive); + REQUIRE(backend.state.dynamicCoreActive); + REQUIRE(!backend.state.tunAllowanceActive); + + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.recoveredStaleProtection); + requireInvariant(controller); +} + +void reconcileFailureRetainsObservedProtection() { + FakeBackend backend; + backend.state.baselineActive = true; + backend.failReconcile = true; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(false, trustedCorePlan()); + REQUIRE(!initialized); + REQUIRE(initialized.enabled); + REQUIRE(initialized.recoveredStaleProtection); + REQUIRE(initialized.state == KillSwitchController::State::Error); + REQUIRE(backend.disableCalls == 0); + requireInvariant(controller); +} + +void reconcileFailureWithoutObservedStateStillFailsClosed() { + FakeBackend backend; + backend.failReconcile = true; + backend.failEnsureBaseline = true; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(false, trustedCorePlan()); + REQUIRE(!initialized); + REQUIRE(initialized.enabled); + REQUIRE(!initialized.recoveredStaleProtection); + REQUIRE(initialized.state == KillSwitchController::State::Error); + + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(!prepared); + REQUIRE(!prepared.mayTearDownCurrentProfile); + REQUIRE(backend.disableCalls == 0); + requireInvariant(controller); +} + +void normalTunConnectionIsDualStack() { + FakeBackend backend; + KillSwitchController controller(backend); + const auto initialized = controller.initialize(true, trustedCorePlan()); + REQUIRE(initialized); + REQUIRE(!initialized.recoveredStaleProtection); + REQUIRE(backend.state.baselineActive); + REQUIRE(backend.state.dynamicCoreActive); + REQUIRE(!backend.state.tunAllowanceActive); + + connectTun(controller); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connected); + REQUIRE(snapshot.backend.tunAllowanceActive); + REQUIRE(snapshot.allowedTun.ipv4); + REQUIRE(snapshot.allowedTun.ipv6); + REQUIRE(backend.lastTun.ipv4); + REQUIRE(backend.lastTun.ipv6); +} + +void configuredLaunchDoesNotReportStaleRecovery() { + FakeBackend backend; + backend.state.baselineActive = true; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(true, trustedCorePlan()); + REQUIRE(initialized); + REQUIRE(initialized.enabled); + REQUIRE(!initialized.recoveredStaleProtection); + REQUIRE(!controller.snapshot().recoveredStaleProtection); + REQUIRE(backend.disableCalls == 0); + requireInvariant(controller); +} + +void systemProxyConnectionNeedsNoTunAllowance() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(prepared); + REQUIRE(controller.profileBecameReady(prepared.operationId, std::nullopt)); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connected); + REQUIRE(!snapshot.backend.tunAllowanceActive); + requireInvariant(controller); +} + +void switchOwnsOperationAcrossNestedStop() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + const auto switching = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Switch); + REQUIRE(switching); + REQUIRE(switching.state == KillSwitchController::State::Switching); + REQUIRE(!backend.state.tunAllowanceActive); + REQUIRE(backend.calls.size() >= 2); + REQUIRE(backend.calls[backend.calls.size() - 2] == "ensure-baseline"); + REQUIRE(backend.calls.back() == "remove-tun"); + + const auto nestedStop = controller.prepareForProfileStop(); + REQUIRE(nestedStop); + REQUIRE(nestedStop.operationId == switching.operationId); + REQUIRE(nestedStop.state == KillSwitchController::State::Switching); + REQUIRE(controller.profileStopped()); + REQUIRE(controller.snapshot().activeOperationId == switching.operationId); + + REQUIRE(controller.profileBecameReady(switching.operationId, dualStackTun())); + REQUIRE(controller.snapshot().state == KillSwitchController::State::Connected); + requireInvariant(controller); +} + +void failedSwitchRemainsFailClosed() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + const auto switching = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Switch); + REQUIRE(switching); + REQUIRE(controller.profileStartFailed( + switching.operationId, QStringLiteral("new profile rejected"))); + + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Error); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(snapshot.backend.dynamicCoreActive); + REQUIRE(!snapshot.backend.tunAllowanceActive); + REQUIRE(snapshot.activeOperationId == 0); + requireInvariant(controller); +} + +void manualStopLeavesBaselineActive() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + const auto stopping = controller.prepareForProfileStop(); + REQUIRE(stopping); + REQUIRE(stopping.state == KillSwitchController::State::Stopping); + REQUIRE(!backend.state.tunAllowanceActive); + REQUIRE(controller.profileStopped()); + + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Disconnected); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(!snapshot.backend.tunAllowanceActive); + requireInvariant(controller); +} + +void failedStopCanRestoreCurrentTunAndRetry() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + const auto tun = dualStackTun(); + connectTun(controller, tun); + REQUIRE(controller.prepareForProfileStop()); + + backend.failAddTun = true; + REQUIRE(!controller.profileStopFailed(tun, QStringLiteral("stop RPC failed"))); + REQUIRE(controller.snapshot().state == KillSwitchController::State::Stopping); + REQUIRE(!controller.snapshot().backend.tunAllowanceActive); + requireInvariant(controller); + + backend.failAddTun = false; + REQUIRE(controller.profileStopFailed(tun, QStringLiteral("stop RPC failed"))); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connected); + REQUIRE(snapshot.backend.tunAllowanceActive); + REQUIRE(snapshot.allowedTun == tun); + REQUIRE(snapshot.lastError == QStringLiteral("stop RPC failed")); + requireInvariant(controller); +} + +void failedSwitchRestorationCanBeCancelledAndStopped() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + const auto tun = dualStackTun(); + connectTun(controller, tun); + + const auto switching = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Switch); + REQUIRE(switching); + REQUIRE(controller.prepareForProfileStop()); + + backend.failAddTun = true; + REQUIRE(!controller.profileStopFailed( + tun, QStringLiteral("stop and TUN restoration failed"))); + REQUIRE(controller.snapshot().state == KillSwitchController::State::Switching); + REQUIRE(controller.snapshot().activeOperationId == switching.operationId); + + REQUIRE(controller.profileStartFailed( + switching.operationId, QStringLiteral("switch cancelled"))); + REQUIRE(controller.snapshot().state == KillSwitchController::State::Error); + REQUIRE(controller.snapshot().activeOperationId == 0); + backend.failAddTun = false; + + REQUIRE(controller.prepareForProfileStop()); + REQUIRE(controller.profileStopped()); + REQUIRE(controller.snapshot().state == KillSwitchController::State::Disconnected); + requireInvariant(controller); +} + +void preparationFailureDoesNotAuthorizeTeardown() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + backend.failEnsureBaseline = true; + + const auto switching = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Switch); + REQUIRE(!switching); + REQUIRE(!switching.mayTearDownCurrentProfile); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connected); + REQUIRE(snapshot.backend.tunAllowanceActive); + REQUIRE(snapshot.activeOperationId == 0); + requireInvariant(controller); +} + +void tunReadinessCanBeRetriedWithoutOpeningDirectAccess() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(prepared); + + backend.failAddTun = true; + REQUIRE(!controller.profileBecameReady(prepared.operationId, dualStackTun())); + auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connecting); + REQUIRE(snapshot.activeOperationId == prepared.operationId); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(!snapshot.backend.tunAllowanceActive); + requireInvariant(controller); + + backend.failAddTun = false; + REQUIRE(controller.profileBecameReady(prepared.operationId, dualStackTun())); + snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Connected); + REQUIRE(snapshot.backend.tunAllowanceActive); + requireInvariant(controller); +} + +void crashAndExitKeepThePersistentBlock() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + REQUIRE(controller.coreTerminatedUnexpectedly(true)); + auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Reconnecting); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(!snapshot.backend.tunAllowanceActive); + requireInvariant(controller); + + const auto reconnect = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Reconnect); + REQUIRE(reconnect); + REQUIRE(controller.profileStartFailed( + reconnect.operationId, QStringLiteral("reconnect failed"))); + const auto exiting = controller.prepareForExit(); + REQUIRE(exiting); + snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Exiting); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(backend.disableCalls == 0); + requireInvariant(controller); +} + +void crashStillRemovesTunWhenBaselineVerificationFails() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + backend.failEnsureBaseline = true; + + REQUIRE(!controller.coreTerminatedUnexpectedly(false)); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Error); + REQUIRE(!snapshot.backend.tunAllowanceActive); + REQUIRE(!backend.state.tunAllowanceActive); + REQUIRE(backend.calls.back() == "remove-tun"); + requireInvariant(controller); +} + +void explicitDisableIsTheOnlyRemovalPath() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + REQUIRE(controller.disable()); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.state == KillSwitchController::State::Disabled); + REQUIRE(!snapshot.enabled); + REQUIRE(!snapshot.backend.anyActive()); + REQUIRE(backend.disableCalls == 1); + requireInvariant(controller); +} + +void failedDisableRefreshesTransientBackendState() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + backend.failDisable = true; + backend.failedDisableDropsDynamicState = true; + REQUIRE(!controller.disable()); + auto snapshot = controller.snapshot(); + REQUIRE(snapshot.enabled); + REQUIRE(snapshot.state == KillSwitchController::State::Error); + REQUIRE(snapshot.backend.baselineActive); + REQUIRE(!snapshot.backend.dynamicCoreActive); + REQUIRE(!snapshot.backend.tunAllowanceActive); + requireInvariant(controller); + + backend.failDisable = false; + const auto reconnect = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(reconnect); + REQUIRE(backend.state.dynamicCoreActive); + REQUIRE(controller.profileBecameReady(reconnect.operationId, dualStackTun())); + requireInvariant(controller); +} + +void failedDisableWithUnknownReconcileStillRefusesUnprotectedStart() { + FakeBackend backend; + KillSwitchController controller(backend); + REQUIRE(controller.initialize(true, trustedCorePlan())); + connectTun(controller); + + backend.failDisable = true; + backend.failedDisableDropsDynamicState = true; + backend.failReconcile = true; + backend.failEnsureBaseline = true; + REQUIRE(!controller.disable()); + const auto snapshot = controller.snapshot(); + REQUIRE(snapshot.enabled); + REQUIRE(snapshot.state == KillSwitchController::State::Error); + REQUIRE(!snapshot.backend.dynamicCoreActive); + + const auto reconnect = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(!reconnect); + REQUIRE(!reconnect.mayTearDownCurrentProfile); + requireInvariant(controller); +} + +} // namespace + +int main() { + const std::vector> tests = { + {"disabled preserves existing behavior", disabledPreservesExistingBehavior}, + {"stale protection is recovered, not deleted", staleProtectionIsRecoveredNotDeleted}, + {"reconcile failure retains observed protection", reconcileFailureRetainsObservedProtection}, + {"unknown reconcile failure still fails closed", reconcileFailureWithoutObservedStateStillFailsClosed}, + {"normal TUN connection is dual stack", normalTunConnectionIsDualStack}, + {"configured launch is not stale recovery", configuredLaunchDoesNotReportStaleRecovery}, + {"System Proxy connection needs no TUN allowance", systemProxyConnectionNeedsNoTunAllowance}, + {"switch owns operation across nested stop", switchOwnsOperationAcrossNestedStop}, + {"failed switch remains fail closed", failedSwitchRemainsFailClosed}, + {"manual stop leaves baseline active", manualStopLeavesBaselineActive}, + {"failed stop can restore current TUN and retry", failedStopCanRestoreCurrentTunAndRetry}, + {"failed switch restoration can be cancelled", failedSwitchRestorationCanBeCancelledAndStopped}, + {"preparation failure does not authorize teardown", preparationFailureDoesNotAuthorizeTeardown}, + {"TUN readiness can be retried", tunReadinessCanBeRetriedWithoutOpeningDirectAccess}, + {"crash and exit keep the persistent block", crashAndExitKeepThePersistentBlock}, + {"crash removes TUN after baseline verification failure", crashStillRemovesTunWhenBaselineVerificationFails}, + {"explicit disable is the only removal path", explicitDisableIsTheOnlyRemovalPath}, + {"failed disable refreshes transient state", failedDisableRefreshesTransientBackendState}, + {"failed disable with unknown state stays closed", failedDisableWithUnknownReconcileStillRefusesUnprotectedStart}, + }; + + int failures = 0; + for (const auto &[name, test] : tests) { + try { + test(); + std::cout << "PASS: " << name << '\n'; + } catch (const std::exception &error) { + ++failures; + std::cerr << "FAIL: " << name << ": " << error.what() << '\n'; + } + } + std::cout << tests.size() - static_cast(failures) << '/' + << tests.size() << " tests passed\n"; + return failures == 0 ? 0 : 1; +} From 1c013a40078f96d316720a458f26f1b2f8411134 Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Thu, 13 Aug 2026 14:35:16 +0300 Subject: [PATCH 2/5] fix: avoid Win32 macro collisions in unity builds --- 3rdparty/WinCommander.hpp | 13 ++++++++----- src/main.cpp | 6 +++--- src/ui/mainwindow_killswitch.cpp | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/3rdparty/WinCommander.hpp b/3rdparty/WinCommander.hpp index 0672a6c11..84d7be37f 100644 --- a/3rdparty/WinCommander.hpp +++ b/3rdparty/WinCommander.hpp @@ -27,12 +27,15 @@ class WinCommander { public: - static const int SW_HIDE = 0; - static const int SW_NORMAL = 1; - static const int SW_SHOWMINIMIZED = 2; + // Do not reuse the Win32 SW_* names here: windows.h defines them as + // preprocessor macros, which also expand in qualified expressions such as + // WinCommander::SW_HIDE when CMake unity builds combine translation units. + static constexpr int WindowHidden = 0; + static constexpr int WindowNormal = 1; + static constexpr int WindowMinimized = 2; static uint runProcessElevated(const QString &path, const QStringList ¶meters = QStringList(), const QString &workingDir = QString(), - int nShow = SW_SHOWMINIMIZED, bool aWait = true); -}; \ No newline at end of file + int nShow = WindowMinimized, bool aWait = true); +}; diff --git a/src/main.cpp b/src/main.cpp index f94341c98..d11d0cf94 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -287,7 +287,7 @@ int main(int argc, char* argv[]) { elevatedArguments.removeFirst(); const uint result = WinCommander::runProcessElevated( QApplication::applicationFilePath(), elevatedArguments, - QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + QApplication::applicationDirPath(), WinCommander::WindowHidden, true); return result == 0 ? 0 : 1; } WindowsWfpKillSwitchBackend recoveryBackend; @@ -382,7 +382,7 @@ int main(int argc, char* argv[]) { elevatedArguments.removeFirst(); const uint result = WinCommander::runProcessElevated( QApplication::applicationFilePath(), elevatedArguments, - QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + QApplication::applicationDirPath(), WinCommander::WindowHidden, true); return result == 0 ? 0 : 1; } @@ -440,7 +440,7 @@ int main(int argc, char* argv[]) { elevatedArguments.removeFirst(); const uint result = WinCommander::runProcessElevated( QApplication::applicationFilePath(), elevatedArguments, - QApplication::applicationDirPath(), WinCommander::SW_NORMAL, false); + QApplication::applicationDirPath(), WinCommander::WindowNormal, false); if (result == static_cast(-1)) { QMessageBox::critical(nullptr, "Throne kill switch", "Administrator permission is required to restore fail-closed protection."); diff --git a/src/ui/mainwindow_killswitch.cpp b/src/ui/mainwindow_killswitch.cpp index 502475c1c..05c88471c 100644 --- a/src/ui/mainwindow_killswitch.cpp +++ b/src/ui/mainwindow_killswitch.cpp @@ -164,7 +164,7 @@ bool MainWindow::setKillSwitchEnabled(const bool enable, QString *error) helperArguments << operation << "--quiet"; return WinCommander::runProcessElevated( QApplication::applicationFilePath(), helperArguments, - QApplication::applicationDirPath(), WinCommander::SW_HIDE, true); + QApplication::applicationDirPath(), WinCommander::WindowHidden, true); }; if (enable && !Configs::IsAdmin()) { @@ -192,7 +192,7 @@ bool MainWindow::setKillSwitchEnabled(const bool enable, QString *error) << QString::number(QCoreApplication::applicationPid()); const uint restartResult = WinCommander::runProcessElevated( QApplication::applicationFilePath(), restartArguments, - QApplication::applicationDirPath(), WinCommander::SW_NORMAL, false); + QApplication::applicationDirPath(), WinCommander::WindowNormal, false); if (restartResult == static_cast(-1)) { const uint rollbackResult = runMaintenanceHelper("--disable-kill-switch"); From 86f411749e442e2b9ab90f8510effa43af57b116 Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Thu, 13 Aug 2026 15:24:44 +0300 Subject: [PATCH 3/5] fix: handle fail-closed bootstrap edge cases --- CMakeLists.txt | 13 ++++ include/configs/GeneratorUtils.h | 24 +++++++ src/configs/GeneratorUtils.cpp | 86 ++++++++++++++++++++++++ src/configs/generate.cpp | 27 +++++++- tests/GeneratorUtilsTest.cpp | 110 +++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 include/configs/GeneratorUtils.h create mode 100644 src/configs/GeneratorUtils.cpp create mode 100644 tests/GeneratorUtilsTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a9c3c0adf..b689f0487 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -361,6 +361,8 @@ set(PROJECT_SOURCES src/configs/outbounds/vmess.cpp src/configs/outbounds/wireguard.cpp src/configs/outbounds/custom.cpp + include/configs/GeneratorUtils.h + src/configs/GeneratorUtils.cpp include/configs/generate.h src/configs/generate.cpp include/configs/common/utils.h @@ -528,4 +530,15 @@ if (BUILD_TESTING) ) target_link_libraries(KillSwitchControllerTest PRIVATE Qt6::Core) add_test(NAME KillSwitchControllerTest COMMAND KillSwitchControllerTest) + + add_executable(GeneratorUtilsTest + tests/GeneratorUtilsTest.cpp + include/configs/GeneratorUtils.h + src/configs/GeneratorUtils.cpp + ) + target_include_directories(GeneratorUtilsTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_libraries(GeneratorUtilsTest PRIVATE Qt6::Core Qt6::Network) + add_test(NAME GeneratorUtilsTest COMMAND GeneratorUtilsTest) endif () diff --git a/include/configs/GeneratorUtils.h b/include/configs/GeneratorUtils.h new file mode 100644 index 000000000..2c5af9e7b --- /dev/null +++ b/include/configs/GeneratorUtils.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace Configs::GeneratorUtils { + +struct ParsedHostPort { + QString host; + quint16 port = 0; +}; + +// Parse host:port without treating the final hextet of a raw IPv6 literal as a +// port. IPv6 ports therefore require the standard [address]:port form. +[[nodiscard]] ParsedHostPort ParseHostPort(const QString &endpoint, + quint16 defaultPort); + +// Return the independent XHTTP download endpoint only when it is a hostname +// that needs bootstrap DNS. Numeric IPv4/IPv6 addresses and malformed objects +// do not need (or cannot safely receive) such a DNS rule. +[[nodiscard]] QString ExtractXrayXhttpDownloadDomain( + const QString &downloadSettings); + +} // namespace Configs::GeneratorUtils diff --git a/src/configs/GeneratorUtils.cpp b/src/configs/GeneratorUtils.cpp new file mode 100644 index 000000000..97906e028 --- /dev/null +++ b/src/configs/GeneratorUtils.cpp @@ -0,0 +1,86 @@ +#include "include/configs/GeneratorUtils.h" + +#include +#include +#include + +namespace Configs::GeneratorUtils { +namespace { + +bool ParsePort(const QString &text, quint16 *port) { + bool ok = false; + const auto parsed = text.toUInt(&ok); + if (!ok || parsed == 0 || parsed > 65535) return false; + *port = static_cast(parsed); + return true; +} + +} // namespace + +ParsedHostPort ParseHostPort(const QString &endpoint, const quint16 defaultPort) { + const auto trimmed = endpoint.trimmed(); + ParsedHostPort result{trimmed, defaultPort}; + + if (trimmed.startsWith('[')) { + const auto closingBracket = trimmed.indexOf(']'); + if (closingBracket <= 1) return result; + + const auto host = trimmed.mid(1, closingBracket - 1); + const auto suffix = trimmed.mid(closingBracket + 1); + if (suffix.isEmpty()) return {host, defaultPort}; + if (!suffix.startsWith(':')) return result; + + quint16 port = 0; + if (!ParsePort(suffix.mid(1), &port)) return result; + return {host, port}; + } + + // A valid unbracketed IPv6 literal has no unambiguous port component. + if (QHostAddress(trimmed).protocol() == QAbstractSocket::IPv6Protocol) + return result; + + if (trimmed.count(':') != 1) return result; + const auto separator = trimmed.indexOf(':'); + const auto host = trimmed.left(separator); + quint16 port = 0; + if (host.isEmpty() || !ParsePort(trimmed.mid(separator + 1), &port)) + return result; + return {host, port}; +} + +QString ExtractXrayXhttpDownloadDomain(const QString &downloadSettings) { + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson(downloadSettings.toUtf8(), + &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + + const auto addressValue = document.object().value(QStringLiteral("address")); + if (!addressValue.isString()) return {}; + auto address = addressValue.toString().trimmed(); + if (address.isEmpty()) return {}; + + // Xray's address normally contains a bare host, but accept the standard + // bracketed-IPv6 and host:port spellings defensively. Returning a string + // with a port would never match a DNS domain selector. + if (address.startsWith('[')) { + const auto closingBracket = address.indexOf(']'); + if (closingBracket <= 1) return {}; + address = address.mid(1, closingBracket - 1); + } else if (QHostAddress(address).protocol() == + QAbstractSocket::UnknownNetworkLayerProtocol && + address.count(':') == 1) { + const auto separator = address.indexOf(':'); + quint16 ignoredPort = 0; + if (ParsePort(address.mid(separator + 1), &ignoredPort)) + address = address.left(separator); + } + + if (QHostAddress(address).protocol() != + QAbstractSocket::UnknownNetworkLayerProtocol) { + return {}; + } + return address; +} + +} // namespace Configs::GeneratorUtils diff --git a/src/configs/generate.cpp b/src/configs/generate.cpp index 6706b2274..a5bb72c8e 100644 --- a/src/configs/generate.cpp +++ b/src/configs/generate.cpp @@ -1,6 +1,7 @@ #include "include/configs/generate.h" #include "include/api/RPC.h" #include "include/configs/AutoSelectorPlan.h" +#include "include/configs/GeneratorUtils.h" #include "include/global/Configs.hpp" #include @@ -429,6 +430,21 @@ namespace Configs { return domains; } if (auto addr = ent->outbound->GetAddress(); !addr.isEmpty() && !IsIpAddress(addr)) domains << addr; + + // XHTTP downloadSettings can dial a second, independent endpoint. + // It must be resolvable before the Xray chain exists, just like the + // primary server, or fail-closed startup deadlocks on remote DNS. + if (ent->outbound->IsXray()) { + const auto stream = ent->outbound->GetXrayStream(); + if (stream != nullptr && stream->network == "xhttp" && + stream->xhttp != nullptr && stream->xhttp->mode != "stream-one") { + const auto downloadDomain = + GeneratorUtils::ExtractXrayXhttpDownloadDomain( + stream->xhttp->downloadSettings); + if (!downloadDomain.isEmpty() && !domains.contains(downloadDomain)) + domains << downloadDomain; + } + } return domains; } @@ -520,8 +536,9 @@ namespace Configs { warpProfile->type = "wireguard"; auto outbound = std::make_shared(); outbound->name = "warp"; - outbound->server = settings.warp_ep.contains(":") ? SubStrBefore(settings.warp_ep, ":") : settings.warp_ep; - outbound->server_port = settings.warp_ep.contains(":") ? SubStrAfter(settings.warp_ep, ":").toInt() : 2408; + const auto endpoint = GeneratorUtils::ParseHostPort(settings.warp_ep, 2408); + outbound->server = endpoint.host; + outbound->server_port = endpoint.port; outbound->private_key = settings.warp_private_key; outbound->address = settings.warp_ifc_addrs; auto peer = std::make_shared(); @@ -1178,7 +1195,11 @@ namespace Configs { } } QJsonArray routeExcludeSets; - if (settings.enable_tun_routing) + // Under fail-closed routing, direct/bypass rules are rewritten + // to proxy. Excluding their destinations from the TUN here would + // prevent those rewritten rules from ever seeing the traffic; + // WFP would then block it instead of sending it through proxy. + if (settings.enable_tun_routing && !failClosedEnabled()) { for (auto item: tun.directIPCIDRs) excludedRanges << item.toString(); for (auto item: tun.directIPSets) routeExcludeSets << item; diff --git a/tests/GeneratorUtilsTest.cpp b/tests/GeneratorUtilsTest.cpp new file mode 100644 index 000000000..4b047a483 --- /dev/null +++ b/tests/GeneratorUtilsTest.cpp @@ -0,0 +1,110 @@ +#include "include/configs/GeneratorUtils.h" + +#include +#include +#include +#include +#include + +using Configs::GeneratorUtils::ExtractXrayXhttpDownloadDomain; +using Configs::GeneratorUtils::ParseHostPort; + +namespace { + +class TestFailure final : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +void require(const bool condition, const char *expression, const int line) { + if (!condition) + throw TestFailure("line " + std::to_string(line) + ": " + expression); +} + +#define REQUIRE(expression) require(static_cast(expression), #expression, __LINE__) + +void parsesWarpHostAndPort() { + const auto endpoint = ParseHostPort(QStringLiteral("engage.cloudflareclient.com:2408"), + 1234); + REQUIRE(endpoint.host == QStringLiteral("engage.cloudflareclient.com")); + REQUIRE(endpoint.port == 2408); +} + +void parsesBracketedWarpIpv6() { + const auto endpoint = ParseHostPort(QStringLiteral("[2606:4700:d0::a29f:c001]:2408"), + 1234); + REQUIRE(endpoint.host == QStringLiteral("2606:4700:d0::a29f:c001")); + REQUIRE(endpoint.port == 2408); +} + +void preservesRawWarpIpv6WithoutGuessingPort() { + const auto endpoint = ParseHostPort(QStringLiteral("2001:db8::1:2408"), + 1234); + REQUIRE(endpoint.host == QStringLiteral("2001:db8::1:2408")); + REQUIRE(endpoint.port == 1234); +} + +void usesDefaultPortForBareHost() { + const auto endpoint = ParseHostPort(QStringLiteral("engage.cloudflareclient.com"), + 2408); + REQUIRE(endpoint.host == QStringLiteral("engage.cloudflareclient.com")); + REQUIRE(endpoint.port == 2408); +} + +void extractsXhttpDownloadHostname() { + const auto domain = ExtractXrayXhttpDownloadDomain(QStringLiteral( + R"({"address":"download.example.com","port":443,"network":"xhttp"})")); + REQUIRE(domain == QStringLiteral("download.example.com")); +} + +void normalizesXhttpDownloadHostAndPort() { + const auto domain = ExtractXrayXhttpDownloadDomain(QStringLiteral( + R"({"address":"download.example.com:8443","port":443,"network":"xhttp"})")); + REQUIRE(domain == QStringLiteral("download.example.com")); +} + +void excludesNumericXhttpDownloadAddresses() { + REQUIRE(ExtractXrayXhttpDownloadDomain( + QStringLiteral(R"({"address":"203.0.113.7"})")) + .isEmpty()); + REQUIRE(ExtractXrayXhttpDownloadDomain( + QStringLiteral(R"({"address":"2001:db8::7"})")) + .isEmpty()); + REQUIRE(ExtractXrayXhttpDownloadDomain( + QStringLiteral(R"({"address":"[2001:db8::7]"})")) + .isEmpty()); +} + +void rejectsMalformedXhttpDownloadSettings() { + REQUIRE(ExtractXrayXhttpDownloadDomain(QStringLiteral("not json")).isEmpty()); + REQUIRE(ExtractXrayXhttpDownloadDomain(QStringLiteral(R"({"port":443})")).isEmpty()); +} + +} // namespace + +int main() { + const std::vector> tests = { + {"WARP host and port", parsesWarpHostAndPort}, + {"bracketed WARP IPv6", parsesBracketedWarpIpv6}, + {"raw WARP IPv6", preservesRawWarpIpv6WithoutGuessingPort}, + {"WARP default port", usesDefaultPortForBareHost}, + {"XHTTP download hostname", extractsXhttpDownloadHostname}, + {"XHTTP download host and port", normalizesXhttpDownloadHostAndPort}, + {"numeric XHTTP download addresses", excludesNumericXhttpDownloadAddresses}, + {"malformed XHTTP download settings", rejectsMalformedXhttpDownloadSettings}, + }; + + int failures = 0; + for (const auto &[name, test] : tests) { + try { + test(); + std::cout << "PASS: " << name << '\n'; + } catch (const std::exception &error) { + ++failures; + std::cerr << "FAIL: " << name << ": " << error.what() << '\n'; + } + } + std::cout << tests.size() - static_cast(failures) << '/' + << tests.size() << " tests passed\n"; + return failures == 0 ? 0 : 1; +} From 0064a973be2939c20b046585e182e431d28ced3f Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Thu, 13 Aug 2026 15:25:16 +0300 Subject: [PATCH 4/5] fix: harden kill switch startup recovery --- .../sys/windows/WindowsWfpKillSwitchBackend.h | 14 ++- include/ui/mainwindow.h | 2 +- src/main.cpp | 16 ++-- src/sys/KillSwitchController.cpp | 12 +++ .../windows/WindowsWfpKillSwitchBackend.cpp | 74 +++++++++++++--- src/ui/mainWindow/mainwindow_setup.cpp | 73 +++++++++------- src/ui/mainwindow_killswitch.cpp | 14 ++- src/ui/setting/dialog_vpn_settings.cpp | 87 +++++++++++++++---- tests/KillSwitchControllerTest.cpp | 28 ++++-- 9 files changed, 240 insertions(+), 80 deletions(-) diff --git a/include/sys/windows/WindowsWfpKillSwitchBackend.h b/include/sys/windows/WindowsWfpKillSwitchBackend.h index 1f1c5d0e0..837717e92 100644 --- a/include/sys/windows/WindowsWfpKillSwitchBackend.h +++ b/include/sys/windows/WindowsWfpKillSwitchBackend.h @@ -28,12 +28,18 @@ class WindowsWfpKillSwitchBackend final : public Configs_sys::KillSwitchBackend { BaselineState state = BaselineState::Error; QString detail; + // True only after queryBaseline has positively identified at least one + // object carrying Throne's ownership marker. A generic BFE/query error + // must not make a default-off installation behave as if protection had + // previously been enabled. + bool throneObjectsObserved = false; [[nodiscard]] bool isValid() const { return state == BaselineState::Valid; } - // Error means the backend could not prove absence. Callers must treat - // it as potentially active and must not authorize an unprotected - // profile transition from it. - [[nodiscard]] bool mayBeActive() const { return state != BaselineState::Absent; } + [[nodiscard]] bool mayBeActive() const { + return state == BaselineState::Valid || + state == BaselineState::StaleOrPartial || + (state == BaselineState::Error && throneObjectsObserved); + } }; WindowsWfpKillSwitchBackend(); diff --git a/include/ui/mainwindow.h b/include/ui/mainwindow.h index 34a5e643f..332d224f2 100644 --- a/include/ui/mainwindow.h +++ b/include/ui/mainwindow.h @@ -542,7 +542,7 @@ inline MainWindow *GetMainWindow() { return (MainWindow *) mainwindow; } -void UI_InitMainWindow(); +[[nodiscard]] bool UI_InitMainWindow(); #ifdef Q_OS_LINUX /* diff --git a/src/main.cpp b/src/main.cpp index d11d0cf94..f45cd5312 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -69,7 +69,9 @@ class MacOpenEventFilter : public QObject { #endif void signal_handler(int signum) { - if (GetMainWindow()->prepare_exit()) { + Q_UNUSED(signum) + auto *window = GetMainWindow(); + if (window == nullptr || window->prepare_exit()) { qApp->quit(); } } @@ -422,10 +424,7 @@ int main(int argc, char* argv[]) { // the rights required to retain/reconcile them before ThroneCore starts. WindowsWfpKillSwitchBackend startupProbe; const auto baselineStatus = startupProbe.queryBaseline(); - const bool persistentProtectionPresent = - baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::Valid || - baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::StaleOrPartial || - baselineStatus.state == WindowsWfpKillSwitchBackend::BaselineState::Error; + const bool persistentProtectionPresent = baselineStatus.mayBeActive(); const bool protectionRequested = Configs::dataManager->settingsRepo->kill_switch_enabled || persistentProtectionPresent; @@ -579,7 +578,7 @@ int main(int argc, char* argv[]) { QObject::connect(s, &QLocalSocket::disconnected, s, &QLocalSocket::deleteLater); readPayload(false); // in case the payload already arrived // raise main window - MW_dialog_message(MwMessage::Raise, {}); + if (MW_dialog_message) MW_dialog_message(MwMessage::Raise, {}); }); QObject::connect(qApp, &QApplication::aboutToQuit, [&] { @@ -609,7 +608,10 @@ int main(int argc, char* argv[]) { API::defaultClient = new API::Client(); - UI_InitMainWindow(); + if (!UI_InitMainWindow()) { + Logging::Shutdown(); + return 1; + } Configs::dataManager->RunDeferredMaintenance(); diff --git a/src/sys/KillSwitchController.cpp b/src/sys/KillSwitchController.cpp index a709c09be..5bb657ff6 100644 --- a/src/sys/KillSwitchController.cpp +++ b/src/sys/KillSwitchController.cpp @@ -64,6 +64,18 @@ KillSwitchController::InitializationResult KillSwitchController::initialize( const auto reconciled = backend_.reconcile(); backendState_ = reconciled.state; if (!reconciled.result) { + if (!shouldEnable && !backendState_.anyActive()) { + // The preference is explicitly off and the backend did not observe + // any owned policy. A generic platform-query failure (for example, + // BFE being unavailable) must preserve legacy/default-off behavior. + // A later explicit enable will still have to install and verify the + // baseline before any profile can be torn down. + enabled_ = false; + recoveredStaleProtection_ = false; + state_ = State::Disabled; + lastError_.clear(); + return {KillSwitchResult::Success(), false, false, state_}; + } lastError_ = normalizedError(QStringLiteral("reconcile kill switch state"), reconciled.result); state_ = State::Error; diff --git a/src/sys/windows/WindowsWfpKillSwitchBackend.cpp b/src/sys/windows/WindowsWfpKillSwitchBackend.cpp index fedbca029..0310437a9 100644 --- a/src/sys/windows/WindowsWfpKillSwitchBackend.cpp +++ b/src/sys/windows/WindowsWfpKillSwitchBackend.cpp @@ -309,12 +309,14 @@ enum class Presence Failed, }; -template +template Presence getObjectPresence(HANDLE engine, const GUID &key, Getter getter, Validator validator, + OwnershipValidator ownershipValidator, bool *matchesSchema, + bool *matchesOwnershipMarker, DWORD notFound, QString *error, const QString &name) @@ -326,6 +328,10 @@ Presence getObjectPresence(HANDLE engine, if (matchesSchema != nullptr) { *matchesSchema = object != nullptr && validator(*object); } + if (matchesOwnershipMarker != nullptr) { + *matchesOwnershipMarker = object != nullptr && + ownershipValidator(*object); + } return Presence::Present; } if (result == notFound) { @@ -1225,6 +1231,7 @@ WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBa return {BaselineState::Error, operationError(QStringLiteral("Open Windows Filtering Platform"), result)}; } + bool throneObjectsObserved = false; bool legacyProviderPresent = false; QString error; FWPM_PROVIDER0 *legacyProvider = nullptr; @@ -1239,6 +1246,7 @@ WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBa QStringLiteral("A deterministic WFP provider collision is present; Throne will not modify it because it lacks the exact v1-v3 ownership marker"), }; } + throneObjectsObserved = true; } else if (result != FWP_E_PROVIDER_NOT_FOUND) { return { BaselineState::Error, @@ -1252,56 +1260,92 @@ WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBa bool staleDynamicFilterPresent = false; bool dynamicFilterPresent = false; bool objectMatches = false; + bool ownershipMarkerMatches = false; objectMatches = false; + ownershipMarkerMatches = false; const Presence subLayer = getObjectPresence(engine.get(), kSubLayerKey, FwpmSubLayerGetByKey0, subLayerMatchesSchema, + [legacyProviderPresent](const FWPM_SUBLAYER0 &object) { + return legacyProviderPresent + ? (object.providerKey != nullptr && + equalGuid(*object.providerKey, kLegacyProviderKey) && + object.providerData.size == 0) + : (object.providerKey == nullptr && + blobMatchesCurrentSchema(object.providerData)); + }, &objectMatches, + &ownershipMarkerMatches, FWP_E_SUBLAYER_NOT_FOUND, &error, QStringLiteral("Throne WFP sublayer")); if (subLayer == Presence::Failed) { - return {BaselineState::Error, error}; + return {BaselineState::Error, error, throneObjectsObserved}; } + throneObjectsObserved |= subLayer == Presence::Present && ownershipMarkerMatches; presentCount += subLayer == Presence::Present ? 1 : 0; schemaMatches &= subLayer != Presence::Present || objectMatches; for (const GUID *key : kPersistentFilterKeys) { objectMatches = false; + ownershipMarkerMatches = false; const Presence filter = getObjectPresence(engine.get(), *key, FwpmFilterGetByKey0, [key](const FWPM_FILTER0 &object) { return persistentFilterMatchesSchema(*key, object); }, + [legacyProviderPresent](const FWPM_FILTER0 &object) { + return equalGuid(object.subLayerKey, kSubLayerKey) && + (legacyProviderPresent + ? (object.providerKey != nullptr && + equalGuid(*object.providerKey, kLegacyProviderKey) && + object.providerData.size == 0) + : (object.providerKey == nullptr && + blobMatchesCurrentSchema(object.providerData))); + }, &objectMatches, + &ownershipMarkerMatches, FWP_E_FILTER_NOT_FOUND, &error, QStringLiteral("Throne WFP filter")); if (filter == Presence::Failed) { - return {BaselineState::Error, error}; + return {BaselineState::Error, error, throneObjectsObserved}; } + throneObjectsObserved |= filter == Presence::Present && ownershipMarkerMatches; presentCount += filter == Presence::Present ? 1 : 0; schemaMatches &= filter != Presence::Present || objectMatches; } for (const GUID *key : kDynamicFilterKeys) { objectMatches = false; + ownershipMarkerMatches = false; const Presence filter = getObjectPresence(engine.get(), *key, FwpmFilterGetByKey0, [key](const FWPM_FILTER0 &object) { return dynamicFilterMatchesSchema(*key, object); }, + [legacyProviderPresent](const FWPM_FILTER0 &object) { + return equalGuid(object.subLayerKey, kSubLayerKey) && + (legacyProviderPresent + ? (object.providerKey != nullptr && + equalGuid(*object.providerKey, kLegacyProviderKey) && + object.providerData.size == 0) + : (object.providerKey == nullptr && + blobMatchesCurrentSchema(object.providerData))); + }, &objectMatches, + &ownershipMarkerMatches, FWP_E_FILTER_NOT_FOUND, &error, QStringLiteral("Throne dynamic WFP filter")); if (filter == Presence::Failed) { - return {BaselineState::Error, error}; + return {BaselineState::Error, error, throneObjectsObserved}; } + throneObjectsObserved |= filter == Presence::Present && ownershipMarkerMatches; dynamicFilterPresent |= filter == Presence::Present; schemaMatches &= filter != Presence::Present || objectMatches; } @@ -1313,8 +1357,10 @@ WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBa const MutationOwnershipResult ownership = verifyMutationOwnership(engine.get()); if (ownership.ownership == MutationOwnership::Foreign || ownership.ownership == MutationOwnership::Error) { - return {BaselineState::Error, ownership.detail}; + return {BaselineState::Error, ownership.detail, throneObjectsObserved}; } + throneObjectsObserved |= + ownership.ownership == MutationOwnership::OwnedByThrone; } if (!anyObjectPresent) { @@ -1325,26 +1371,31 @@ WindowsWfpKillSwitchBackend::BaselineStatus WindowsWfpKillSwitchBackend::queryBa return { BaselineState::Valid, QStringLiteral("The providerless Throne kill-switch v4 baseline is installed"), + true, }; } if (legacyProviderPresent) { return { BaselineState::StaleOrPartial, QStringLiteral("A marked provider-associated Throne v1-v3 policy requires migration to providerless schema v4"), + true, }; } if (presentCount == expectedCount && schemaMatches && staleDynamicFilterPresent) { return {BaselineState::StaleOrPartial, - QStringLiteral("The baseline is valid, but stale Throne runtime allowances are present")}; + QStringLiteral("The baseline is valid, but stale Throne runtime allowances are present"), + true}; } if (presentCount == expectedCount) { return {BaselineState::StaleOrPartial, - QStringLiteral("All Throne kill-switch objects exist, but at least one uses an obsolete or invalid schema")}; + QStringLiteral("All Throne kill-switch objects exist, but at least one uses an obsolete or invalid schema"), + throneObjectsObserved}; } return {BaselineState::StaleOrPartial, QStringLiteral("Only %1 of %2 expected Throne kill-switch objects are present") .arg(presentCount) - .arg(expectedCount)}; + .arg(expectedCount), + throneObjectsObserved}; } bool WindowsWfpKillSwitchBackend::reconcileBaseline(QString *error) @@ -1406,9 +1457,10 @@ Configs_sys::KillSwitchReconcileResult WindowsWfpKillSwitchBackend::reconcile() } if (status.state == BaselineState::Error) { // Failure to query BFE cannot prove that Throne's persistent objects - // are absent. Conservatively report protection as active so the - // controller remains enabled and refuses an unverified transition. - return {Configs_sys::KillSwitchResult::Failure(status.detail), {true, false, false}}; + // are absent. Retain fail-closed state when an owned object was already + // observed; otherwise a default-off installation remains disabled. + return {Configs_sys::KillSwitchResult::Failure(status.detail), + {status.throneObjectsObserved, false, false}}; } if (status.state == BaselineState::Absent) { return {Configs_sys::KillSwitchResult::Success(), {}}; diff --git a/src/ui/mainWindow/mainwindow_setup.cpp b/src/ui/mainWindow/mainwindow_setup.cpp index 4dcb57e56..bf123b6a6 100644 --- a/src/ui/mainWindow/mainwindow_setup.cpp +++ b/src/ui/mainWindow/mainwindow_setup.cpp @@ -71,8 +71,20 @@ #include #include "include/global/DeviceDetailsHelper.hpp" -void UI_InitMainWindow() { - mainwindow = new MainWindow; +bool UI_InitMainWindow() { + auto *candidate = new MainWindow; + if (mainwindow != candidate) { + delete candidate; + // Clear callbacks which may have been installed by startup work before + // the constructor discovered a fatal initialization error. Nothing may + // retain a callable that captures the deleted partial window. + MW_dialog_message = {}; + MW_handle_deeplink = {}; + MW_import_files = {}; + MW_show_log = {}; + return false; + } + return true; } // Caller must hold coreProcessMutex (reads core_process lock-free by design). @@ -122,26 +134,7 @@ static bool themeUsesDarkLog(const QString &theme) { } MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow) { - mainwindow = this; setAcceptDrops(true); - MW_dialog_message = [=,this](MwMessage cmd, QStringList args) { - runOnUiThread([=,this] - { - dialog_message_impl(cmd, args); - }); - }; - MW_handle_deeplink = [=,this](const QString &url) { - runOnUiThread([=,this] - { - handle_deeplink_impl(url); - }); - }; - MW_import_files = [=,this](const QStringList &paths) { - runOnUiThread([=,this] - { - importFromFiles(paths); - }); - }; // handle AutoRun migration and stale task settings AutoRun_FixTaskIfNeeded(); @@ -182,9 +175,6 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi ui->masterLogBrowser->setDocument(qvLogDocument); applyLogBrowserFont(); updateLogFilterFields(); - runOnThread([=, this] { - log_process_loop(); - }, LogThread); #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged, this, [=,this](const Qt::ColorScheme& scheme) { @@ -196,20 +186,45 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWi setLogHighlighter(themeUsesDarkLog(theme)); scheduleProxyListRefresh(); }); - MW_show_log = [=,this](const QString &log) { - append_log(log); - Logging::WriteUserLog(log); - }; // Reconcile persistent fail-closed policy before ThroneCore or any profile // can create direct sockets. If requested or stale policy cannot be // reconciled, leave any persistent block intact and exit into the explicit // recovery flow instead of launching the trusted core unaudited. if (!initializeKillSwitch()) { - QTimer::singleShot(0, [] { QCoreApplication::quit(); }); return; } + // Publish the window and its callbacks only after fail-closed startup has + // succeeded. UI_InitMainWindow uses this publication as its success signal, + // so failed construction cannot expose a partially initialized window. + mainwindow = this; + MW_dialog_message = [=,this](MwMessage cmd, QStringList args) { + runOnUiThread([=,this] + { + dialog_message_impl(cmd, args); + }); + }; + MW_handle_deeplink = [=,this](const QString &url) { + runOnUiThread([=,this] + { + handle_deeplink_impl(url); + }); + }; + MW_import_files = [=,this](const QStringList &paths) { + runOnUiThread([=,this] + { + importFromFiles(paths); + }); + }; + MW_show_log = [=,this](const QString &log) { + append_log(log); + Logging::WriteUserLog(log); + }; + runOnThread([=, this] { + log_process_loop(); + }, LogThread); + // Listen port if random if (Configs::dataManager->settingsRepo->random_inbound_port) { diff --git a/src/ui/mainwindow_killswitch.cpp b/src/ui/mainwindow_killswitch.cpp index 05c88471c..c4cbcc3ad 100644 --- a/src/ui/mainwindow_killswitch.cpp +++ b/src/ui/mainwindow_killswitch.cpp @@ -1,6 +1,7 @@ #include "include/ui/mainwindow.h" #include "include/global/Configs.hpp" +#include "include/global/Logger.hpp" #include "include/sys/KillSwitchController.hpp" #include "include/sys/Process.hpp" @@ -69,6 +70,11 @@ std::optional currentTunInterface() bool MainWindow::initializeKillSwitch() { #ifdef Q_OS_WIN + const auto startupLog = [this](const QString &message) { + append_log(message); + Logging::WriteUserLog(message); + }; + killSwitchBackend = std::make_unique(); killSwitchController = std::make_unique(*killSwitchBackend); @@ -85,11 +91,11 @@ bool MainWindow::initializeKillSwitch() Configs::dataManager->settingsRepo->Save(); } if (initialized.recoveredStaleProtection) { - MW_show_log(tr("Recovered persistent Throne kill-switch state.")); + startupLog(tr("Recovered persistent Throne kill-switch state.")); } if (!initialized) { - MW_show_log(tr("Failed to initialize kill switch: %1") - .arg(initialized.result.error)); + startupLog(tr("Failed to initialize kill switch: %1") + .arg(initialized.result.error)); QMessageBox::critical( this, tr("Kill switch unavailable"), @@ -101,7 +107,7 @@ bool MainWindow::initializeKillSwitch() return false; } if (initialized.enabled) { - MW_show_log(tr("Kill switch enabled (persistent IPv4 and IPv6 blocking active).")); + startupLog(tr("Kill switch enabled (persistent IPv4 and IPv6 blocking active).")); } #endif return true; diff --git a/src/ui/setting/dialog_vpn_settings.cpp b/src/ui/setting/dialog_vpn_settings.cpp index 5218eac67..a2e72f299 100644 --- a/src/ui/setting/dialog_vpn_settings.cpp +++ b/src/ui/setting/dialog_vpn_settings.cpp @@ -91,43 +91,92 @@ void DialogVPNSettings::accept() { return; } - Configs::dataManager->settingsRepo->vpn_implementation = ui->vpn_implementation->currentText(); - Configs::dataManager->settingsRepo->vpn_mtu = mtu; - Configs::dataManager->settingsRepo->vpn_ipv6 = ui->vpn_ipv6->isChecked(); - Configs::dataManager->settingsRepo->vpn_strict_route = ui->strict_route->isChecked(); - Configs::dataManager->settingsRepo->enable_tun_routing = ui->tun_routing->isChecked(); - Configs::dataManager->settingsRepo->vpn_tun_ipv4_cidr = tunIPv4CIDR; - Configs::dataManager->settingsRepo->vpn_tun_ipv6_cidr = tunIPv6CIDR; - Configs::dataManager->settingsRepo->disable_private_range_bypass = ui->disable_priv_range->isChecked(); - Configs::dataManager->settingsRepo->vpn_auto_redirect = ui->auto_redirect->isChecked(); + struct VpnSettingsValues { + QString implementation; + int mtu; + bool ipv6; + bool strictRoute; + bool tunRouting; + QString tunIPv4CIDR; + QString tunIPv6CIDR; + bool disablePrivateRangeBypass; + bool autoRedirect; + }; + + auto &settings = *Configs::dataManager->settingsRepo; + const VpnSettingsValues pendingSettings{ + ui->vpn_implementation->currentText(), + mtu, + ui->vpn_ipv6->isChecked(), + ui->strict_route->isChecked(), + ui->tun_routing->isChecked(), + tunIPv4CIDR, + tunIPv6CIDR, + ui->disable_priv_range->isChecked(), + ui->auto_redirect->isChecked(), + }; + const auto applySettings = [&settings](const VpnSettingsValues &values) { + settings.vpn_implementation = values.implementation; + settings.vpn_mtu = values.mtu; + settings.vpn_ipv6 = values.ipv6; + settings.vpn_strict_route = values.strictRoute; + settings.enable_tun_routing = values.tunRouting; + settings.vpn_tun_ipv4_cidr = values.tunIPv4CIDR; + settings.vpn_tun_ipv6_cidr = values.tunIPv6CIDR; + settings.disable_private_range_bypass = values.disablePrivateRangeBypass; + settings.vpn_auto_redirect = values.autoRedirect; + }; + bool protectedRestartWillApplySettings = false; #ifdef Q_OS_WIN + const VpnSettingsValues previousSettings{ + settings.vpn_implementation, + settings.vpn_mtu, + settings.vpn_ipv6, + settings.vpn_strict_route, + settings.enable_tun_routing, + settings.vpn_tun_ipv4_cidr, + settings.vpn_tun_ipv6_cidr, + settings.disable_private_range_bypass, + settings.vpn_auto_redirect, + }; const bool requestedKillSwitch = ui->kill_switch->isChecked(); - if (requestedKillSwitch != - Configs::dataManager->settingsRepo->kill_switch_enabled) { + if (requestedKillSwitch != settings.kill_switch_enabled) { // A non-elevated first enable restarts the whole application. Persist - // all ordinary fields before launching that replacement, and skip the - // normal profile-restart prompt (which would otherwise run before the - // WFP baseline is installed). - if (requestedKillSwitch && !Configs::IsAdmin()) { - Configs::dataManager->settingsRepo->Save(); - protectedRestartWillApplySettings = true; + // the pending ordinary fields immediately before launching the helper, + // because the elevated replacement must read them from the database. + // If any part of that transition fails, restore both the live and + // persisted ordinary settings before leaving the dialog open. + const bool stageForElevatedRestart = + requestedKillSwitch && !Configs::IsAdmin(); + if (stageForElevatedRestart) { + applySettings(pendingSettings); + settings.Save(); } QString error; if (!GetMainWindow()->setKillSwitchEnabled(requestedKillSwitch, &error)) { + if (stageForElevatedRestart) { + applySettings(previousSettings); + settings.Save(); + } QMessageBox::critical( this, tr("Kill switch change failed"), tr("The requested kill-switch change could not be completed safely. " "Throne retained the safest state it could verify.\n\n%1") .arg(error)); - ui->kill_switch->setChecked( - Configs::dataManager->settingsRepo->kill_switch_enabled); + ui->kill_switch->setChecked(settings.kill_switch_enabled); return; } + protectedRestartWillApplySettings = stageForElevatedRestart; } #endif + // For an ordinary in-process transition, commit these fields only after + // the kill-switch state change has succeeded. In the elevated-restart case + // this simply reapplies the already persisted pending snapshot. + applySettings(pendingSettings); + if (!protectedRestartWillApplySettings) { MW_dialog_message(MwMessage::UpdateSettings, {MwArg::Vpn}); } diff --git a/tests/KillSwitchControllerTest.cpp b/tests/KillSwitchControllerTest.cpp index 06a539ab7..9e51e53f3 100644 --- a/tests/KillSwitchControllerTest.cpp +++ b/tests/KillSwitchControllerTest.cpp @@ -193,23 +193,40 @@ void reconcileFailureRetainsObservedProtection() { requireInvariant(controller); } -void reconcileFailureWithoutObservedStateStillFailsClosed() { +void reconcileFailureWithoutObservedStatePreservesDefaultOffBehavior() { FakeBackend backend; backend.failReconcile = true; - backend.failEnsureBaseline = true; KillSwitchController controller(backend); const auto initialized = controller.initialize(false, trustedCorePlan()); + REQUIRE(initialized); + REQUIRE(!initialized.enabled); + REQUIRE(!initialized.recoveredStaleProtection); + REQUIRE(initialized.state == KillSwitchController::State::Disabled); + + const auto prepared = controller.prepareForProfileStart( + KillSwitchController::StartIntent::Connect); + REQUIRE(prepared); + REQUIRE(prepared.operationId == 0); + REQUIRE(backend.disableCalls == 0); + requireInvariant(controller); +} + +void configuredProtectionStillFailsClosedOnUnknownReconcile() { + FakeBackend backend; + backend.failReconcile = true; + backend.failEnsureBaseline = true; + KillSwitchController controller(backend); + + const auto initialized = controller.initialize(true, trustedCorePlan()); REQUIRE(!initialized); REQUIRE(initialized.enabled); - REQUIRE(!initialized.recoveredStaleProtection); REQUIRE(initialized.state == KillSwitchController::State::Error); const auto prepared = controller.prepareForProfileStart( KillSwitchController::StartIntent::Connect); REQUIRE(!prepared); REQUIRE(!prepared.mayTearDownCurrentProfile); - REQUIRE(backend.disableCalls == 0); requireInvariant(controller); } @@ -540,7 +557,8 @@ int main() { {"disabled preserves existing behavior", disabledPreservesExistingBehavior}, {"stale protection is recovered, not deleted", staleProtectionIsRecoveredNotDeleted}, {"reconcile failure retains observed protection", reconcileFailureRetainsObservedProtection}, - {"unknown reconcile failure still fails closed", reconcileFailureWithoutObservedStateStillFailsClosed}, + {"unknown reconcile failure preserves default-off behavior", reconcileFailureWithoutObservedStatePreservesDefaultOffBehavior}, + {"configured protection stays closed on unknown reconcile", configuredProtectionStillFailsClosedOnUnknownReconcile}, {"normal TUN connection is dual stack", normalTunConnectionIsDualStack}, {"configured launch is not stale recovery", configuredLaunchDoesNotReportStaleRecovery}, {"System Proxy connection needs no TUN allowance", systemProxyConnectionNeedsNoTunAllowance}, From 7a97013e2c38b6c63646532a8c62bd48b39ae94a Mon Sep 17 00:00:00 2001 From: 4RH1T3CT0R7 Date: Thu, 13 Aug 2026 15:39:46 +0300 Subject: [PATCH 5/5] fix: classify routing outbounds for fail-closed builds Classify routing-profile outbounds and every chain hop while collecting dependencies. This keeps the fail-closed policy explicit where these additional egress paths enter the build and protects against later build-order changes bypassing shared chain validation. Also clarify that the rejection applies to profiles selected by routing rules. --- src/configs/generate.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/configs/generate.cpp b/src/configs/generate.cpp index a5bb72c8e..924766fc2 100644 --- a/src/configs/generate.cpp +++ b/src/configs/generate.cpp @@ -627,6 +627,12 @@ namespace Configs { ctx.error = "Outbounds used in routing profile cannot use an extra core or be a custom full config"; return; } + // A routing rule may select an outbound whose egress leaves the + // tunnel (a direct profile builds a real sing-box direct + // outbound). Its tag is route-chain-N, so fail-closed rule + // hardening never rewrites it. + if (hasUnverifiableNetworkBehavior(neededEnt)) + ctx.result->hasUnverifiableNetworkConfig = true; if (neededEnt->type == "chain") { auto chain = neededEnt->Chain(); if (chain == nullptr || chain->list.isEmpty()) { @@ -644,6 +650,8 @@ namespace Configs { ctx.error = "Chain hops in routing profile cannot use an extra core, a custom full config, or be of type chain"; return; } + if (hasUnverifiableNetworkBehavior(hopEnt)) + ctx.result->hasUnverifiableNetworkConfig = true; if (usesXrayCore(hopEnt)) ctx.proxyUsesXray = true; // Collect exact endpoint hostnames for bootstrap DNS. if (auto addrs = getEntDomains({hopID}, ctx.error); !addrs.empty()) { @@ -2287,7 +2295,8 @@ namespace Configs { ctx.error = QObject::tr( "Direct, SOCKS4, Tailscale, ExtraCore, auto-selector, and custom " "profiles are not supported while the kill switch is active because " - "their direct-routing or destination-DNS behavior cannot be constrained safely."); + "their direct-routing or destination-DNS behavior cannot be constrained " + "safely. This also applies to profiles selected by the routing profile."); if (failed()) return ctx.result; }