From afc2be414ce764fea684bce4e6fb9c010d597d37 Mon Sep 17 00:00:00 2001 From: Vitaly Kuzyaev <37330183+vitkuz573@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:09:30 +0500 Subject: [PATCH] feat: add headless cli client --- client/CMakeLists.txt | 2 + client/cli/CMakeLists.txt | 72 ++ client/cli/README.md | 58 + client/cli/cli_common.h | 50 + client/cli/cli_context.cpp | 1050 +++++++++++++++++ client/cli/cli_context.h | 125 ++ client/cli/cli_ipc.cpp | 239 ++++ client/cli/cli_ipc.h | 46 + client/cli/main.cpp | 696 +++++++++++ client/core/controllers/gatewayController.cpp | 33 +- .../controllers/api/apiConfigsController.cpp | 14 +- .../ui/controllers/connectionController.cpp | 21 +- 12 files changed, 2392 insertions(+), 14 deletions(-) create mode 100644 client/cli/CMakeLists.txt create mode 100644 client/cli/README.md create mode 100644 client/cli/cli_common.h create mode 100644 client/cli/cli_context.cpp create mode 100644 client/cli/cli_context.h create mode 100644 client/cli/cli_ipc.cpp create mode 100644 client/cli/cli_ipc.h create mode 100644 client/cli/main.cpp diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 0f3ae7a0f2..ab4dc84e1f 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -238,3 +238,5 @@ if(COMMAND qt_finalize_executable) else() qt_finalize_target(${PROJECT}) endif() + +add_subdirectory(cli) diff --git a/client/cli/CMakeLists.txt b/client/cli/CMakeLists.txt new file mode 100644 index 0000000000..6d1571e6e3 --- /dev/null +++ b/client/cli/CMakeLists.txt @@ -0,0 +1,72 @@ +set(CLI_PROJECT amnezia-cli) + +set(CLI_SOURCES ${SOURCES}) +list(FILTER CLI_SOURCES EXCLUDE REGEX "/main\\.cpp$") +list(FILTER CLI_SOURCES EXCLUDE REGEX "/amnezia_application\\.cpp$") +list(FILTER CLI_SOURCES EXCLUDE REGEX "/core/controllers/coreController\\.cpp$") +list(FILTER CLI_SOURCES EXCLUDE REGEX "/ui/controllers/settingsController\\.cpp$") +list(FILTER CLI_SOURCES EXCLUDE REGEX "/core/osSignalHandler\\.cpp$") + +set(CLI_HEADERS ${HEADERS}) +list(FILTER CLI_HEADERS EXCLUDE REGEX "/amnezia_application\\.h$") +list(FILTER CLI_HEADERS EXCLUDE REGEX "/core/controllers/coreController\\.h$") +list(FILTER CLI_HEADERS EXCLUDE REGEX "/ui/controllers/settingsController\\.h$") +list(FILTER CLI_HEADERS EXCLUDE REGEX "/core/osSignalHandler\\.h$") + +list(APPEND CLI_SOURCES + ${CMAKE_CURRENT_LIST_DIR}/main.cpp + ${CMAKE_CURRENT_LIST_DIR}/cli_context.cpp + ${CMAKE_CURRENT_LIST_DIR}/cli_ipc.cpp +) + +qt_add_executable(${CLI_PROJECT} MANUAL_FINALIZATION) +target_include_directories(${CLI_PROJECT} PUBLIC + $ + $ +) + +if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID)) + qt_add_repc_replicas(${CLI_PROJECT} ${CMAKE_CURRENT_LIST_DIR}/../../ipc/ipc_interface.rep) + qt_add_repc_replicas(${CLI_PROJECT} ${CMAKE_CURRENT_LIST_DIR}/../../ipc/ipc_process_interface.rep) +endif() + +target_link_libraries(${CLI_PROJECT} PRIVATE ${LIBS}) +target_compile_definitions(${CLI_PROJECT} PRIVATE "MZ_$") + +if(WIN32 OR (APPLE AND NOT IOS AND NOT MACOS_NE) OR (LINUX AND NOT ANDROID)) + target_compile_definitions(${CLI_PROJECT} PRIVATE AMNEZIA_DESKTOP) +endif() + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + target_compile_definitions(${CLI_PROJECT} PRIVATE "MZ_DEBUG") +endif() + +target_sources(${CLI_PROJECT} PRIVATE ${CLI_SOURCES} ${CLI_HEADERS}) + +if(NOT IOS AND NOT ANDROID AND NOT MACOS_NE) + add_custom_command( + TARGET ${CLI_PROJECT} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E $,copy_directory,true> + ${CMAKE_SOURCE_DIR}/deploy/data/${DEPLOY_PLATFORM_PATH} + $ + COMMAND_EXPAND_LISTS + ) + if(EXISTS "${CMAKE_SOURCE_DIR}/client/3rd-prebuilt/deploy-prebuilt/${DEPLOY_PLATFORM_PATH}") + add_custom_command( + TARGET ${CLI_PROJECT} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E $,copy_directory,true> + ${CMAKE_SOURCE_DIR}/client/3rd-prebuilt/deploy-prebuilt/${DEPLOY_PLATFORM_PATH} + $ + COMMAND_EXPAND_LISTS + ) + endif() +endif() + +if(COMMAND qt_import_qml_plugins) + qt_import_qml_plugins(${CLI_PROJECT}) +endif() +if(COMMAND qt_finalize_executable) + qt_finalize_executable(${CLI_PROJECT}) +else() + qt_finalize_target(${CLI_PROJECT}) +endif() diff --git a/client/cli/README.md b/client/cli/README.md new file mode 100644 index 0000000000..0298164746 --- /dev/null +++ b/client/cli/README.md @@ -0,0 +1,58 @@ +# amnezia-cli + +`amnezia-cli` is a headless command-line client built on top of the same core models, controllers, and VPN runtime used by the desktop application. + +## Build + +From the repository root: + +```bash +cmake -S . -B build -G Ninja +cmake --build build --target amnezia-cli -j4 +``` + +The binary is produced at: + +```bash +./build/client/cli/amnezia-cli +``` + +## Command Model + +- `connect` and `disconnect` talk to a local CLI daemon over `QLocalSocket`. +- `status` queries the daemon when it is running; otherwise it returns a local disconnected snapshot. +- The daemon keeps VPN state and allows the CLI process itself to stay short-lived. +- Management commands such as `servers`, `countries`, `containers`, `install`, and `logs cleanup` can run directly or through the daemon. + +## Common Commands + +```bash +./build/client/cli/amnezia-cli status +./build/client/cli/amnezia-cli daemon start +./build/client/cli/amnezia-cli servers list +./build/client/cli/amnezia-cli countries list --index 0 +./build/client/cli/amnezia-cli containers list --index 0 +./build/client/cli/amnezia-cli connect --index 0 +./build/client/cli/amnezia-cli disconnect +``` + +JSON output is available for automation on any command: + +```bash +./build/client/cli/amnezia-cli status --json +``` + +## Supported Operations + +- Inspect VPN state: `status` +- Control VPN connection: `connect`, `disconnect` +- Control the daemon: `daemon start|stop|status` +- Manage saved servers: `servers list|show|add|import|remove|set-default|scan` +- Manage available countries for API-backed configs: `countries list|set` +- Manage containers: `containers list|set-default|remove` +- Install a new server or container: `install server`, `install container` +- Cleanup local logs: `logs cleanup` + +## Notes + +- The daemon redirects its own stdout and stderr to avoid breaking `--json` output from the foreground CLI process. diff --git a/client/cli/cli_common.h b/client/cli/cli_common.h new file mode 100644 index 0000000000..f069de881d --- /dev/null +++ b/client/cli/cli_common.h @@ -0,0 +1,50 @@ +#ifndef CLI_COMMON_H +#define CLI_COMMON_H + +#include +#include + +namespace cli +{ + +struct Result +{ + bool ok = false; + int exitCode = 1; + QString message; + QJsonObject data; + + static Result success(const QString &message = {}, const QJsonObject &data = {}) + { + return { true, 0, message, data }; + } + + static Result failure(const QString &message, int exitCode = 1, const QJsonObject &data = {}) + { + return { false, exitCode, message, data }; + } +}; + +inline QJsonObject resultToJson(const Result &result) +{ + QJsonObject json; + json["ok"] = result.ok; + json["exit_code"] = result.exitCode; + json["message"] = result.message; + json["data"] = result.data; + return json; +} + +inline Result resultFromJson(const QJsonObject &json) +{ + Result result; + result.ok = json.value("ok").toBool(false); + result.exitCode = json.value("exit_code").toInt(result.ok ? 0 : 1); + result.message = json.value("message").toString(); + result.data = json.value("data").toObject(); + return result; +} + +} // namespace cli + +#endif // CLI_COMMON_H diff --git a/client/cli/cli_context.cpp b/client/cli/cli_context.cpp new file mode 100644 index 0000000000..9572e00c2e --- /dev/null +++ b/client/cli/cli_context.cpp @@ -0,0 +1,1050 @@ +#include "cli_context.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/api/apiDefs.h" +#include "logger.h" +#include "core/errorstrings.h" +#include "ui/controllers/api/apiSettingsController.h" +#include "ui/controllers/api/apiConfigsController.h" +#include "ui/controllers/connectionController.h" +#include "ui/controllers/importController.h" +#include "ui/controllers/installController.h" +#include "ui/models/api/apiAccountInfoModel.h" +#include "ui/models/api/apiBenefitsModel.h" +#include "ui/models/api/apiCountryModel.h" +#include "ui/models/api/apiDevicesModel.h" +#include "ui/models/api/apiServicesModel.h" +#include "ui/models/api/apiSubscriptionPlansModel.h" +#include "ui/models/clientManagementModel.h" +#include "ui/models/containers_model.h" +#include "ui/models/protocols_model.h" +#include "ui/models/servers_model.h" +#include "version.h" +#include "vpnconnection.h" + +namespace +{ + +using namespace amnezia; + +QString serviceTypeKey(ServiceType serviceType) +{ + switch (serviceType) { + case ServiceType::None: return "none"; + case ServiceType::Vpn: return "vpn"; + case ServiceType::Other: return "other"; + } + + return "unknown"; +} + +QString hostWithOptionalPort(const ServerCredentials &credentials) +{ + if (credentials.port > 0 && credentials.port != 22) { + return QString("%1:%2").arg(credentials.hostName).arg(credentials.port); + } + + return credentials.hostName; +} + +void disconnectTemporaryConnections(const QList &connections) +{ + for (const auto &connection : connections) { + QObject::disconnect(connection); + } +} + +QJsonObject apiConfigObject(const QJsonObject &serverConfig) +{ + return serverConfig.value(apiDefs::key::apiConfig).toObject(); +} + +QJsonArray availableCountriesArray(const QJsonObject &serverConfig) +{ + return apiConfigObject(serverConfig).value(apiDefs::key::availableCountries).toArray(); +} + +QString selectedCountryCode(const QJsonObject &serverConfig) +{ + return apiConfigObject(serverConfig).value(apiDefs::key::serverCountryCode).toString(); +} + +QString selectedCountryName(const QJsonObject &serverConfig) +{ + return apiConfigObject(serverConfig).value(apiDefs::key::serverCountryName).toString(); +} + +QHash issuedConfigsByCountry(const QJsonArray &issuedConfigs) +{ + QHash issuedByCountry; + + for (const auto &value : issuedConfigs) { + const auto issuedObject = value.toObject(); + if (issuedObject.value(apiDefs::key::sourceType).toString() != QStringLiteral("country_config")) { + continue; + } + + issuedByCountry.insert(issuedObject.value(apiDefs::key::serverCountryCode).toString().toLower(), issuedObject); + } + + return issuedByCountry; +} + +QJsonObject countrySummary(const QJsonObject &countryObject, const QString &selectedCountryCode, + const QHash &issuedByCountry) +{ + const QString countryCode = countryObject.value(apiDefs::key::serverCountryCode).toString(); + const QString normalizedCode = countryCode.toLower(); + const auto issuedObject = issuedByCountry.value(normalizedCode); + + QJsonObject summary; + summary["code"] = countryCode; + summary["name"] = countryObject.value(apiDefs::key::serverCountryName).toString(); + summary["selected"] = normalizedCode == selectedCountryCode.toLower(); + summary["issued"] = !issuedObject.isEmpty(); + summary["worker_expired"] = + issuedObject.value(apiDefs::key::lastDownloaded).toString() < issuedObject.value(apiDefs::key::workerLastUpdated).toString(); + + return summary; +} + +} // namespace + +namespace cli +{ + +QString connectionStateKey(Vpn::ConnectionState state) +{ + switch (state) { + case Vpn::ConnectionState::Unknown: return "unknown"; + case Vpn::ConnectionState::Disconnected: return "disconnected"; + case Vpn::ConnectionState::Preparing: return "preparing"; + case Vpn::ConnectionState::Connecting: return "connecting"; + case Vpn::ConnectionState::Connected: return "connected"; + case Vpn::ConnectionState::Disconnecting: return "disconnecting"; + case Vpn::ConnectionState::Reconnecting: return "reconnecting"; + case Vpn::ConnectionState::Error: return "error"; + } + + return "unknown"; +} + +QString displayContainerName(amnezia::DockerContainer container) +{ + return ContainerProps::containerHumanNames().value(container, ContainerProps::containerTypeToString(container)); +} + +amnezia::DockerContainer containerFromCliName(const QString &rawName) +{ + const QString name = rawName.trimmed().toLower(); + if (name.isEmpty()) { + return amnezia::DockerContainer::None; + } + + const QHash aliases = { + { "none", amnezia::DockerContainer::None }, + { "openvpn", amnezia::DockerContainer::OpenVpn }, + { "amnezia-openvpn", amnezia::DockerContainer::OpenVpn }, + { "cloak", amnezia::DockerContainer::Cloak }, + { "openvpn-cloak", amnezia::DockerContainer::Cloak }, + { "amnezia-openvpn-cloak", amnezia::DockerContainer::Cloak }, + { "shadowsocks", amnezia::DockerContainer::ShadowSocks }, + { "openvpn-ss", amnezia::DockerContainer::ShadowSocks }, + { "amnezia-shadowsocks", amnezia::DockerContainer::ShadowSocks }, + { "wireguard", amnezia::DockerContainer::WireGuard }, + { "amnezia-wireguard", amnezia::DockerContainer::WireGuard }, + { "awg", amnezia::DockerContainer::Awg2 }, + { "awg2", amnezia::DockerContainer::Awg2 }, + { "amneziawg", amnezia::DockerContainer::Awg2 }, + { "amneziawg2", amnezia::DockerContainer::Awg2 }, + { "amnezia-awg", amnezia::DockerContainer::Awg }, + { "amnezia-awg2", amnezia::DockerContainer::Awg2 }, + { "xray", amnezia::DockerContainer::Xray }, + { "amnezia-xray", amnezia::DockerContainer::Xray }, + { "ssxray", amnezia::DockerContainer::SSXray }, + { "amnezia-ssxray", amnezia::DockerContainer::SSXray }, + { "ipsec", amnezia::DockerContainer::Ipsec }, + { "ikev2", amnezia::DockerContainer::Ipsec }, + { "amnezia-ipsec", amnezia::DockerContainer::Ipsec }, + { "dns", amnezia::DockerContainer::Dns }, + { "amneziadns", amnezia::DockerContainer::Dns }, + { "amnezia-dns", amnezia::DockerContainer::Dns }, + { "tor", amnezia::DockerContainer::TorWebSite }, + { "torwebsite", amnezia::DockerContainer::TorWebSite }, + { "amnezia-torwebsite", amnezia::DockerContainer::TorWebSite }, + { "sftp", amnezia::DockerContainer::Sftp }, + { "amnezia-sftp", amnezia::DockerContainer::Sftp }, + { "socks5", amnezia::DockerContainer::Socks5Proxy }, + { "socks5proxy", amnezia::DockerContainer::Socks5Proxy }, + { "amnezia-socks5proxy", amnezia::DockerContainer::Socks5Proxy }, + }; + + if (aliases.contains(name)) { + return aliases.value(name); + } + + const auto parsed = ContainerProps::containerFromString(name); + if (parsed != amnezia::DockerContainer::None) { + return parsed; + } + + for (const auto container : ContainerProps::allContainers()) { + if (ContainerProps::containerTypeToString(container) == name) { + return container; + } + } + + return amnezia::DockerContainer::None; +} + +QStringList availableContainerNames() +{ + return { + "openvpn", + "cloak", + "shadowsocks", + "wireguard", + "awg", + "awg2", + "xray", + "ssxray", + "ikev2", + "dns", + "torwebsite", + "sftp", + "socks5proxy", + }; +} + +Context::Context(QObject *parent) + : QObject(parent) +{ + registerMetaTypes(); + + m_settings = std::make_shared(); + + m_serversModel.reset(new ServersModel(m_settings, this)); + m_containersModel.reset(new ContainersModel(this)); + m_protocolsModel.reset(new ProtocolsModel(m_settings, this)); + m_clientManagementModel.reset(new ClientManagementModel(m_settings, this)); + m_apiAccountInfoModel.reset(new ApiAccountInfoModel(this)); + m_apiCountryModel.reset(new ApiCountryModel(this)); + m_apiDevicesModel.reset(new ApiDevicesModel(m_settings, this)); + m_apiServicesModel.reset(new ApiServicesModel(this)); + m_apiSubscriptionPlansModel.reset(new ApiSubscriptionPlansModel(this)); + m_apiBenefitsModel.reset(new ApiBenefitsModel(this)); + + connect(m_serversModel.get(), &ServersModel::containersUpdated, m_containersModel.get(), &ContainersModel::updateModel); + + m_vpnConnection.reset(new VpnConnection(m_settings)); + m_vpnConnection->moveToThread(&m_vpnConnectionThread); + m_vpnConnectionThread.start(); + + m_connectionController.reset( + new ConnectionController(m_serversModel, m_containersModel, m_clientManagementModel, m_vpnConnection, m_settings, this)); + m_installController.reset(new InstallController(m_serversModel, m_containersModel, m_protocolsModel, m_clientManagementModel, m_settings, this)); + m_importController.reset(new ImportController(m_serversModel, m_containersModel, m_settings, this)); + m_apiConfigsController.reset(new ApiConfigsController( + m_serversModel, m_apiServicesModel, m_apiSubscriptionPlansModel, m_apiBenefitsModel, m_settings, this)); + m_apiSettingsController.reset( + new ApiSettingsController(m_serversModel, m_apiAccountInfoModel, m_apiCountryModel, m_apiDevicesModel, m_settings, this)); + + connect(m_connectionController.get(), &ConnectionController::prepareConfig, this, [this]() { + clearLastError(); + emit m_vpnConnection->connectionStateChanged(Vpn::ConnectionState::Preparing); + + if (!m_apiConfigsController->isConfigValid()) { + emit m_vpnConnection->connectionStateChanged(Vpn::ConnectionState::Disconnected); + return; + } + + m_installController->validateConfig(); + }); + + connect(m_installController.get(), &InstallController::configValidated, this, [this](bool isValid) { + if (!isValid) { + emit m_vpnConnection->connectionStateChanged(Vpn::ConnectionState::Disconnected); + return; + } + + m_connectionController->openConnection(); + }); + + connect(m_connectionController.get(), &ConnectionController::connectionErrorOccurred, this, [this](ErrorCode error) { + setLastError(error); + emit m_vpnConnection->connectionStateChanged(Vpn::ConnectionState::Disconnected); + }); + + connect(m_installController.get(), &InstallController::installationErrorOccurred, this, &Context::setLastError); + connect(m_apiConfigsController.get(), &ApiConfigsController::errorOccurred, this, &Context::setLastError); + connect(m_apiSettingsController.get(), &ApiSettingsController::errorOccurred, this, &Context::setLastError); + + connect(m_vpnConnection.get(), &VpnConnection::connectionStateChanged, this, [this](Vpn::ConnectionState state) { + m_state = state; + if (state == Vpn::ConnectionState::Connected) { + clearLastError(); + } + if (state == Vpn::ConnectionState::Disconnected && m_lastError == ErrorCode::NoError) { + m_totalReceivedBytes = 0; + m_totalSentBytes = 0; + } + emit statusChanged(); + }); + + connect(m_vpnConnection.get(), &VpnConnection::bytesChanged, this, [this](quint64 receivedBytes, quint64 sentBytes) { + m_totalReceivedBytes += receivedBytes; + m_totalSentBytes += sentBytes; + emit statusChanged(); + }); + + reload(); +} + +Context::~Context() +{ + if (m_vpnConnection && m_vpnConnectionThread.isRunning()) { + QMetaObject::invokeMethod(m_vpnConnection.get(), "disconnectSlots", Qt::BlockingQueuedConnection); + QMetaObject::invokeMethod(m_vpnConnection.get(), "disconnectFromVpn", Qt::BlockingQueuedConnection); + } + + m_vpnConnectionThread.requestInterruption(); + m_vpnConnectionThread.quit(); + + if (!m_vpnConnectionThread.wait(3000)) { + m_vpnConnectionThread.terminate(); + m_vpnConnectionThread.wait(500); + } +} + +void Context::registerMetaTypes() +{ + qRegisterMetaType("ServerCredentials"); + qRegisterMetaType("DockerContainer"); + qRegisterMetaType("TransportProto"); + qRegisterMetaType("Proto"); + qRegisterMetaType("ServiceType"); + qRegisterMetaType("amnezia::ErrorCode"); + qRegisterMetaType("Vpn::ConnectionState"); +} + +Result Context::reload() +{ + m_serversModel->resetModel(); + + if (hasServers()) { + const int defaultIndex = m_serversModel->getDefaultServerIndex(); + if (defaultIndex >= 0 && defaultIndex < m_serversModel->getServersCount()) { + m_serversModel->setProcessedServerIndex(defaultIndex); + } + } else { + m_containersModel->updateModel(QJsonArray {}); + } + + refreshActiveContainer(); + emit statusChanged(); + return Result::success(); +} + +bool Context::hasServers() const +{ + return m_serversModel->getServersCount() > 0; +} + +Result Context::resolveServerIndexResult(int requestedIndex, int &resolvedIndex) const +{ + if (!hasServers()) { + return Result::failure("No servers configured"); + } + + resolvedIndex = requestedIndex >= 0 ? requestedIndex : m_serversModel->getDefaultServerIndex(); + if (resolvedIndex < 0 || resolvedIndex >= m_serversModel->getServersCount()) { + return Result::failure(QString("Server index %1 is out of range").arg(resolvedIndex)); + } + + return Result::success(); +} + +void Context::activateServer(int resolvedIndex, bool alsoSetDefault) +{ + m_serversModel->setProcessedServerIndex(resolvedIndex); + if (alsoSetDefault) { + m_serversModel->setDefaultServerIndex(resolvedIndex); + } + refreshActiveContainer(); +} + +void Context::refreshActiveContainer() +{ + if (m_activeServerIndex < 0 || m_activeServerIndex >= m_serversModel->getServersCount()) { + m_activeContainer = amnezia::DockerContainer::None; + return; + } + + const auto current = m_serversModel->data(m_activeServerIndex, ServersModel::Roles::DefaultContainerRole); + m_activeContainer = qvariant_cast(current); +} + +void Context::setLastError(amnezia::ErrorCode error) +{ + m_lastError = error; + emit statusChanged(); +} + +void Context::clearLastError() +{ + m_lastError = amnezia::ErrorCode::NoError; + emit statusChanged(); +} + +QJsonObject Context::serverSummary(int index) const +{ + QJsonObject server; + server["index"] = index; + server["name"] = m_serversModel->data(index, ServersModel::Roles::NameRole).toString(); + server["host"] = m_serversModel->data(index, ServersModel::Roles::HostNameRole).toString(); + server["description"] = m_serversModel->data(index, ServersModel::Roles::ServerDescriptionRole).toString(); + server["default"] = m_serversModel->data(index, ServersModel::Roles::IsDefaultRole).toBool(); + server["default_container"] = ContainerProps::containerToString( + qvariant_cast(m_serversModel->data(index, ServersModel::Roles::DefaultContainerRole))); + server["default_container_name"] = displayContainerName( + qvariant_cast(m_serversModel->data(index, ServersModel::Roles::DefaultContainerRole))); + server["has_write_access"] = m_serversModel->data(index, ServersModel::Roles::HasWriteAccessRole).toBool(); + server["has_installed_containers"] = m_serversModel->data(index, ServersModel::Roles::HasInstalledContainers).toBool(); + server["is_api"] = m_serversModel->data(index, ServersModel::Roles::IsServerFromTelegramApiRole).toBool() + || m_serversModel->data(index, ServersModel::Roles::IsServerFromGatewayApiRole).toBool(); + server["credentials_login"] = m_serversModel->data(index, ServersModel::Roles::CredentialsLoginRole).toString(); + return server; +} + +QJsonObject Context::containerSummary(const QJsonObject &containerConfig, const QString &defaultContainerName) const +{ + const QString containerName = containerConfig.value(config_key::container).toString(); + const auto container = ContainerProps::containerFromString(containerName); + + QJsonObject summary; + summary["container"] = containerName; + summary["type"] = ContainerProps::containerTypeToString(container); + summary["display_name"] = displayContainerName(container); + summary["default"] = containerName == defaultContainerName; + summary["supported"] = ContainerProps::isSupportedByCurrentPlatform(container); + summary["service_type"] = serviceTypeKey(ContainerProps::containerService(container)); + + QJsonArray protocols; + for (const auto proto : ContainerProps::protocolsForContainer(container)) { + protocols.append(ProtocolProps::protoToString(proto)); + } + summary["protocols"] = protocols; + + return summary; +} + +QJsonArray Context::containerSummaries(const QJsonObject &serverConfig) const +{ + const QString defaultContainerName = serverConfig.value(config_key::defaultContainer).toString(); + QJsonArray containers; + for (const auto &value : serverConfig.value(config_key::containers).toArray()) { + containers.append(containerSummary(value.toObject(), defaultContainerName)); + } + return containers; +} + +Result Context::listServers() +{ + reload(); + + QJsonArray servers; + for (int index = 0; index < m_serversModel->getServersCount(); ++index) { + servers.append(serverSummary(index)); + } + + QJsonObject data; + data["count"] = m_serversModel->getServersCount(); + data["servers"] = servers; + + return Result::success(m_serversModel->getServersCount() ? "Servers loaded" : "No servers configured", data); +} + +Result Context::showServer(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + QJsonObject data; + auto server = serverSummary(resolvedIndex); + server["containers"] = containerSummaries(m_serversModel->getServerConfig(resolvedIndex)); + data["server"] = server; + return Result::success("Server loaded", data); +} + +Result Context::addServer(const QString &name, const amnezia::ServerCredentials &credentials) +{ + reload(); + + if (!credentials.isValid()) { + return Result::failure("Host, user, secret, and a valid SSH port are required"); + } + + QJsonObject server; + server.insert(config_key::hostName, credentials.hostName); + server.insert(config_key::userName, credentials.userName); + server.insert(config_key::password, credentials.secretData); + server.insert(config_key::port, credentials.port); + server.insert(config_key::description, name.isEmpty() ? m_settings->nextAvailableServerName() : name); + server.insert(config_key::defaultContainer, ContainerProps::containerToString(amnezia::DockerContainer::None)); + + m_serversModel->addServer(server); + reload(); + + QJsonObject data; + data["server"] = serverSummary(m_serversModel->getServersCount() - 1); + return Result::success("Server added", data); +} + +Result Context::importConfigFromFile(const QString &fileName) +{ + reload(); + + Result result; + QList connections; + connections.append(QObject::connect(m_importController.get(), &ImportController::importFinished, this, [&result]() { + result = Result::success("Configuration imported"); + })); + connections.append(QObject::connect(m_importController.get(), &ImportController::importErrorOccurred, this, [&result](ErrorCode error, bool) { + result = Result::failure(errorString(error)); + })); + + if (!m_importController->extractConfigFromFile(fileName)) { + if (result.message.isEmpty()) { + result = Result::failure("Failed to read configuration file"); + } + disconnectTemporaryConnections(connections); + return result; + } + + m_importController->importConfig(); + reload(); + disconnectTemporaryConnections(connections); + + if (!result.ok) { + return result; + } + + QJsonObject data; + data["count"] = m_serversModel->getServersCount(); + return Result::success(result.message, data); +} + +Result Context::importConfigFromData(const QString &data) +{ + reload(); + + Result result; + QList connections; + connections.append(QObject::connect(m_importController.get(), &ImportController::importFinished, this, [&result]() { + result = Result::success("Configuration imported"); + })); + connections.append(QObject::connect(m_importController.get(), &ImportController::importErrorOccurred, this, [&result](ErrorCode error, bool) { + result = Result::failure(errorString(error)); + })); + + if (!m_importController->extractConfigFromData(data)) { + if (result.message.isEmpty()) { + result = Result::failure("Failed to parse configuration"); + } + disconnectTemporaryConnections(connections); + return result; + } + + m_importController->importConfig(); + reload(); + disconnectTemporaryConnections(connections); + + if (!result.ok) { + return result; + } + + QJsonObject payload; + payload["count"] = m_serversModel->getServersCount(); + return Result::success(result.message, payload); +} + +Result Context::removeServer(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + const QString name = m_serversModel->data(resolvedIndex, ServersModel::Roles::NameRole).toString(); + m_serversModel->removeServer(resolvedIndex); + reload(); + + return Result::success(QString("Server '%1' removed").arg(name)); +} + +Result Context::setDefaultServer(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + m_serversModel->setDefaultServerIndex(resolvedIndex); + m_serversModel->setProcessedServerIndex(resolvedIndex); + reload(); + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + return Result::success(QString("Default server set to #%1").arg(resolvedIndex), data); +} + +Result Context::scanServer(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, false); + + Result result = Result::failure("Scan failed"); + QList connections; + connections.append(QObject::connect(m_installController.get(), &InstallController::scanServerFinished, this, [&result](bool foundNewContainers) { + result = Result::success(foundNewContainers ? "Containers added from server" : "No new containers found"); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::installationErrorOccurred, this, [&result](ErrorCode error) { + result = Result::failure(errorString(error)); + })); + + m_installController->scanServerForInstalledContainers(); + reload(); + disconnectTemporaryConnections(connections); + + if (!result.ok) { + return result; + } + + QJsonObject data; + auto server = serverSummary(resolvedIndex); + server["containers"] = containerSummaries(m_serversModel->getServerConfig(resolvedIndex)); + data["server"] = server; + return Result::success(result.message, data); +} + +Result Context::listCountries(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, false); + + if (!m_serversModel->data(resolvedIndex, ServersModel::Roles::IsServerFromGatewayApiRole).toBool()) { + return Result::failure("Country selection is supported only for gateway API profiles"); + } + + clearLastError(); + const bool refreshed = m_apiSettingsController->getAccountInfo(true); + const ErrorCode refreshError = m_lastError; + + const auto serverConfig = m_serversModel->getServerConfig(resolvedIndex); + const auto fallbackCountries = availableCountriesArray(serverConfig); + const auto refreshedCountries = m_apiAccountInfoModel->getAvailableCountries(); + const auto countries = refreshed && !refreshedCountries.isEmpty() ? refreshedCountries : fallbackCountries; + + if (countries.isEmpty()) { + return Result::failure(refreshed ? "This server does not expose selectable countries" + : (refreshError == ErrorCode::NoError ? "Failed to load available countries" + : errorString(refreshError))); + } + + if (!refreshed) { + clearLastError(); + } + + const auto issuedByCountry = refreshed ? issuedConfigsByCountry(m_apiAccountInfoModel->getIssuedConfigsInfo()) : QHash {}; + const QString currentCountryCode = selectedCountryCode(serverConfig); + + QJsonArray countryItems; + for (const auto &value : countries) { + countryItems.append(countrySummary(value.toObject(), currentCountryCode, issuedByCountry)); + } + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + data["countries"] = countryItems; + data["count"] = countryItems.size(); + data["selected_country_code"] = currentCountryCode; + data["selected_country_name"] = selectedCountryName(serverConfig); + data["refreshed"] = refreshed; + if (!refreshed && refreshError != ErrorCode::NoError) { + data["refresh_error"] = static_cast(refreshError); + data["refresh_error_text"] = errorString(refreshError); + } + + return Result::success(refreshed ? "Countries loaded" : "Countries loaded from cached server config", data); +} + +Result Context::setCountry(int requestedIndex, const QString &countryCode) +{ + reload(); + + const QString requestedCountryCode = countryCode.trimmed(); + if (requestedCountryCode.isEmpty()) { + return Result::failure("Country code is required"); + } + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, false); + + if (!m_serversModel->data(resolvedIndex, ServersModel::Roles::IsServerFromGatewayApiRole).toBool()) { + return Result::failure("Country selection is supported only for gateway API profiles"); + } + + clearLastError(); + const bool refreshed = m_apiSettingsController->getAccountInfo(true); + const ErrorCode refreshError = m_lastError; + + auto serverConfig = m_serversModel->getServerConfig(resolvedIndex); + const auto countries = refreshed && !m_apiAccountInfoModel->getAvailableCountries().isEmpty() + ? m_apiAccountInfoModel->getAvailableCountries() + : availableCountriesArray(serverConfig); + + if (countries.isEmpty()) { + return Result::failure(refreshed ? "This server does not expose selectable countries" + : (refreshError == ErrorCode::NoError ? "Failed to load available countries" + : errorString(refreshError))); + } + + if (!refreshed) { + clearLastError(); + } + + QString matchedCountryCode; + QString matchedCountryName; + for (const auto &value : countries) { + const auto countryObject = value.toObject(); + const QString currentCode = countryObject.value(apiDefs::key::serverCountryCode).toString(); + if (currentCode.compare(requestedCountryCode, Qt::CaseInsensitive) == 0) { + matchedCountryCode = currentCode; + matchedCountryName = countryObject.value(apiDefs::key::serverCountryName).toString(); + break; + } + } + + if (matchedCountryCode.isEmpty()) { + return Result::failure(QString("Country '%1' is not available for this server").arg(requestedCountryCode)); + } + + if (selectedCountryCode(serverConfig).compare(matchedCountryCode, Qt::CaseInsensitive) == 0) { + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + data["selected_country_code"] = matchedCountryCode; + data["selected_country_name"] = matchedCountryName; + return Result::success(QString("Country is already set to %1").arg(matchedCountryName), data); + } + + Result result = Result::failure(QString("Failed to change country to %1").arg(matchedCountryName)); + QList connections; + connections.append(QObject::connect(m_apiConfigsController.get(), &ApiConfigsController::changeApiCountryFinished, this, + [&result](const QString &message) { result = Result::success(message); })); + connections.append(QObject::connect(m_apiConfigsController.get(), &ApiConfigsController::errorOccurred, this, + [&result](ErrorCode error) { result = Result::failure(errorString(error)); })); + + if (!m_apiConfigsController->updateServiceFromGateway(resolvedIndex, matchedCountryCode, matchedCountryName)) { + if (result.message.isEmpty()) { + result = Result::failure(m_lastError == ErrorCode::NoError ? "Failed to change country" : errorString(m_lastError)); + } + disconnectTemporaryConnections(connections); + return result; + } + + disconnectTemporaryConnections(connections); + reload(); + serverConfig = m_serversModel->getServerConfig(resolvedIndex); + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + data["selected_country_code"] = selectedCountryCode(serverConfig); + data["selected_country_name"] = selectedCountryName(serverConfig); + return Result::success(result.ok ? result.message : QString("Country changed to %1").arg(matchedCountryName), data); +} + +Result Context::listContainers(int requestedIndex) +{ + reload(); + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + data["containers"] = containerSummaries(m_serversModel->getServerConfig(resolvedIndex)); + data["count"] = data["containers"].toArray().size(); + return Result::success("Containers loaded", data); +} + +Result Context::setDefaultContainer(int requestedIndex, amnezia::DockerContainer container) +{ + reload(); + + if (container == amnezia::DockerContainer::None) { + return Result::failure("Container name is required"); + } + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + m_serversModel->setDefaultContainer(resolvedIndex, container); + reload(); + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + return Result::success(QString("Default container set to %1").arg(displayContainerName(container)), data); +} + +Result Context::removeContainer(int requestedIndex, amnezia::DockerContainer container) +{ + reload(); + + if (container == amnezia::DockerContainer::None) { + return Result::failure("Container name is required"); + } + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, false); + m_containersModel->setProcessedContainerIndex(static_cast(container)); + + Result result = Result::failure("Failed to remove container"); + QList connections; + connections.append(QObject::connect(m_installController.get(), &InstallController::removeProcessedContainerFinished, this, + [&result](const QString &message) { + result = Result::success(message); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::installationErrorOccurred, this, + [&result](ErrorCode error) { + result = Result::failure(errorString(error)); + })); + + m_installController->removeProcessedContainer(); + reload(); + disconnectTemporaryConnections(connections); + return result; +} + +Result Context::installServer(const QString &name, const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, + int containerPort, amnezia::TransportProto transport, const QString &keyPassphrase) +{ + reload(); + + if (!credentials.isValid()) { + return Result::failure("Host, user, secret, and a valid SSH port are required"); + } + if (container == amnezia::DockerContainer::None) { + return Result::failure("Container name is required"); + } + + m_installController->setShouldCreateServer(true); + m_installController->setProcessedServerCredentials(hostWithOptionalPort(credentials), credentials.userName, credentials.secretData); + + Result result = Result::failure("Failed to install server"); + QList connections; + connections.append(QObject::connect(m_installController.get(), &InstallController::installServerFinished, this, [&result](const QString &message) { + result = Result::success(message); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::serverAlreadyExists, this, [&result](int index) { + result = Result::failure(QString("Server already exists at index %1").arg(index)); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::wrongInstallationUser, this, + [&result](const QString &message) { + result = Result::failure(message); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::installationErrorOccurred, this, + [&result](ErrorCode error) { + result = Result::failure(errorString(error)); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::passphraseRequestStarted, this, + [this, keyPassphrase]() { m_installController->setEncryptedPassphrase(keyPassphrase); })); + + m_installController->install(container, containerPort, transport); + reload(); + disconnectTemporaryConnections(connections); + + if (!result.ok) { + return result; + } + + const int newIndex = m_serversModel->getServersCount() - 1; + if (!name.isEmpty() && newIndex >= 0) { + auto server = m_serversModel->getServerConfig(newIndex); + server[config_key::description] = name; + m_serversModel->editServer(server, newIndex); + reload(); + } + + QJsonObject data; + data["server"] = serverSummary(m_serversModel->getServersCount() - 1); + return Result::success(result.message, data); +} + +Result Context::installContainer(int requestedIndex, amnezia::DockerContainer container, int containerPort, + amnezia::TransportProto transport, const QString &keyPassphrase) +{ + reload(); + + if (container == amnezia::DockerContainer::None) { + return Result::failure("Container name is required"); + } + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, false); + m_installController->setShouldCreateServer(false); + + Result result = Result::failure("Failed to install container"); + QList connections; + connections.append(QObject::connect(m_installController.get(), &InstallController::installContainerFinished, + this, [&result](const QString &message, bool) { result = Result::success(message); })); + connections.append(QObject::connect(m_installController.get(), &InstallController::installationErrorOccurred, this, + [&result](ErrorCode error) { + result = Result::failure(errorString(error)); + })); + connections.append(QObject::connect(m_installController.get(), &InstallController::passphraseRequestStarted, this, + [this, keyPassphrase]() { m_installController->setEncryptedPassphrase(keyPassphrase); })); + + m_installController->install(container, containerPort, transport); + reload(); + disconnectTemporaryConnections(connections); + + if (!result.ok) { + return result; + } + + QJsonObject data; + auto server = serverSummary(resolvedIndex); + server["containers"] = containerSummaries(m_serversModel->getServerConfig(resolvedIndex)); + data["server"] = server; + return Result::success(result.message, data); +} + +Result Context::startConnection(int requestedIndex) +{ + reload(); + + if (m_state == Vpn::ConnectionState::Connected) { + return Result::failure("A VPN connection is already active"); + } + if (m_state == Vpn::ConnectionState::Preparing || m_state == Vpn::ConnectionState::Connecting + || m_state == Vpn::ConnectionState::Disconnecting || m_state == Vpn::ConnectionState::Reconnecting) { + return Result::failure("A VPN operation is already in progress"); + } + + int resolvedIndex = -1; + Result resolved = resolveServerIndexResult(requestedIndex, resolvedIndex); + if (!resolved.ok) { + return resolved; + } + + activateServer(resolvedIndex, true); + m_activeServerIndex = resolvedIndex; + refreshActiveContainer(); + clearLastError(); + m_totalReceivedBytes = 0; + m_totalSentBytes = 0; + + m_connectionController->toggleConnection(); + + QJsonObject data; + data["server"] = serverSummary(resolvedIndex); + data["status"] = status(); + return Result::success("Connection requested", data); +} + +Result Context::stopConnection() +{ + if (m_state == Vpn::ConnectionState::Disconnected || m_state == Vpn::ConnectionState::Unknown) { + return Result::success("Already disconnected", status()); + } + + clearLastError(); + m_connectionController->closeConnection(); + return Result::success("Disconnect requested", status()); +} + +Result Context::cleanupLogs() +{ + Logger::cleanUp(); + return Result::success("Logs cleaned"); +} + +QJsonObject Context::status() const +{ + QJsonObject json; + json["state"] = connectionStateKey(m_state); + json["state_text"] = VpnProtocol::textConnectionState(m_state); + json["state_code"] = static_cast(m_state); + json["connected"] = m_state == Vpn::ConnectionState::Connected; + json["in_progress"] = m_state == Vpn::ConnectionState::Preparing || m_state == Vpn::ConnectionState::Connecting + || m_state == Vpn::ConnectionState::Disconnecting || m_state == Vpn::ConnectionState::Reconnecting; + json["last_error"] = static_cast(m_lastError); + json["last_error_text"] = errorString(m_lastError); + json["remote_address"] = m_vpnConnection ? m_vpnConnection->remoteAddress() : QString(); + json["received_bytes_total"] = QString::number(m_totalReceivedBytes); + json["sent_bytes_total"] = QString::number(m_totalSentBytes); + json["active_server_index"] = m_activeServerIndex; + json["active_container"] = ContainerProps::containerToString(m_activeContainer); + json["active_container_name"] = displayContainerName(m_activeContainer); + + if (m_activeServerIndex >= 0 && m_activeServerIndex < m_serversModel->getServersCount()) { + json["active_server"] = serverSummary(m_activeServerIndex); + } + + return json; +} + +} // namespace cli diff --git a/client/cli/cli_context.h b/client/cli/cli_context.h new file mode 100644 index 0000000000..0372da6c54 --- /dev/null +++ b/client/cli/cli_context.h @@ -0,0 +1,125 @@ +#ifndef CLI_CONTEXT_H +#define CLI_CONTEXT_H + +#include +#include +#include +#include +#include + +#include "cli_common.h" +#include "containers/containers_defs.h" +#include "core/defs.h" +#include "protocols/vpnprotocol.h" +#include "settings.h" + +class ApiConfigsController; +class ApiAccountInfoModel; +class ApiBenefitsModel; +class ApiCountryModel; +class ApiDevicesModel; +class ApiSettingsController; +class ApiServicesModel; +class ApiSubscriptionPlansModel; +class ClientManagementModel; +class ConnectionController; +class ContainersModel; +class ImportController; +class InstallController; +class ProtocolsModel; +class ServersModel; +class VpnConnection; + +namespace cli +{ + +QString connectionStateKey(Vpn::ConnectionState state); +QString displayContainerName(amnezia::DockerContainer container); +amnezia::DockerContainer containerFromCliName(const QString &name); +QStringList availableContainerNames(); + +class Context : public QObject +{ + Q_OBJECT + +public: + explicit Context(QObject *parent = nullptr); + ~Context() override; + + Result reload(); + + Result listServers(); + Result showServer(int requestedIndex); + Result addServer(const QString &name, const amnezia::ServerCredentials &credentials); + Result importConfigFromFile(const QString &fileName); + Result importConfigFromData(const QString &data); + Result removeServer(int requestedIndex); + Result setDefaultServer(int requestedIndex); + Result scanServer(int requestedIndex); + Result listCountries(int requestedIndex); + Result setCountry(int requestedIndex, const QString &countryCode); + + Result listContainers(int requestedIndex); + Result setDefaultContainer(int requestedIndex, amnezia::DockerContainer container); + Result removeContainer(int requestedIndex, amnezia::DockerContainer container); + + Result installServer(const QString &name, const amnezia::ServerCredentials &credentials, amnezia::DockerContainer container, + int containerPort, amnezia::TransportProto transport, const QString &keyPassphrase = {}); + Result installContainer(int requestedIndex, amnezia::DockerContainer container, int containerPort, amnezia::TransportProto transport, + const QString &keyPassphrase = {}); + + Result startConnection(int requestedIndex); + Result stopConnection(); + Result cleanupLogs(); + + QJsonObject status() const; + +signals: + void statusChanged(); + +private: + void registerMetaTypes(); + bool hasServers() const; + Result resolveServerIndexResult(int requestedIndex, int &resolvedIndex) const; + void activateServer(int resolvedIndex, bool alsoSetDefault); + void refreshActiveContainer(); + + void setLastError(amnezia::ErrorCode error); + void clearLastError(); + + QJsonObject serverSummary(int index) const; + QJsonObject containerSummary(const QJsonObject &containerConfig, const QString &defaultContainerName) const; + QJsonArray containerSummaries(const QJsonObject &serverConfig) const; + + std::shared_ptr m_settings; + QSharedPointer m_serversModel; + QSharedPointer m_containersModel; + QSharedPointer m_protocolsModel; + QSharedPointer m_clientManagementModel; + QSharedPointer m_apiAccountInfoModel; + QSharedPointer m_apiCountryModel; + QSharedPointer m_apiDevicesModel; + QSharedPointer m_apiServicesModel; + QSharedPointer m_apiSubscriptionPlansModel; + QSharedPointer m_apiBenefitsModel; + + QScopedPointer m_connectionController; + QScopedPointer m_installController; + QScopedPointer m_importController; + QScopedPointer m_apiConfigsController; + QScopedPointer m_apiSettingsController; + + QSharedPointer m_vpnConnection; + QThread m_vpnConnectionThread; + + Vpn::ConnectionState m_state = Vpn::ConnectionState::Disconnected; + amnezia::ErrorCode m_lastError = amnezia::ErrorCode::NoError; + int m_activeServerIndex = -1; + amnezia::DockerContainer m_activeContainer = amnezia::DockerContainer::None; + quint64 m_totalReceivedBytes = 0; + quint64 m_totalSentBytes = 0; +}; + +} // namespace cli + +#endif // CLI_CONTEXT_H diff --git a/client/cli/cli_ipc.cpp b/client/cli/cli_ipc.cpp new file mode 100644 index 0000000000..fbb6a538ae --- /dev/null +++ b/client/cli/cli_ipc.cpp @@ -0,0 +1,239 @@ +#include "cli_ipc.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cli_context.h" + +namespace cli +{ + +namespace ipc +{ + +QString socketName() +{ + return QStringLiteral("AmneziaVPN-cli-daemon"); +} + +bool isDaemonRunning(int timeoutMs) +{ + QLocalSocket socket; + socket.connectToServer(socketName()); + return socket.waitForConnected(timeoutMs); +} + +bool ensureDaemonRunning(const QString &programPath, int startupTimeoutMs) +{ + if (isDaemonRunning()) { + return true; + } + + QProcess daemonProcess; + daemonProcess.setProgram(programPath); + daemonProcess.setArguments({ QStringLiteral("__daemon") }); + daemonProcess.setStandardOutputFile(QProcess::nullDevice()); + daemonProcess.setStandardErrorFile(QProcess::nullDevice()); + + if (!daemonProcess.startDetached()) { + return false; + } + + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < startupTimeoutMs) { + if (isDaemonRunning()) { + return true; + } + QThread::msleep(100); + } + + return false; +} + +Result sendRequest(const QJsonObject &request, int timeoutMs) +{ + QLocalSocket socket; + socket.connectToServer(socketName()); + if (!socket.waitForConnected(timeoutMs)) { + return Result::failure("CLI daemon is not running"); + } + + const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n'; + if (socket.write(payload) != payload.size()) { + return Result::failure("Failed to send request to CLI daemon"); + } + if (!socket.waitForBytesWritten(timeoutMs)) { + return Result::failure("Timed out while sending request to CLI daemon"); + } + if (!socket.waitForReadyRead(timeoutMs)) { + return Result::failure("Timed out while waiting for CLI daemon response"); + } + + QByteArray response; + while (socket.canReadLine() || socket.waitForReadyRead(100)) { + response.append(socket.readLine()); + if (response.endsWith('\n')) { + break; + } + } + + const QJsonDocument json = QJsonDocument::fromJson(response.trimmed()); + if (!json.isObject()) { + return Result::failure("CLI daemon returned an invalid response"); + } + + return resultFromJson(json.object()); +} + +} // namespace ipc + +IpcServer::IpcServer(Context *context, QObject *parent) + : QObject(parent), m_context(context), m_server(new QLocalServer(this)) +{ + QLocalServer::removeServer(ipc::socketName()); + m_server->listen(ipc::socketName()); + + connect(m_server, &QLocalServer::newConnection, this, [this]() { + while (m_server->hasPendingConnections()) { + handleConnection(m_server->nextPendingConnection()); + } + }); +} + +bool IpcServer::isListening() const +{ + return m_server->isListening(); +} + +QString IpcServer::errorString() const +{ + return m_server->errorString(); +} + +void IpcServer::handleConnection(QLocalSocket *socket) +{ + connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { + while (socket->canReadLine()) { + const QByteArray requestBytes = socket->readLine().trimmed(); + if (requestBytes.isEmpty()) { + continue; + } + + const QJsonDocument requestJson = QJsonDocument::fromJson(requestBytes); + Result result = requestJson.isObject() ? dispatch(requestJson.object()) : Result::failure("Invalid CLI request"); + + socket->write(QJsonDocument(resultToJson(result)).toJson(QJsonDocument::Compact)); + socket->write("\n"); + socket->flush(); + socket->disconnectFromServer(); + } + }); + + connect(socket, &QLocalSocket::disconnected, socket, &QObject::deleteLater); +} + +Result IpcServer::dispatch(const QJsonObject &request) +{ + const QString command = request.value("command").toString(); + + if (command != "status" && command != "disconnect" && command != "daemon.shutdown") { + m_context->reload(); + } + + if (command == "ping") { + return Result::success("CLI daemon is running"); + } + if (command == "status") { + return Result::success("Status loaded", m_context->status()); + } + if (command == "connect") { + return m_context->startConnection(request.value("index").toInt(-1)); + } + if (command == "disconnect") { + return m_context->stopConnection(); + } + if (command == "daemon.shutdown") { + QTimer::singleShot(0, QCoreApplication::instance(), &QCoreApplication::quit); + return Result::success("CLI daemon is stopping"); + } + if (command == "servers.list") { + return m_context->listServers(); + } + if (command == "servers.show") { + return m_context->showServer(request.value("index").toInt(-1)); + } + if (command == "servers.add") { + amnezia::ServerCredentials credentials; + credentials.hostName = request.value("host").toString(); + credentials.userName = request.value("user").toString(); + credentials.secretData = request.value("secret").toString(); + credentials.port = request.value("port").toInt(22); + return m_context->addServer(request.value("name").toString(), credentials); + } + if (command == "servers.import_file") { + return m_context->importConfigFromFile(request.value("file").toString()); + } + if (command == "servers.import_data") { + return m_context->importConfigFromData(request.value("data").toString()); + } + if (command == "servers.remove") { + return m_context->removeServer(request.value("index").toInt(-1)); + } + if (command == "servers.set_default") { + return m_context->setDefaultServer(request.value("index").toInt(-1)); + } + if (command == "servers.scan") { + return m_context->scanServer(request.value("index").toInt(-1)); + } + if (command == "countries.list") { + return m_context->listCountries(request.value("index").toInt(-1)); + } + if (command == "countries.set") { + return m_context->setCountry(request.value("index").toInt(-1), request.value("country").toString()); + } + if (command == "containers.list") { + return m_context->listContainers(request.value("index").toInt(-1)); + } + if (command == "containers.set_default") { + return m_context->setDefaultContainer(request.value("index").toInt(-1), + containerFromCliName(request.value("container").toString())); + } + if (command == "containers.remove") { + return m_context->removeContainer(request.value("index").toInt(-1), + containerFromCliName(request.value("container").toString())); + } + if (command == "install.server") { + amnezia::ServerCredentials credentials; + credentials.hostName = request.value("host").toString(); + credentials.userName = request.value("user").toString(); + credentials.secretData = request.value("secret").toString(); + credentials.port = request.value("ssh_port").toInt(22); + + return m_context->installServer(request.value("name").toString(), credentials, + containerFromCliName(request.value("container").toString()), + request.value("container_port").toInt(-1), + ProtocolProps::transportProtoFromString(request.value("transport").toString()), + request.value("key_passphrase").toString()); + } + if (command == "install.container") { + return m_context->installContainer(request.value("index").toInt(-1), + containerFromCliName(request.value("container").toString()), + request.value("container_port").toInt(-1), + ProtocolProps::transportProtoFromString(request.value("transport").toString()), + request.value("key_passphrase").toString()); + } + if (command == "logs.cleanup") { + return m_context->cleanupLogs(); + } + + return Result::failure(QString("Unknown command '%1'").arg(command)); +} + +} // namespace cli diff --git a/client/cli/cli_ipc.h b/client/cli/cli_ipc.h new file mode 100644 index 0000000000..9229a8639c --- /dev/null +++ b/client/cli/cli_ipc.h @@ -0,0 +1,46 @@ +#ifndef CLI_IPC_H +#define CLI_IPC_H + +#include + +#include "cli_common.h" + +class QLocalServer; +class QLocalSocket; + +namespace cli +{ + +class Context; + +class IpcServer : public QObject +{ + Q_OBJECT + +public: + explicit IpcServer(Context *context, QObject *parent = nullptr); + + bool isListening() const; + QString errorString() const; + +private: + Result dispatch(const QJsonObject &request); + void handleConnection(QLocalSocket *socket); + + Context *m_context = nullptr; + QLocalServer *m_server = nullptr; +}; + +namespace ipc +{ + +QString socketName(); +bool isDaemonRunning(int timeoutMs = 250); +bool ensureDaemonRunning(const QString &programPath, int startupTimeoutMs = 5000); +Result sendRequest(const QJsonObject &request, int timeoutMs = 15000); + +} // namespace ipc + +} // namespace cli + +#endif // CLI_IPC_H diff --git a/client/cli/main.cpp b/client/cli/main.cpp new file mode 100644 index 0000000000..a23fa6d9aa --- /dev/null +++ b/client/cli/main.cpp @@ -0,0 +1,696 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "cli_context.h" +#include "cli_ipc.h" +#include "core/errorstrings.h" +#include "logger.h" +#include "migrations.h" +#include "utilities.h" +#include "version.h" + +namespace +{ + +using namespace amnezia; + +QString takeOption(QStringList &args, const QString &name, const QString &shortName = {}) +{ + const QStringList names = shortName.isEmpty() ? QStringList { name } : QStringList { name, shortName }; + + for (int i = 0; i < args.size(); ++i) { + const QString argument = args.at(i); + for (const auto &candidate : names) { + const QString prefix = candidate + "="; + if (argument.startsWith(prefix)) { + const QString value = argument.mid(prefix.size()); + args.removeAt(i); + return value; + } + } + + if (!names.contains(argument)) { + continue; + } + if (i + 1 >= args.size()) { + return {}; + } + + const QString value = args.at(i + 1); + args.removeAt(i + 1); + args.removeAt(i); + return value; + } + + return {}; +} + +bool takeFlag(QStringList &args, const QString &name, const QString &shortName = {}) +{ + const QStringList names = shortName.isEmpty() ? QStringList { name } : QStringList { name, shortName }; + for (int i = 0; i < args.size(); ++i) { + if (!names.contains(args.at(i))) { + continue; + } + args.removeAt(i); + return true; + } + return false; +} + +QString readSecretFromFile(const QString &fileName) +{ + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(file.readAll()); +} + +int parsePort(const QString &value, int fallback) +{ + bool ok = false; + const int parsed = value.toInt(&ok); + return ok ? parsed : fallback; +} + +TransportProto parseTransport(const QString &value, Proto protocol) +{ + if (!value.isEmpty()) { + return ProtocolProps::transportProtoFromString(value); + } + return ProtocolProps::defaultTransportProto(protocol); +} + +QString statusSummary(const QJsonObject &status) +{ + QString text = QString("State: %1").arg(status.value("state_text").toString()); + const auto activeServer = status.value("active_server").toObject(); + if (!activeServer.isEmpty()) { + text += QString(" | Server: #%1 %2").arg(activeServer.value("index").toInt()).arg(activeServer.value("name").toString()); + } + const QString remoteAddress = status.value("remote_address").toString(); + if (!remoteAddress.isEmpty()) { + text += QString(" | Remote: %1").arg(remoteAddress); + } + return text; +} + +void printServers(const QJsonObject &data) +{ + QTextStream out(stdout); + const auto servers = data.value("servers").toArray(); + if (servers.isEmpty()) { + out << "No servers configured.\n"; + return; + } + + for (const auto &value : servers) { + const auto server = value.toObject(); + out << "#" << server.value("index").toInt(); + if (server.value("default").toBool()) { + out << " *"; + } + out << " " << server.value("name").toString() + << " | " << server.value("host").toString() + << " | " << server.value("default_container_name").toString() + << " | " << (server.value("has_installed_containers").toBool() ? "containers" : "no-containers") + << "\n"; + } +} + +void printServer(const QJsonObject &data) +{ + QTextStream out(stdout); + const auto server = data.value("server").toObject(); + if (server.isEmpty()) { + out << "Server not found.\n"; + return; + } + + out << "Index: " << server.value("index").toInt() << "\n"; + out << "Name: " << server.value("name").toString() << "\n"; + out << "Host: " << server.value("host").toString() << "\n"; + out << "Default: " << (server.value("default").toBool() ? "yes" : "no") << "\n"; + out << "Default Container: " << server.value("default_container_name").toString() << "\n"; + out << "Write Access: " << (server.value("has_write_access").toBool() ? "yes" : "no") << "\n"; + out << "Containers:\n"; + + for (const auto &value : server.value("containers").toArray()) { + const auto container = value.toObject(); + out << " - " << container.value("display_name").toString() + << " (" << container.value("type").toString() << ")"; + if (container.value("default").toBool()) { + out << " [default]"; + } + out << "\n"; + } +} + +void printContainers(const QJsonObject &data) +{ + QTextStream out(stdout); + const auto containers = data.value("containers").toArray(); + if (containers.isEmpty()) { + out << "No containers installed for this server.\n"; + return; + } + + for (const auto &value : containers) { + const auto container = value.toObject(); + out << container.value("display_name").toString() + << " (" << container.value("type").toString() << ")"; + if (container.value("default").toBool()) { + out << " [default]"; + } + out << " | " << container.value("service_type").toString(); + out << " | " << (container.value("supported").toBool() ? "supported" : "unsupported"); + out << "\n"; + } +} + +void printCountries(const QJsonObject &data) +{ + QTextStream out(stdout); + const QString selectedCountryName = data.value("selected_country_name").toString(); + const QString selectedCountryCode = data.value("selected_country_code").toString(); + if (!selectedCountryName.isEmpty() || !selectedCountryCode.isEmpty()) { + out << "Current: " << selectedCountryName; + if (!selectedCountryCode.isEmpty()) { + out << " (" << selectedCountryCode << ")"; + } + out << "\n"; + } + + const auto countries = data.value("countries").toArray(); + if (countries.isEmpty()) { + out << "No selectable countries available.\n"; + return; + } + + for (const auto &value : countries) { + const auto country = value.toObject(); + out << (country.value("selected").toBool() ? "* " : " ") + << country.value("name").toString() + << " (" << country.value("code").toString() << ")"; + if (country.value("issued").toBool()) { + out << " [issued]"; + } + if (country.value("worker_expired").toBool()) { + out << " [outdated]"; + } + out << "\n"; + } +} + +void printSelectedCountry(const QJsonObject &data) +{ + QTextStream out(stdout); + const auto server = data.value("server").toObject(); + if (!server.isEmpty()) { + out << "Server: #" << server.value("index").toInt() << " " << server.value("name").toString() << "\n"; + } + + const QString selectedCountryName = data.value("selected_country_name").toString(); + const QString selectedCountryCode = data.value("selected_country_code").toString(); + if (!selectedCountryName.isEmpty() || !selectedCountryCode.isEmpty()) { + out << "Current: " << selectedCountryName; + if (!selectedCountryCode.isEmpty()) { + out << " (" << selectedCountryCode << ")"; + } + out << "\n"; + } +} + +void printStatus(const QJsonObject &status) +{ + QTextStream out(stdout); + out << statusSummary(status) << "\n"; + const int errorCode = status.value("last_error").toInt(0); + if (errorCode != 0) { + out << status.value("last_error_text").toString() << "\n"; + } +} + +void printHelp() +{ + QTextStream out(stdout); + out << +R"(amnezia-cli + +Usage: + amnezia-cli status + amnezia-cli connect [--index N] [--wait SECONDS] + amnezia-cli disconnect [--wait SECONDS] + amnezia-cli daemon start|stop|status + amnezia-cli servers list + amnezia-cli servers show [--index N] + amnezia-cli servers add --host HOST --user USER (--password SECRET | --key-file FILE) [--port SSH_PORT] [--name NAME] + amnezia-cli servers import (--file FILE | --data TEXT) + amnezia-cli servers remove --index N + amnezia-cli servers set-default --index N + amnezia-cli servers scan [--index N] + amnezia-cli countries list [--index N] + amnezia-cli countries set --index N --country CODE + amnezia-cli containers list [--index N] + amnezia-cli containers set-default --index N --container NAME + amnezia-cli containers remove --index N --container NAME + amnezia-cli install server --host HOST --user USER (--password SECRET | --key-file FILE) + --container NAME [--port SSH_PORT] [--container-port PORT] + [--transport tcp|udp|tcpandudp] [--name NAME] [--key-passphrase PASS] + amnezia-cli install container --index N --container NAME + [--container-port PORT] [--transport tcp|udp|tcpandudp] + [--key-passphrase PASS] + amnezia-cli logs cleanup + +Global: + --json Print raw JSON response + +Container names: + openvpn, cloak, shadowsocks, wireguard, awg, awg2, xray, ssxray, ikev2, dns, torwebsite, sftp, socks5proxy +)"; +} + +int finish(const cli::Result &result, bool jsonOutput, void (*printer)(const QJsonObject &) = nullptr) +{ + QTextStream out(stdout); + QTextStream err(stderr); + + if (jsonOutput) { + out << QJsonDocument(cli::resultToJson(result)).toJson(QJsonDocument::Indented); + return result.exitCode; + } + + if (!result.message.isEmpty()) { + (result.ok ? out : err) << result.message << "\n"; + } + + if (result.ok && printer) { + printer(result.data); + } + + return result.exitCode; +} + +cli::Result waitForState(const QString &desiredState, int timeoutSeconds) +{ + const qint64 deadlineMs = timeoutSeconds > 0 ? qint64(timeoutSeconds) * 1000 : 0; + QElapsedTimer timer; + timer.start(); + + while (true) { + const auto status = cli::ipc::sendRequest(QJsonObject { { "command", "status" } }); + if (!status.ok) { + return status; + } + + const auto state = status.data.value("state").toString(); + const int lastError = status.data.value("last_error").toInt(); + + if (state == desiredState) { + return cli::Result::success(statusSummary(status.data), status.data); + } + if ((state == "error") || (desiredState == "connected" && state == "disconnected" && lastError != 0)) { + return cli::Result::failure(status.data.value("last_error_text").toString(), 1, status.data); + } + if (deadlineMs > 0 && timer.elapsed() >= deadlineMs) { + return cli::Result::failure("Timed out waiting for requested VPN state", 1, status.data); + } + + QThread::msleep(300); + } +} + +} // namespace + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + app.setApplicationName(QStringLiteral("amnezia-cli")); + app.setApplicationVersion(QStringLiteral(APP_VERSION)); + app.setOrganizationName(QStringLiteral(ORGANIZATION_NAME)); + + Utils::initializePath(Logger::systemLogDir()); + + Migrations migrations; + migrations.doMigrations(); + + QStringList args = app.arguments().mid(1); + const bool jsonOutput = takeFlag(args, "--json"); + + if (!args.isEmpty() && args.first() == "__daemon") { + cli::Context context; + cli::IpcServer server(&context); + if (!server.isListening()) { + QTextStream(stderr) << "Failed to start CLI daemon: " << server.errorString() << "\n"; + return 1; + } + return app.exec(); + } + + if (args.isEmpty() || takeFlag(args, "--help", "-h")) { + printHelp(); + return 0; + } + + const bool daemonRunning = cli::ipc::isDaemonRunning(); + const auto runDirect = [&](auto handler) { + cli::Context context; + return handler(context); + }; + const auto runViaDaemon = [&](const QJsonObject &request) { + return cli::ipc::sendRequest(request); + }; + + const QString command = args.takeFirst(); + + if (command == "daemon") { + if (args.isEmpty()) { + return finish(cli::Result::failure("daemon subcommand required"), jsonOutput); + } + + const QString action = args.takeFirst(); + if (action == "start") { + const bool started = cli::ipc::ensureDaemonRunning(QCoreApplication::applicationFilePath()); + return finish(started ? cli::Result::success("CLI daemon is running") : cli::Result::failure("Failed to start CLI daemon"), + jsonOutput); + } + if (action == "stop") { + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "daemon.shutdown" } }) + : cli::Result::success("CLI daemon is not running"), + jsonOutput); + } + if (action == "status") { + return finish(cli::Result::success(daemonRunning ? "CLI daemon is running" : "CLI daemon is not running"), jsonOutput); + } + + return finish(cli::Result::failure(QString("Unknown daemon action '%1'").arg(action)), jsonOutput); + } + + if (command == "status") { + if (!daemonRunning) { + return finish(cli::Result::success("CLI daemon is not running", + QJsonObject { + { "state", "disconnected" }, + { "state_text", "Disconnected" }, + { "last_error", 0 }, + { "last_error_text", errorString(ErrorCode::NoError) }, + }), + jsonOutput, printStatus); + } + + return finish(runViaDaemon(QJsonObject { { "command", "status" } }), jsonOutput, printStatus); + } + + if (command == "connect") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + const int timeout = parsePort(takeOption(args, "--wait"), 60); + + if (!cli::ipc::ensureDaemonRunning(QCoreApplication::applicationFilePath())) { + return finish(cli::Result::failure("Failed to start CLI daemon"), jsonOutput); + } + + auto result = runViaDaemon(QJsonObject { + { "command", "connect" }, + { "index", index }, + }); + if (!result.ok || timeout == 0) { + return finish(result, jsonOutput, printStatus); + } + + return finish(waitForState("connected", timeout), jsonOutput, printStatus); + } + + if (command == "disconnect") { + const int timeout = parsePort(takeOption(args, "--wait"), 30); + if (!daemonRunning) { + return finish(cli::Result::success("Already disconnected"), jsonOutput); + } + + auto result = runViaDaemon(QJsonObject { { "command", "disconnect" } }); + if (!result.ok || timeout == 0) { + return finish(result, jsonOutput, printStatus); + } + + return finish(waitForState("disconnected", timeout), jsonOutput, printStatus); + } + + if (command == "servers") { + if (args.isEmpty()) { + return finish(cli::Result::failure("servers subcommand required"), jsonOutput); + } + + const QString action = args.takeFirst(); + if (action == "list") { + auto handler = [](cli::Context &context) { return context.listServers(); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.list" } }) : runDirect(handler), jsonOutput, + printServers); + } + if (action == "show") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.showServer(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.show" }, { "index", index } }) + : runDirect(handler), + jsonOutput, printServer); + } + if (action == "add") { + ServerCredentials credentials; + credentials.hostName = takeOption(args, "--host"); + credentials.userName = takeOption(args, "--user"); + credentials.port = parsePort(takeOption(args, "--port"), 22); + credentials.secretData = takeOption(args, "--password"); + if (credentials.secretData.isEmpty()) { + credentials.secretData = takeOption(args, "--secret"); + } + + const QString keyFile = takeOption(args, "--key-file"); + if (credentials.secretData.isEmpty() && !keyFile.isEmpty()) { + credentials.secretData = readSecretFromFile(keyFile); + } + + const QString name = takeOption(args, "--name"); + auto handler = [name, credentials](cli::Context &context) { return context.addServer(name, credentials); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "servers.add" }, + { "host", credentials.hostName }, + { "user", credentials.userName }, + { "secret", credentials.secretData }, + { "port", credentials.port }, + { "name", name }, + }) + : runDirect(handler), + jsonOutput, printServer); + } + if (action == "import") { + const QString fileName = takeOption(args, "--file"); + const QString data = takeOption(args, "--data"); + if (fileName.isEmpty() == data.isEmpty()) { + return finish(cli::Result::failure("Use exactly one of --file or --data"), jsonOutput); + } + + if (!fileName.isEmpty()) { + auto handler = [fileName](cli::Context &context) { return context.importConfigFromFile(fileName); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.import_file" }, { "file", fileName } }) + : runDirect(handler), + jsonOutput); + } + + auto handler = [data](cli::Context &context) { return context.importConfigFromData(data); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.import_data" }, { "data", data } }) + : runDirect(handler), + jsonOutput); + } + if (action == "remove") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.removeServer(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.remove" }, { "index", index } }) + : runDirect(handler), + jsonOutput); + } + if (action == "set-default") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.setDefaultServer(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.set_default" }, { "index", index } }) + : runDirect(handler), + jsonOutput, printServer); + } + if (action == "scan") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.scanServer(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "servers.scan" }, { "index", index } }) + : runDirect(handler), + jsonOutput, printServer); + } + + return finish(cli::Result::failure(QString("Unknown servers action '%1'").arg(action)), jsonOutput); + } + + if (command == "countries") { + if (args.isEmpty()) { + return finish(cli::Result::failure("countries subcommand required"), jsonOutput); + } + + const QString action = args.takeFirst(); + if (action == "list") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.listCountries(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "countries.list" }, { "index", index } }) + : runDirect(handler), + jsonOutput, printCountries); + } + if (action == "set") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + const QString countryCode = takeOption(args, "--country"); + auto handler = [index, countryCode](cli::Context &context) { return context.setCountry(index, countryCode); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "countries.set" }, + { "index", index }, + { "country", countryCode }, + }) + : runDirect(handler), + jsonOutput, printSelectedCountry); + } + + return finish(cli::Result::failure(QString("Unknown countries action '%1'").arg(action)), jsonOutput); + } + + if (command == "containers") { + if (args.isEmpty()) { + return finish(cli::Result::failure("containers subcommand required"), jsonOutput); + } + + const QString action = args.takeFirst(); + if (action == "list") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + auto handler = [index](cli::Context &context) { return context.listContainers(index); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "containers.list" }, { "index", index } }) + : runDirect(handler), + jsonOutput, printContainers); + } + if (action == "set-default") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + const QString container = takeOption(args, "--container", "-c"); + auto handler = [index, container](cli::Context &context) { + return context.setDefaultContainer(index, cli::containerFromCliName(container)); + }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "containers.set_default" }, + { "index", index }, + { "container", container }, + }) + : runDirect(handler), + jsonOutput, printServer); + } + if (action == "remove") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + const QString container = takeOption(args, "--container", "-c"); + auto handler = [index, container](cli::Context &context) { + return context.removeContainer(index, cli::containerFromCliName(container)); + }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "containers.remove" }, + { "index", index }, + { "container", container }, + }) + : runDirect(handler), + jsonOutput); + } + + return finish(cli::Result::failure(QString("Unknown containers action '%1'").arg(action)), jsonOutput); + } + + if (command == "install") { + if (args.isEmpty()) { + return finish(cli::Result::failure("install target required"), jsonOutput); + } + + const QString target = args.takeFirst(); + if (target == "server") { + ServerCredentials credentials; + credentials.hostName = takeOption(args, "--host"); + credentials.userName = takeOption(args, "--user"); + credentials.port = parsePort(takeOption(args, "--port"), 22); + credentials.secretData = takeOption(args, "--password"); + if (credentials.secretData.isEmpty()) { + credentials.secretData = takeOption(args, "--secret"); + } + + const QString keyFile = takeOption(args, "--key-file"); + if (credentials.secretData.isEmpty() && !keyFile.isEmpty()) { + credentials.secretData = readSecretFromFile(keyFile); + } + + const QString name = takeOption(args, "--name"); + const QString containerName = takeOption(args, "--container", "-c"); + const auto container = cli::containerFromCliName(containerName); + const auto protocol = ContainerProps::defaultProtocol(container); + const int containerPort = parsePort(takeOption(args, "--container-port"), ProtocolProps::defaultPort(protocol)); + const auto transport = parseTransport(takeOption(args, "--transport"), protocol); + const QString keyPassphrase = takeOption(args, "--key-passphrase"); + + auto handler = [name, credentials, container, containerPort, transport, keyPassphrase](cli::Context &context) { + return context.installServer(name, credentials, container, containerPort, transport, keyPassphrase); + }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "install.server" }, + { "host", credentials.hostName }, + { "user", credentials.userName }, + { "secret", credentials.secretData }, + { "ssh_port", credentials.port }, + { "name", name }, + { "container", containerName }, + { "container_port", containerPort }, + { "transport", ProtocolProps::transportProtoToString(transport) }, + { "key_passphrase", keyPassphrase }, + }) + : runDirect(handler), + jsonOutput, printServer); + } + if (target == "container") { + const int index = parsePort(takeOption(args, "--index", "-i"), -1); + const QString containerName = takeOption(args, "--container", "-c"); + const auto container = cli::containerFromCliName(containerName); + const auto protocol = ContainerProps::defaultProtocol(container); + const int containerPort = parsePort(takeOption(args, "--container-port"), ProtocolProps::defaultPort(protocol)); + const auto transport = parseTransport(takeOption(args, "--transport"), protocol); + const QString keyPassphrase = takeOption(args, "--key-passphrase"); + + auto handler = [index, container, containerPort, transport, keyPassphrase](cli::Context &context) { + return context.installContainer(index, container, containerPort, transport, keyPassphrase); + }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { + { "command", "install.container" }, + { "index", index }, + { "container", containerName }, + { "container_port", containerPort }, + { "transport", ProtocolProps::transportProtoToString(transport) }, + { "key_passphrase", keyPassphrase }, + }) + : runDirect(handler), + jsonOutput, printServer); + } + + return finish(cli::Result::failure(QString("Unknown install target '%1'").arg(target)), jsonOutput); + } + + if (command == "logs") { + if (args.isEmpty()) { + return finish(cli::Result::failure("logs subcommand required"), jsonOutput); + } + + const QString action = args.takeFirst(); + if (action == "cleanup") { + auto handler = [](cli::Context &context) { return context.cleanupLogs(); }; + return finish(daemonRunning ? runViaDaemon(QJsonObject { { "command", "logs.cleanup" } }) : runDirect(handler), jsonOutput); + } + + return finish(cli::Result::failure(QString("Unknown logs action '%1'").arg(action)), jsonOutput); + } + + return finish(cli::Result::failure(QString("Unknown command '%1'").arg(command)), jsonOutput); +} diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 30b4c572d6..f19f921568 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -5,9 +5,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -15,7 +17,6 @@ #include "QBlockCipher.h" #include "QRsa.h" -#include "amnezia_application.h" #include "core/api/apiUtils.h" #include "core/networkUtilities.h" #include "utilities.h" @@ -24,6 +25,8 @@ #include "core/ipcclient.h" #endif +using amnezia::ErrorCode; + namespace { namespace configKey @@ -49,6 +52,18 @@ namespace constexpr int httpStatusCodeUnprocessableEntity = 422; constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); + + QNetworkAccessManager *networkManager() + { + if (auto *app = QCoreApplication::instance()) { + if (auto *manager = app->findChild()) { + return manager; + } + } + + static QNetworkAccessManager fallbackManager; + return &fallbackManager; + } } GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, @@ -165,7 +180,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api return encRequestData.errorCode; } - QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); + QNetworkReply *reply = networkManager()->post(encRequestData.request, encRequestData.requestBody); QEventLoop wait; connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); @@ -187,7 +202,7 @@ ErrorCode GatewayController::post(const QString &endpoint, const QJsonObject api if (sslErrors.isEmpty() && shouldBypassProxy(replyError, decryptionResult.decryptedBody, decryptionResult.isDecryptionSuccessful)) { auto requestFunction = [&encRequestData, &encryptedResponseBody](const QString &url) { encRequestData.request.setUrl(url); - return amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); + return networkManager()->post(encRequestData.request, encRequestData.requestBody); }; auto replyProcessingFunction = [&encryptedResponseBody, &replyErrorString, &replyError, &httpStatusCode, &sslErrors, &encRequestData, @@ -240,7 +255,7 @@ QFuture> GatewayController::postAsync(const QString return promise->future(); } - QNetworkReply *reply = amnApp->networkManager()->post(encRequestData.request, encRequestData.requestBody); + QNetworkReply *reply = networkManager()->post(encRequestData.request, encRequestData.requestBody); auto sslErrors = QSharedPointer>::create(); @@ -365,7 +380,7 @@ QStringList GatewayController::getProxyUrls(const QString &serviceType, const QS for (const auto &proxyStorageUrl : proxyStorageUrls) { request.setUrl(proxyStorageUrl); - reply = amnApp->networkManager()->get(request); + reply = networkManager()->get(request); connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); @@ -520,7 +535,7 @@ void GatewayController::bypassProxy(const QString &endpoint, const QString &serv for (const QString &proxyUrl : proxyUrls) { request.setUrl(proxyUrl + "lmbd-health"); - reply = amnApp->networkManager()->get(request); + reply = networkManager()->get(request); connect(reply, &QNetworkReply::finished, &wait, &QEventLoop::quit); connect(reply, &QNetworkReply::sslErrors, [this, &sslErrors](const QList &errors) { sslErrors = errors; }); @@ -566,7 +581,7 @@ void GatewayController::getProxyUrlsAsync(const QStringList proxyStorageUrls, co request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setUrl(proxyStorageUrls[currentProxyStorageIndex]); - QNetworkReply *reply = amnApp->networkManager()->get(request); + QNetworkReply *reply = networkManager()->get(request); // connect(reply, &QNetworkReply::sslErrors, this, [state](const QList &e) { *(state->sslErrors) = e; }); @@ -636,7 +651,7 @@ void GatewayController::getProxyUrlAsync(const QStringList proxyUrls, const int request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setUrl(proxyUrls[currentProxyIndex] + "lmbd-health"); - QNetworkReply *reply = amnApp->networkManager()->get(request); + QNetworkReply *reply = networkManager()->get(request); // connect(reply, &QNetworkReply::sslErrors, this, [state](const QList &e) { // *(state->sslErrors) = e; @@ -669,7 +684,7 @@ void GatewayController::bypassProxyAsync( QNetworkRequest request = encRequestData.request; request.setUrl(endpoint.arg(proxyUrl)); - QNetworkReply *reply = amnApp->networkManager()->post(request, encRequestData.requestBody); + QNetworkReply *reply = networkManager()->post(request, encRequestData.requestBody); connect(reply, &QNetworkReply::sslErrors, this, [sslErrors](const QList &errors) { *sslErrors = errors; }); diff --git a/client/ui/controllers/api/apiConfigsController.cpp b/client/ui/controllers/api/apiConfigsController.cpp index d55a74dbdc..a77b4e0ad7 100644 --- a/client/ui/controllers/api/apiConfigsController.cpp +++ b/client/ui/controllers/api/apiConfigsController.cpp @@ -1,6 +1,5 @@ #include "apiConfigsController.h" -#include "amnezia_application.h" #include "configurators/wireguard_configurator.h" #include "core/api/apiDefs.h" #include "core/api/apiUtils.h" @@ -12,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -417,6 +417,13 @@ namespace data.insert(configKey::services, services); } #endif + + QClipboard *applicationClipboard() + { + auto *application = QCoreApplication::instance(); + auto *guiApplication = qobject_cast(application); + return guiApplication ? guiApplication->clipboard() : nullptr; + } } ApiConfigsController::ApiConfigsController(const QSharedPointer &serversModel, @@ -546,8 +553,9 @@ void ApiConfigsController::prepareVpnKeyExport() void ApiConfigsController::copyVpnKeyToClipboard() { - auto clipboard = amnApp->getClipboard(); - clipboard->setText(m_vpnKey); + if (auto *clipboard = applicationClipboard()) { + clipboard->setText(m_vpnKey); + } } bool ApiConfigsController::fillAvailableServices() diff --git a/client/ui/controllers/connectionController.cpp b/client/ui/controllers/connectionController.cpp index d84b9c8984..2a7f3dbc16 100644 --- a/client/ui/controllers/connectionController.cpp +++ b/client/ui/controllers/connectionController.cpp @@ -6,11 +6,28 @@ #include #endif -#include "amnezia_application.h" +#include +#include + #include "utilities.h" #include "core/controllers/vpnConfigurationController.h" #include "version.h" +namespace +{ + QNetworkAccessManager *networkManager() + { + if (auto *app = QCoreApplication::instance()) { + if (auto *manager = app->findChild()) { + return manager; + } + } + + static QNetworkAccessManager fallbackManager; + return &fallbackManager; + } +} + ConnectionController::ConnectionController(const QSharedPointer &serversModel, const QSharedPointer &containersModel, const QSharedPointer &clientManagementModel, @@ -82,7 +99,7 @@ void ConnectionController::onConnectionStateChanged(Vpn::ConnectionState state) m_connectionStateText = tr("Connecting..."); switch (state) { case Vpn::ConnectionState::Connected: { - amnApp->networkManager()->clearConnectionCache(); + networkManager()->clearConnectionCache(); m_isConnectionInProgress = false; m_isConnected = true;