diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index beec3131c..36136f71a 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -135,6 +135,9 @@ void CoreController::initControllers() new SettingsController(m_serversModel, m_containersModel, m_languageModel, m_sitesModel, m_appSplitTunnelingModel, m_settings)); m_engine->rootContext()->setContextProperty("SettingsController", m_settingsController.get()); + m_serversBackupController.reset(new ServersBackupController(m_settings)); + m_engine->rootContext()->setContextProperty("ServersBackupController", m_serversBackupController.get()); + m_sitesController.reset(new SitesController(m_settings, m_vpnConnection, m_sitesModel)); m_engine->rootContext()->setContextProperty("SitesController", m_sitesController.get()); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index f15f6d31a..c723eeadf 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -22,6 +22,7 @@ #include "ui/controllers/installController.h" #include "ui/controllers/pageController.h" #include "ui/controllers/settingsController.h" +#include "ui/controllers/serversBackupController.h" #include "ui/controllers/sitesController.h" #include "ui/controllers/systemController.h" @@ -115,6 +116,7 @@ private: QScopedPointer m_importController; QScopedPointer m_exportController; QScopedPointer m_settingsController; + QScopedPointer m_serversBackupController; QScopedPointer m_sitesController; QScopedPointer m_systemController; QScopedPointer m_appSplitTunnelingController; diff --git a/client/core/controllers/serverController.cpp b/client/core/controllers/serverController.cpp index 24583171e..c46c2fb91 100644 --- a/client/core/controllers/serverController.cpp +++ b/client/core/controllers/serverController.cpp @@ -94,6 +94,25 @@ ErrorCode ServerController::runScript(const ServerCredentials &credentials, QStr return ErrorCode::NoError; } +ErrorCode ServerController::runHostScript(const ServerCredentials &credentials, QString script, + const std::function &cbReadStdOut, + const std::function &cbReadStdErr) +{ + QString fileName = "/tmp/amnezia_" + Utils::getRandomString(16) + ".sh"; + + ErrorCode e = uploadFileToHost(credentials, script.toUtf8(), fileName); + if (e) + return e; + + QString runner = QString("sudo bash %1").arg(fileName); + e = runScript(credentials, runner, cbReadStdOut, cbReadStdErr); + + QString remover = QString("sudo rm -f %1").arg(fileName); + runScript(credentials, remover, cbReadStdOut, cbReadStdErr); + + return e; +} + ErrorCode ServerController::runContainerScript(const ServerCredentials &credentials, DockerContainer container, QString script, const std::function &cbReadStdOut, const std::function &cbReadStdErr) @@ -210,6 +229,37 @@ ErrorCode ServerController::uploadFileToHost(const ServerCredentials &credential return ErrorCode::NoError; } +ErrorCode ServerController::downloadFileFromHost(const ServerCredentials &credentials, const QString &remotePath, const QString &localPath) +{ + auto error = m_sshClient.connectToHost(credentials); + if (error != ErrorCode::NoError) { + return error; + } + + error = m_sshClient.scpFileDownload(remotePath, localPath); + + if (error != ErrorCode::NoError) { + return error; + } + return ErrorCode::NoError; +} + +ErrorCode ServerController::uploadFileToHostPublic(const ServerCredentials &credentials, const QString &localPath, const QString &remotePath, + libssh::ScpOverwriteMode overwriteMode) +{ + auto error = m_sshClient.connectToHost(credentials); + if (error != ErrorCode::NoError) { + return error; + } + + error = m_sshClient.scpFileCopy(overwriteMode, localPath, remotePath, "backup_file"); + + if (error != ErrorCode::NoError) { + return error; + } + return ErrorCode::NoError; +} + ErrorCode ServerController::rebootServer(const ServerCredentials &credentials) { QString script = QString("sudo reboot"); diff --git a/client/core/controllers/serverController.h b/client/core/controllers/serverController.h index c87d15235..f7904baf3 100644 --- a/client/core/controllers/serverController.h +++ b/client/core/controllers/serverController.h @@ -46,6 +46,10 @@ public: const std::function &cbReadStdOut = nullptr, const std::function &cbReadStdErr = nullptr); + ErrorCode runHostScript(const ServerCredentials &credentials, QString script, + const std::function &cbReadStdOut = nullptr, + const std::function &cbReadStdErr = nullptr); + ErrorCode runContainerScript(const ServerCredentials &credentials, DockerContainer container, QString script, const std::function &cbReadStdOut = nullptr, const std::function &cbReadStdErr = nullptr); @@ -57,6 +61,10 @@ public: ErrorCode getDecryptedPrivateKey(const ServerCredentials &credentials, QString &decryptedPrivateKey, const std::function &callback); + ErrorCode downloadFileFromHost(const ServerCredentials &credentials, const QString &remotePath, const QString &localPath); + ErrorCode uploadFileToHostPublic(const ServerCredentials &credentials, const QString &localPath, const QString &remotePath, + libssh::ScpOverwriteMode overwriteMode = libssh::ScpOverwriteMode::ScpOverwriteExisting); + private: ErrorCode installDockerWorker(const ServerCredentials &credentials, DockerContainer container); ErrorCode prepareHostWorker(const ServerCredentials &credentials, DockerContainer container, const QJsonObject &config = QJsonObject()); diff --git a/client/core/sshclient.cpp b/client/core/sshclient.cpp index 30322bb56..6bdbb6a09 100644 --- a/client/core/sshclient.cpp +++ b/client/core/sshclient.cpp @@ -290,6 +290,86 @@ namespace libssh { return watcher.result(); } + ErrorCode Client::scpFileDownload(const QString& remotePath, const QString& localPath) + { + // Use full path for SCP download + m_scpSession = ssh_scp_new(m_session, SSH_SCP_READ, remotePath.toStdString().c_str()); + + if (m_scpSession == nullptr) { + return fromLibsshErrorCode(); + } + + if (ssh_scp_init(m_scpSession) != SSH_OK) { + auto errorCode = fromLibsshErrorCode(); + closeScpSession(); + return errorCode; + } + + QFutureWatcher watcher; + connect(&watcher, &QFutureWatcher::finished, this, &Client::scpFileDownloadFinished); + QFuture future = QtConcurrent::run([this, &remotePath, &localPath]() { + // Pull request - this gets file info + int result = ssh_scp_pull_request(m_scpSession); + if (result != SSH_SCP_REQUEST_NEWFILE) { + return fromLibsshErrorCode(); + } + + // Accept the request + ssh_scp_accept_request(m_scpSession); + + // Get file size + int fileSize = ssh_scp_request_get_size(m_scpSession); + if (fileSize <= 0) { + return ErrorCode::InternalError; + } + + // Open local file for writing + QFile fout(localPath); + if (!fout.open(QIODevice::WriteOnly)) { + return fromFileErrorCode(fout.error()); + } + + // Read file data in chunks + constexpr size_t bufferSize = 16384; + int transferred = 0; + + while (transferred < fileSize) { + int chunkSize = qMin(bufferSize, static_cast(fileSize - transferred)); + QByteArray buffer(chunkSize, 0); + + int bytesRead = ssh_scp_read(m_scpSession, buffer.data(), chunkSize); + if (bytesRead == SSH_ERROR) { + fout.close(); + return fromLibsshErrorCode(); + } + + if (bytesRead != chunkSize) { + fout.close(); + return ErrorCode::InternalError; + } + + qint64 bytesWritten = fout.write(buffer); + if (bytesWritten != chunkSize) { + fout.close(); + return fromFileErrorCode(fout.error()); + } + + transferred += bytesRead; + } + + fout.close(); + return ErrorCode::NoError; + }); + watcher.setFuture(future); + + QEventLoop wait; + QObject::connect(this, &Client::scpFileDownloadFinished, &wait, &QEventLoop::quit); + wait.exec(); + + closeScpSession(); + return watcher.result(); + } + void Client::closeScpSession() { if (m_scpSession != nullptr) { diff --git a/client/core/sshclient.h b/client/core/sshclient.h index 2ef26fb1a..d99a3c7be 100644 --- a/client/core/sshclient.h +++ b/client/core/sshclient.h @@ -36,6 +36,8 @@ namespace libssh { const QString &localPath, const QString &remotePath, const QString &fileDesc); + ErrorCode scpFileDownload(const QString &remotePath, + const QString &localPath); ErrorCode getDecryptedPrivateKey(const ServerCredentials &credentials, QString &decryptedPrivateKey, const std::function &passphraseCallback); private: ErrorCode closeChannel(); @@ -52,6 +54,7 @@ namespace libssh { signals: void writeToChannelFinished(); void scpFileCopyFinished(); + void scpFileDownloadFinished(); }; } diff --git a/client/ui/controllers/serversBackupController.cpp b/client/ui/controllers/serversBackupController.cpp new file mode 100644 index 000000000..8672373a4 --- /dev/null +++ b/client/ui/controllers/serversBackupController.cpp @@ -0,0 +1,826 @@ +#include "serversBackupController.h" +#include +#include +#include +#include +#include +#ifdef Q_OS_ANDROID +#include +#endif +#ifdef Q_OS_IOS +#include "platforms/ios/ios_controller.h" +#endif +#include "containers/containers_defs.h" + +ServersBackupController::ServersBackupController(std::shared_ptr settings, QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_serverController(new ServerController(settings, this)) + , m_status(Idle) + , m_backupDir("/var/backups/amnezia") +{ +} + +ServersBackupController::~ServersBackupController() +{ +} + +void ServersBackupController::setBackupDirectory(const QString &directory) +{ + m_backupDir = directory; +} + +void ServersBackupController::createBackup(const ServerCredentials &credentials) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Starting backup creation...")); + + m_currentOutput.clear(); + m_currentError.clear(); + + // Получаем bash скрипт для backup + QString script = getBackupScript(); + + // Callback для обработки stdout + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + // Callback для обработки stderr + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + // Запускаем скрипт на сервере + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + // Парсим имя созданного backup из вывода + QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz"); + QRegularExpressionMatch match = re.match(m_currentOutput); + + if (match.hasMatch()) { + QString backupFilename = "backup_" + match.captured(1) + ".tar.gz"; + setStatus(Success); + setProgress(100, tr("Backup created successfully")); + emit backupCreated(backupFilename); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError); + } + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to create backup: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::createBackupByName(const ServerCredentials &credentials, const QString &containerName) +{ + DockerContainer container = ContainerProps::containerFromString(containerName); + if (container == DockerContainer::None) { + emit errorOccurred(tr("Unknown container: %1").arg(containerName), ErrorCode::InternalError); + return; + } + createContainerBackup(credentials, container); +} + +void ServersBackupController::createContainerBackup(const ServerCredentials &credentials, DockerContainer container) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + QString containerName = ContainerProps::containerToString(container); + setProgress(0, tr("Starting backup for container: %1...").arg(containerName)); + + m_currentOutput.clear(); + m_currentError.clear(); + + QString script = getContainerBackupScript(container); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz"); + QRegularExpressionMatch match = re.match(m_currentOutput); + + if (match.hasMatch()) { + QString backupFilename = "backup_" + match.captured(1) + ".tar.gz"; + setStatus(Success); + setProgress(100, tr("Container backup created successfully")); + emit backupCreated(backupFilename); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError); + } + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to create container backup: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::createContainersBackup(const ServerCredentials &credentials, const QList &containers) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Starting backup for %1 containers...").arg(containers.size())); + + m_currentOutput.clear(); + m_currentError.clear(); + + QString script = getContainersBackupScript(containers); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + QRegularExpression re("backup_(\\d{8}_\\d{6})\\.tar\\.gz"); + QRegularExpressionMatch match = re.match(m_currentOutput); + + if (match.hasMatch()) { + QString backupFilename = "backup_" + match.captured(1) + ".tar.gz"; + setStatus(Success); + setProgress(100, tr("Containers backup created successfully")); + emit backupCreated(backupFilename); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to parse backup filename from output: %1").arg(m_currentOutput.left(200)), ErrorCode::InternalError); + } + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to create containers backup: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::fetchBackupList(const ServerCredentials &credentials) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Fetching backup list...")); + + m_currentOutput.clear(); + m_currentError.clear(); + + QString script = getListBackupsScript(); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + QList backups = parseBackupList(m_currentOutput); + setStatus(Success); + setProgress(100, tr("Backup list received")); + emit backupListReceived(backups); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to fetch backup list: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::restoreBackup(const ServerCredentials &credentials, + const QString &backupFilename, + const QStringList &containers) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Starting restore from %1...").arg(backupFilename)); + + m_currentOutput.clear(); + m_currentError.clear(); + + QString script = getRestoreScript(backupFilename, containers); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + setStatus(Success); + setProgress(100, tr("Backup restored successfully")); + emit backupRestored(); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to restore backup: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::checkBackupStatus(const ServerCredentials &credentials) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Checking backup status...")); + + m_currentOutput.clear(); + m_currentError.clear(); + + QString script = getCheckStatusScript(); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + QJsonObject status = parseBackupStatus(m_currentOutput); + setStatus(Success); + setProgress(100, tr("Status received")); + emit backupStatusReceived(status); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to check backup status: %1").arg(m_currentError), error); + } +} + +void ServersBackupController::downloadBackup(const ServerCredentials &credentials, + const QString &backupFilename, + const QString &localPath) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Downloading backup...")); + + // Validate backup filename + if (backupFilename.isEmpty()) { + setStatus(Failed); + emit errorOccurred(tr("Backup filename is empty"), ErrorCode::InternalError); + return; + } + + // Construct remote file path + QString remotePath = QString("%1/%2").arg(m_backupDir, backupFilename); + + // Determine actual local path + QString actualLocalPath = localPath; + QFileInfo pathInfo(localPath); + + // If only filename provided (no directory), use appropriate folder + if (!pathInfo.isAbsolute() || pathInfo.dir().path() == ".") { +#ifdef Q_OS_ANDROID + // На Android используем публичную папку Download (через JNI) + QJniObject mediaDir = QJniObject::callStaticObjectMethod( + "android/os/Environment", + "getExternalStoragePublicDirectory", + "(Ljava/lang/String;)Ljava/io/File;", + QJniObject::getStaticObjectField("android/os/Environment", "DIRECTORY_DOWNLOADS", "Ljava/lang/String;").object()); + QString downloadsPath = mediaDir.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;").toString(); + actualLocalPath = QDir(downloadsPath).filePath(backupFilename); +#elif defined(Q_OS_IOS) + QString tempPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + actualLocalPath = QDir(tempPath).filePath(backupFilename); +#else + // На Desktop используем Documents (как обычный backup) + QString documentsPath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (documentsPath.isEmpty()) { + documentsPath = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation); + } + actualLocalPath = QDir(documentsPath).filePath(backupFilename); +#endif + } + + // Ensure local directory exists + QFileInfo localFileInfo(actualLocalPath); + QDir localDir = localFileInfo.dir(); + if (!localDir.exists()) { + if (!localDir.mkpath(".")) { + setStatus(Failed); + emit errorOccurred(tr("Failed to create local directory: %1").arg(localDir.path()), ErrorCode::InternalError); + return; + } + } + + setProgress(25, tr("Starting file transfer...")); + + ErrorCode error = m_serverController->downloadFileFromHost(credentials, remotePath, actualLocalPath); + + if (error == ErrorCode::NoError) { + // qDebug() << "Backup downloaded to:" << actualLocalPath; + +#ifdef Q_OS_IOS + QStringList filesToShare; + filesToShare.append(actualLocalPath); + IosController::Instance()->shareText(filesToShare); +#endif + + setStatus(Success); + setProgress(100, tr("Backup downloaded successfully")); + emit backupDownloaded(actualLocalPath); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to download backup: error code %1").arg(static_cast(error)), error); + } +} + +void ServersBackupController::uploadBackup(const ServerCredentials &credentials, + const QString &localPath) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + // Check if local file exists + QFileInfo localFileInfo(localPath); + if (!localFileInfo.exists()) { + emit errorOccurred(tr("Local file does not exist: %1").arg(localPath), ErrorCode::InternalError); + return; + } + + if (!localFileInfo.isFile()) { + emit errorOccurred(tr("Path is not a file: %1").arg(localPath), ErrorCode::InternalError); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Uploading backup...")); + + // Construct remote file path with filename from local path + QString filename = localFileInfo.fileName(); + QString remotePath = QString("%1/%2").arg(m_backupDir, filename); + + setProgress(25, tr("Starting file transfer...")); + + ErrorCode error = m_serverController->uploadFileToHostPublic(credentials, localPath, remotePath, + libssh::ScpOverwriteMode::ScpOverwriteExisting); + + if (error == ErrorCode::NoError) { + setStatus(Success); + setProgress(100, tr("Backup uploaded successfully")); + emit backupUploaded(remotePath); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to upload backup: error code %1").arg(static_cast(error)), error); + } +} + +void ServersBackupController::deleteBackup(const ServerCredentials &credentials, + const QString &backupFilename) +{ + if (m_status == InProgress) { + emit errorOccurred("Another operation is in progress", ErrorCode::AmneziaServiceConnectionFailed); + return; + } + + setStatus(InProgress); + setProgress(0, tr("Deleting backup...")); + + QString script = QString("sudo rm -f %1/%2").arg(m_backupDir).arg(backupFilename); + + m_currentOutput.clear(); + m_currentError.clear(); + + auto cbStdOut = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdOut(data, m_currentOutput); + }; + + auto cbStdErr = [this](const QString &data, libssh::Client &client) -> ErrorCode { + Q_UNUSED(client); + return handleStdErr(data, m_currentError); + }; + + ErrorCode error = m_serverController->runHostScript(credentials, script, cbStdOut, cbStdErr); + + if (error == ErrorCode::NoError) { + setStatus(Success); + setProgress(100, tr("Backup deleted")); + } else { + setStatus(Failed); + emit errorOccurred(tr("Failed to delete backup: %1").arg(m_currentError), error); + } +} + +// ============================================================================ +// ВСТРОЕННЫЕ BASH СКРИПТЫ +// ============================================================================ + +QString ServersBackupController::getBackupScript() const +{ + // Упрощенная версия bash скрипта, встроенная в C++ + return QString(R"( +#!/bin/bash +set -e + +BACKUP_DIR=%1 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP" + +echo "[INFO] Starting backup..." + +# Создание директории +mkdir -p "$BACKUP_SUBDIR" + +# Список контейнеров Amnezia +CONTAINERS=( + "amnezia-awg" + "amnezia-awg2" + "amnezia-openvpn" + "amnezia-xray" + "amnezia-wireguard" + "amnezia-ipsec" + "amnezia-cloak" + "amnezia-shadowsocks" +) + +# Backup каждого контейнера (включая остановленные) +for container in "${CONTAINERS[@]}"; do + if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container$"; then + echo "[INFO] Backing up $container..." + mkdir -p "$BACKUP_SUBDIR/$container" + + # Копируем /opt/amnezia + sudo docker cp "$container:/opt/amnezia" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true + + # Сохраняем метаданные + sudo docker inspect "$container" > "$BACKUP_SUBDIR/$container/container_inspect.json" 2>/dev/null || true + fi +done + +# Создание архива +cd "$BACKUP_DIR" +tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null +rm -rf "$BACKUP_SUBDIR" + +echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz" +)").arg(m_backupDir); +} + +QString ServersBackupController::getContainerBackupScript(DockerContainer container) const +{ + QString containerName = ContainerProps::containerToString(container); + + // Backup конкретного контейнера напрямую через docker cp + return QString(R"( +#!/bin/bash +set -e + +BACKUP_DIR=%1 +CONTAINER_NAME=%2 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP" + +echo "[INFO] Starting backup for container: $CONTAINER_NAME..." + +# Проверка существования контейнера +if ! sudo docker ps -a --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then + echo "[ERROR] Container $CONTAINER_NAME does not exist" + exit 1 +fi + +# Создание директории +mkdir -p "$BACKUP_SUBDIR/$CONTAINER_NAME" + +# Backup конфигураций из контейнера напрямую +echo "[INFO] Copying /opt/amnezia from container..." +sudo docker cp "$CONTAINER_NAME:/opt/amnezia" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || { + echo "[WARN] Failed to copy /opt/amnezia, trying alternative paths..." + # Альтернативные пути для разных типов контейнеров + sudo docker cp "$CONTAINER_NAME:/etc/openvpn" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true + sudo docker cp "$CONTAINER_NAME:/etc/wireguard" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true + sudo docker cp "$CONTAINER_NAME:/etc/ipsec.d" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true + sudo docker cp "$CONTAINER_NAME:/etc/xray" "$BACKUP_SUBDIR/$CONTAINER_NAME/" 2>/dev/null || true +} + +# Сохранение метаданных контейнера +echo "[INFO] Saving container metadata..." +sudo docker inspect "$CONTAINER_NAME" > "$BACKUP_SUBDIR/$CONTAINER_NAME/container_inspect.json" 2>/dev/null || true + +# Сохранение конфигурации сети +sudo docker network inspect $(sudo docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$CONTAINER_NAME" | awk '{print $1}') \ + > "$BACKUP_SUBDIR/$CONTAINER_NAME/network_config.json" 2>/dev/null || true + +# Создание архива +cd "$BACKUP_DIR" +tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null +rm -rf "$BACKUP_SUBDIR" + +echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz" +)").arg(m_backupDir).arg(containerName); +} + +QString ServersBackupController::getContainersBackupScript(const QList &containers) const +{ + QString containersList; + for (const DockerContainer &container : containers) { + QString containerName = ContainerProps::containerToString(container); + containersList += QString("\"%1\" ").arg(containerName); + } + + return QString(R"( +#!/bin/bash +set -e + +BACKUP_DIR=%1 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_SUBDIR="$BACKUP_DIR/backup_$TIMESTAMP" + +echo "[INFO] Starting backup for containers..." + +# Создание директории +mkdir -p "$BACKUP_SUBDIR" + +# Список контейнеров для backup +CONTAINERS=(%2) + +# Backup каждого контейнера +for container in "${CONTAINERS[@]}"; do + if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container$"; then + echo "[INFO] Backing up $container..." + mkdir -p "$BACKUP_SUBDIR/$container" + + # Копируем /opt/amnezia напрямую из контейнера + sudo docker cp "$container:/opt/amnezia" "$BACKUP_SUBDIR/$container/" 2>/dev/null || { + echo "[WARN] Failed to copy /opt/amnezia from $container, trying alternative paths..." + sudo docker cp "$container:/etc/openvpn" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true + sudo docker cp "$container:/etc/wireguard" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true + sudo docker cp "$container:/etc/ipsec.d" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true + sudo docker cp "$container:/etc/xray" "$BACKUP_SUBDIR/$container/" 2>/dev/null || true + } + + # Сохраняем метаданные + sudo docker inspect "$container" > "$BACKUP_SUBDIR/$container/container_inspect.json" 2>/dev/null || true + else + echo "[WARN] Container $container does not exist, skipping..." + fi +done + +# Создание архива +cd "$BACKUP_DIR" +tar -czf "backup_$TIMESTAMP.tar.gz" "backup_$TIMESTAMP" 2>/dev/null +rm -rf "$BACKUP_SUBDIR" + +echo "[INFO] Backup created: backup_$TIMESTAMP.tar.gz" +)").arg(m_backupDir).arg(containersList.trimmed()); +} + +QString ServersBackupController::getRestoreScript(const QString &backupFilename, + const QStringList &containers) const +{ + Q_UNUSED(containers); // TODO: Использовать для выборочного восстановления + + return QString(R"( +#!/bin/bash +set -e + +BACKUP_DIR=%1 +BACKUP_FILE=%2 +TEMP_DIR="/tmp/amnezia_restore_$$" + +echo "[INFO] Starting restore from $BACKUP_FILE..." + +# Извлечение backup +mkdir -p "$TEMP_DIR" +tar -xzf "$BACKUP_DIR/$BACKUP_FILE" -C "$TEMP_DIR" 2>/dev/null + +BACKUP_SUBDIR=$(ls -d "$TEMP_DIR"/backup_* 2>/dev/null | head -1) + +if [ -z "$BACKUP_SUBDIR" ]; then + echo "[ERROR] Failed to extract backup" + exit 1 +fi + +# Восстановление каждого контейнера +for container_dir in "$BACKUP_SUBDIR"/*; do + if [ ! -d "$container_dir" ]; then + continue + fi + + container_name=$(basename "$container_dir") + + if sudo docker ps -a --format '{{.Names}}' | grep -q "^$container_name$"; then + echo "[INFO] Restoring $container_name..." + + # Остановка контейнера + sudo docker stop "$container_name" 2>/dev/null || true + + # Восстановление /opt/amnezia + if [ -d "$container_dir/amnezia" ]; then + sudo docker cp "$container_dir/amnezia" "$container_name:/opt/" 2>/dev/null || true + fi + + # Запуск контейнера + sudo docker start "$container_name" 2>/dev/null || true + + echo "[INFO] $container_name restored" + fi +done + +# Очистка +rm -rf "$TEMP_DIR" + +echo "[INFO] Restore completed successfully" +)").arg(m_backupDir).arg(backupFilename); +} + +QString ServersBackupController::getCheckStatusScript() const +{ + return QString(R"SCRIPT( +#!/bin/bash + +BACKUP_DIR=%1 + +echo "Backup directory: $BACKUP_DIR" + +# Проверка наличия backup +BACKUPS=$(ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | wc -l) +echo "Total backups: $BACKUPS" + +if [ "$BACKUPS" -gt 0 ]; then + # Информация о последнем backup + LATEST=$(ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | head -1) + if [ -n "$LATEST" ]; then + echo "Latest backup: $(basename "$LATEST")" + echo "Size: $(du -h "$LATEST" | cut -f1)" + echo "Modified: $(stat -c %y "$LATEST" 2>/dev/null | cut -d'.' -f1)" + fi +fi + +# Проверка запущенных контейнеров +echo "Running containers:" +sudo docker ps --filter "name=amnezia-" --format '{{.Names}}' 2>/dev/null + +echo "Status check completed" +)SCRIPT").arg(m_backupDir); +} + +QString ServersBackupController::getListBackupsScript() const +{ + return QString(R"( +#!/bin/bash + +BACKUP_DIR=%1 + +ls -lht "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null || echo "No backups found" +)").arg(m_backupDir); +} + +// ============================================================================ +// ПАРСИНГ ВЫВОДА +// ============================================================================ + +QList ServersBackupController::parseBackupList(const QString &output) +{ + QList backups; + + QStringList lines = output.split('\n', Qt::SkipEmptyParts); + + // Парсим вывод ls -lht + QRegularExpression re("^-.*\\s+(\\d+)\\s+\\w+\\s+\\d+\\s+([\\d:]+)\\s+(.+backup_(\\d{8}_\\d{6})\\.tar\\.gz)$"); + + for (const QString &line : lines) { + QRegularExpressionMatch match = re.match(line); + if (match.hasMatch()) { + BackupInfo info; + info.size = match.captured(1).toLongLong(); + info.filename = QFileInfo(match.captured(3)).fileName(); + info.fullPath = match.captured(3); + + // Парсим дату из имени файла + QString dateStr = match.captured(4); + info.createdAt = QDateTime::fromString(dateStr, "yyyyMMdd_HHmmss"); + info.isValid = true; + + backups.append(info); + } + } + + return backups; +} + +QJsonObject ServersBackupController::parseBackupStatus(const QString &output) +{ + QJsonObject status; + + // Парсим текстовый вывод + status["raw_output"] = output; + status["has_backups"] = output.contains("Total backups:"); + + // Извлекаем количество backup + QRegularExpression reTotal("Total backups: (\\d+)"); + QRegularExpressionMatch matchTotal = reTotal.match(output); + if (matchTotal.hasMatch()) { + status["total_backups"] = matchTotal.captured(1).toInt(); + } + + // Извлекаем информацию о последнем backup + QRegularExpression reLatest("Latest backup: (.+)"); + QRegularExpressionMatch matchLatest = reLatest.match(output); + if (matchLatest.hasMatch()) { + status["latest_backup"] = matchLatest.captured(1); + } + + return status; +} + +// ============================================================================ +// ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ +// ============================================================================ + +ErrorCode ServersBackupController::handleStdOut(const QString &data, QString &output) +{ + output += data; + qDebug().noquote() << "[BACKUP]" << data; + + // Обновляем прогресс на основе вывода + if (data.contains("Starting backup")) { + setProgress(10, tr("Starting backup...")); + } else if (data.contains("Backing up")) { + setProgress(50, tr("Backing up containers...")); + } else if (data.contains("Backup created")) { + setProgress(90, tr("Finalizing...")); + } + + return ErrorCode::NoError; +} + +ErrorCode ServersBackupController::handleStdErr(const QString &data, QString &error) +{ + error += data; + qDebug().noquote() << "[BACKUP ERROR]" << data; + return ErrorCode::NoError; +} + +void ServersBackupController::setStatus(BackupStatus status) +{ + if (m_status != status) { + m_status = status; + emit statusChanged(status); + } +} + +void ServersBackupController::setProgress(int percent, const QString &message) +{ + emit progressChanged(percent, message); +} + diff --git a/client/ui/controllers/serversBackupController.h b/client/ui/controllers/serversBackupController.h new file mode 100644 index 000000000..b3891eb07 --- /dev/null +++ b/client/ui/controllers/serversBackupController.h @@ -0,0 +1,259 @@ +#ifndef SERVERSBACKUPCONTROLLER_H +#define SERVERSBACKUPCONTROLLER_H + +#include +#include +#include +#include +#include +#include + +#include "core/controllers/serverController.h" +#include "core/defs.h" +#include "containers/containers_defs.h" + +using namespace amnezia; + +/** + * @brief Контроллер для управления backup конфигураций Amnezia VPN + * + * Использует существующий ServerController и libssh::Client из Amnezia + * Bash скрипты встроены напрямую в C++ код + * Поддерживает backup конкретных контейнеров напрямую через docker cp + * + * Полностью кроссплатформенный: Windows, macOS, Linux, iOS, Android + */ +class ServersBackupController : public QObject +{ + Q_OBJECT + +public: + explicit ServersBackupController(std::shared_ptr settings, QObject *parent = nullptr); + ~ServersBackupController(); + + /** + * @brief Информация о backup + */ + struct BackupInfo { + QString filename; + QString fullPath; + QDateTime createdAt; + qint64 size; + bool isValid; + QStringList containers; + }; + + enum BackupStatus { + Idle, + InProgress, + Success, + Failed + }; + Q_ENUM(BackupStatus) + +public slots: + /** + * @brief Создать backup на сервере (всех контейнеров) + * @param credentials Учетные данные сервера + */ + void createBackup(const ServerCredentials &credentials); + + /** + * @brief Создать backup конкретного контейнера + * @param credentials Учетные данные сервера + * @param container Тип контейнера для backup + */ + void createContainerBackup(const ServerCredentials &credentials, DockerContainer container); + + /** + * @brief Создать backup конкретного контейнера по имени + * @param credentials Учетные данные сервера + * @param containerName Имя контейнера (например "amnezia-awg") + */ + void createBackupByName(const ServerCredentials &credentials, const QString &containerName); + + /** + * @brief Создать backup нескольких контейнеров + * @param credentials Учетные данные сервера + * @param containers Список контейнеров для backup + */ + void createContainersBackup(const ServerCredentials &credentials, const QList &containers); + + /** + * @brief Получить список backup с сервера + * @param credentials Учетные данные сервера + */ + void fetchBackupList(const ServerCredentials &credentials); + + /** + * @brief Восстановить из backup + * @param credentials Учетные данные сервера + * @param backupFilename Имя файла backup + * @param containers Список контейнеров (пустой = все) + */ + void restoreBackup(const ServerCredentials &credentials, + const QString &backupFilename, + const QStringList &containers = QStringList()); + + /** + * @brief Проверить состояние backup на сервере + * @param credentials Учетные данные сервера + */ + void checkBackupStatus(const ServerCredentials &credentials); + + /** + * @brief Скачать backup на локальную машину + * @param credentials Учетные данные сервера + * @param backupFilename Имя файла backup + * @param localPath Путь для сохранения + */ + void downloadBackup(const ServerCredentials &credentials, + const QString &backupFilename, + const QString &localPath); + + /** + * @brief Загрузить backup на сервер + * @param credentials Учетные данные сервера + * @param localPath Путь к локальному файлу + */ + void uploadBackup(const ServerCredentials &credentials, + const QString &localPath); + + /** + * @brief Удалить backup с сервера + * @param credentials Учетные данные сервера + * @param backupFilename Имя файла backup + */ + void deleteBackup(const ServerCredentials &credentials, + const QString &backupFilename); + + /** + * @brief Установить директорию backup на сервере + */ + void setBackupDirectory(const QString &directory); + + /** + * @brief Получить директорию backup + */ + QString backupDirectory() const { return m_backupDir; } + +signals: + /** + * @brief Изменился статус операции + */ + void statusChanged(BackupStatus status); + + /** + * @brief Прогресс операции (0-100) + */ + void progressChanged(int percent, const QString &message); + + /** + * @brief Получен список backup + */ + void backupListReceived(const QList &backups); + + /** + * @brief Backup создан успешно + */ + void backupCreated(const QString &backupFilename); + + /** + * @brief Backup восстановлен успешно + */ + void backupRestored(); + + /** + * @brief Backup скачан + */ + void backupDownloaded(const QString &localPath); + + /** + * @brief Backup загружен на сервер + */ + void backupUploaded(const QString &serverPath); + + /** + * @brief Получена информация о состоянии backup + */ + void backupStatusReceived(const QJsonObject &status); + + /** + * @brief Произошла ошибка + */ + void errorOccurred(const QString &errorMessage, ErrorCode errorCode); + +private: + /** + * @brief Получить bash скрипт для создания backup всех контейнеров + */ + QString getBackupScript() const; + + /** + * @brief Получить bash скрипт для создания backup конкретного контейнера + * @param container Тип контейнера + */ + QString getContainerBackupScript(DockerContainer container) const; + + /** + * @brief Получить bash скрипт для создания backup нескольких контейнеров + * @param containers Список контейнеров + */ + QString getContainersBackupScript(const QList &containers) const; + + /** + * @brief Получить bash скрипт для восстановления + */ + QString getRestoreScript(const QString &backupFilename, const QStringList &containers) const; + + /** + * @brief Получить bash скрипт для проверки состояния + */ + QString getCheckStatusScript() const; + + /** + * @brief Получить bash скрипт для списка backup + */ + QString getListBackupsScript() const; + + /** + * @brief Парсить список backup из вывода + */ + QList parseBackupList(const QString &output); + + /** + * @brief Парсить статус из вывода + */ + QJsonObject parseBackupStatus(const QString &output); + + /** + * @brief Обработать стандартный вывод + */ + ErrorCode handleStdOut(const QString &data, QString &output); + + /** + * @brief Обработать вывод ошибок + */ + ErrorCode handleStdErr(const QString &data, QString &error); + + /** + * @brief Установить статус + */ + void setStatus(BackupStatus status); + + /** + * @brief Установить прогресс + */ + void setProgress(int percent, const QString &message); + +private: + std::shared_ptr m_settings; + ServerController *m_serverController; + BackupStatus m_status; + QString m_backupDir; + QString m_currentOutput; + QString m_currentError; +}; + +#endif // SERVERSBACKUPCONTROLLER_H + diff --git a/client/ui/qml/Pages2/PageSettingsServerData.qml b/client/ui/qml/Pages2/PageSettingsServerData.qml index b744a6da8..79d841620 100644 --- a/client/ui/qml/Pages2/PageSettingsServerData.qml +++ b/client/ui/qml/Pages2/PageSettingsServerData.qml @@ -19,6 +19,7 @@ PageType { signal lastItemTabClickedSignal() property bool isServerWithWriteAccess: ServersModel.isProcessedServerHasWriteAccess() + property string selectedContainerValue: "all" Connections { target: InstallController @@ -75,10 +76,148 @@ PageType { delegate: ColumnLayout { width: listView.width + // Кастомная секция для backup + ColumnLayout { + Layout.fillWidth: true + visible: isVisible && isBackupSection + + spacing: 16 + + Header2Type { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + + headerText: qsTr("Backup & Restore") + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + text: qsTr("Create and restore server configuration backups") + color: AmneziaStyle.color.mutedGray + } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + spacing: 12 + + LabelTextType { + text: qsTr("Select containers:") + } + + DropDownType { + id: containerSelector + Layout.fillWidth: true + + objectName: "containerSelector" + descriptionText: qsTr("Choose which containers to backup") + headerText: qsTr("Containers") + + text: "All containers" + + drawerHeight: 0.4375 + drawerParent: root + + listView: ListViewWithRadioButtonType { + id: containerSelectorListView + rootWidth: root.width + + model: ListModel { + id: containersModel + ListElement { name: "All containers"; value: "all" } + ListElement { name: "OpenVPN"; value: "amnezia-openvpn" } + ListElement { name: "WireGuard"; value: "amnezia-wireguard" } + ListElement { name: "AmneziaWG (legacy)"; value: "amnezia-awg" } + ListElement { name: "AmneziaWG 2"; value: "amnezia-awg2" } + ListElement { name: "Xray"; value: "amnezia-xray" } + ListElement { name: "IKEv2"; value: "amnezia-ipsec" } + ListElement { name: "Cloak"; value: "amnezia-cloak" } + ListElement { name: "ShadowSocks"; value: "amnezia-shadowsocks" } + } + + clickedFunction: function() { + if (containerSelectorListView.selectedText) { + containerSelector.text = containerSelectorListView.selectedText + } + // Сохранить выбранное значение + var index = containerSelectorListView.selectedIndex + if (index >= 0 && index < containersModel.count) { + root.selectedContainerValue = containersModel.get(index).value + } + containerSelector.closeTriggered() + } + + Component.onCompleted: { + containerSelectorListView.selectedIndex = 0 + root.selectedContainerValue = "all" + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + BasicButtonType { + Layout.fillWidth: true + text: qsTr("Create Backup") + + clickedFunc: function() { + createBackup(false) // false = не скачивать автоматически + } + } + + BasicButtonType { + Layout.fillWidth: true + text: qsTr("Backup & Download") + + clickedFunc: function() { + createBackup(true) // true = скачать после создания + } + } + } + + BasicButtonType { + Layout.fillWidth: true + text: qsTr("Restore Backup") + defaultColor: AmneziaStyle.color.transparent + hoveredColor: Qt.rgba(1, 1, 1, 0.08) + pressedColor: Qt.rgba(1, 1, 1, 0.12) + disabledColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.goldenApricot + + clickedFunc: function() { + restoreBackup() + } + } + + BasicButtonType { + Layout.fillWidth: true + text: qsTr("Manage Backups") + defaultColor: AmneziaStyle.color.transparent + hoveredColor: Qt.rgba(1, 1, 1, 0.08) + pressedColor: Qt.rgba(1, 1, 1, 0.12) + disabledColor: AmneziaStyle.color.mutedGray + textColor: AmneziaStyle.color.goldenApricot + + clickedFunc: function() { + manageBackups() + } + } + } + } + + // Обычные кнопки для других действий LabelWithButtonType { Layout.fillWidth: true - visible: isVisible + visible: isVisible && !isBackupSection text: title descriptionText: description @@ -97,6 +236,7 @@ PageType { property list serverActions: [ check, + backupSection, reboot, remove, clear, @@ -108,6 +248,7 @@ PageType { id: check property bool isVisible: root.isServerWithWriteAccess + readonly property bool isBackupSection: false readonly property string title: qsTr("Check the server for previously installed Amnezia services") readonly property string description: qsTr("Add them to the application if they were not displayed") readonly property var tColor: AmneziaStyle.color.paleGray @@ -118,10 +259,22 @@ PageType { } } + QtObject { + id: backupSection + + property bool isVisible: root.isServerWithWriteAccess + readonly property bool isBackupSection: true + readonly property string title: "" + readonly property string description: "" + readonly property var tColor: AmneziaStyle.color.paleGray + readonly property var clickedHandler: function() {} + } + QtObject { id: reboot property bool isVisible: root.isServerWithWriteAccess + readonly property bool isBackupSection: false readonly property string title: qsTr("Reboot server") readonly property string description: "" readonly property var tColor: AmneziaStyle.color.vibrantRed @@ -152,6 +305,7 @@ PageType { id: remove property bool isVisible: true + readonly property bool isBackupSection: false readonly property string title: qsTr("Remove server from application") readonly property string description: "" readonly property var tColor: AmneziaStyle.color.vibrantRed @@ -182,6 +336,7 @@ PageType { id: clear property bool isVisible: root.isServerWithWriteAccess + readonly property bool isBackupSection: false readonly property string title: qsTr("Clear server from Amnezia software") readonly property string description: "" readonly property var tColor: AmneziaStyle.color.vibrantRed @@ -211,6 +366,7 @@ PageType { id: reset property bool isVisible: ServersModel.getProcessedServerData("isServerFromTelegramApi") + readonly property bool isBackupSection: false readonly property string title: qsTr("Reset API config") readonly property string description: "" readonly property var tColor: AmneziaStyle.color.vibrantRed @@ -241,6 +397,7 @@ PageType { id: switch_to_premium property bool isVisible: ServersModel.getProcessedServerData("isServerFromTelegramApi") && ServersModel.processedServerIsPremium + readonly property bool isBackupSection: false readonly property string title: qsTr("Switch to the new Amnezia Premium subscription") readonly property string description: "" readonly property var tColor: AmneziaStyle.color.vibrantRed @@ -249,4 +406,171 @@ PageType { ApiPremV1MigrationController.showMigrationDrawer() } } + + // ============ Backup Functions ============ + + function getServerCredentials() { + var index = ServersModel.processedIndex + return ServersModel.getServerCredentials(index) + } + + function getSelectedContainerValue() { + return root.selectedContainerValue + } + + property bool downloadAfterCreate: false + + function createBackup(shouldDownload) { + downloadAfterCreate = shouldDownload || false + + var headerText = shouldDownload ? + qsTr("Create backup and download to device?") : + qsTr("Create server configuration backup?") + var descriptionText = shouldDownload ? + qsTr("Backup will be created on server and automatically downloaded to your device") : + qsTr("This will create a backup of your server containers configuration on the server") + var yesButtonText = qsTr("Create") + var noButtonText = qsTr("Cancel") + + var yesButtonFunction = function() { + PageController.showBusyIndicator(true) + + var credentials = getServerCredentials() + var containerValue = getSelectedContainerValue() + + if (containerValue === "all") { + ServersBackupController.createBackup(credentials) + } else { + ServersBackupController.createBackupByName(credentials, containerValue) + } + } + var noButtonFunction = function() {} + + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + + property string selectedBackupForRestore: "" + + function restoreBackup() { + // Шаг 1: Выбрать backup файл на устройстве + // На Android используем "*.gz" для MIME типа application/gzip + // На Desktop используем "Backup files (*.tar.gz *.backup)" + var filter = GC.isMobile() ? "*.gz" : "Backup files (*.tar.gz *.backup)" + var localPath = SystemController.getFileName( + qsTr("Select Backup to Restore"), + filter, + "", + false, // openMode + "" + ) + + if (!localPath || localPath.length === 0) { + return // Пользователь отменил + } + + selectedBackupForRestore = localPath + + // Шаг 2: Подтверждение восстановления + var headerText = qsTr("Restore server configuration?") + var descriptionText = qsTr("Selected backup will be uploaded to server and restored. Current configuration will be overwritten. All containers will be restarted.") + var yesButtonText = qsTr("Restore") + var noButtonText = qsTr("Cancel") + + var yesButtonFunction = function() { + PageController.showBusyIndicator(true) + + var credentials = getServerCredentials() + + // Загрузить backup на сервер + ServersBackupController.uploadBackup(credentials, selectedBackupForRestore) + } + var noButtonFunction = function() { + selectedBackupForRestore = "" + } + + showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction) + } + + function manageBackups() { + PageController.showNotificationMessage(qsTr("Backup management page will be available in the next update")) + } + + // ============ Backup Controller Connections ============ + + property string lastCreatedBackupFilename: "" + property string lastUploadedBackupFilename: "" + + Connections { + target: ServersBackupController + + function onBackupCreated(backupFilename) { + lastCreatedBackupFilename = backupFilename + + if (downloadAfterCreate) { + // Скачать backup на устройство + var credentials = getServerCredentials() + + // Автоматически скачиваем: + var localPath = backupFilename + PageController.showNotificationMessage(qsTr("Backup created. Downloading to device...")) + ServersBackupController.downloadBackup(credentials, backupFilename, localPath) + + downloadAfterCreate = false + } else { + PageController.showBusyIndicator(false) + PageController.showNotificationMessage(qsTr("Backup created successfully: %1").arg(backupFilename)) + } + } + + function onBackupDownloaded(localPath) { + PageController.showBusyIndicator(false) + console.log("Backup downloaded to:", localPath) + + // Удалить backup с сервера после успешного скачивания + if (lastCreatedBackupFilename && lastCreatedBackupFilename.length > 0) { + var credentials = getServerCredentials() + ServersBackupController.deleteBackup(credentials, lastCreatedBackupFilename) + console.log("Deleting backup from server:", lastCreatedBackupFilename) + } + + PageController.showNotificationMessage(qsTr("Backup downloaded successfully!\n\nSaved to:\n%1").arg(localPath)) + } + + function onBackupUploaded(serverPath) { + // Backup загружен на сервер, теперь восстановить + PageController.showNotificationMessage(qsTr("Backup uploaded. Restoring configuration...")) + + // Извлечь имя файла из пути и сохранить для последующего удаления + var backupFilename = serverPath.split('/').pop() + lastUploadedBackupFilename = backupFilename + + var credentials = getServerCredentials() + ServersBackupController.restoreBackup(credentials, backupFilename) + } + + function onBackupRestored() { + PageController.showBusyIndicator(false) + + // Удалить backup с сервера после успешного восстановления + /* if (lastUploadedBackupFilename && lastUploadedBackupFilename.length > 0) { + var credentials = getServerCredentials() + ServersBackupController.deleteBackup(credentials, lastUploadedBackupFilename) + console.log("Deleting backup from server after restore:", lastUploadedBackupFilename) + lastUploadedBackupFilename = "" + }*/ + + selectedBackupForRestore = "" + PageController.showNotificationMessage(qsTr("Backup restored successfully! Containers are restarting...")) + } + + function onProgressChanged(percent, message) { + // TODO: Show progress in UI + console.log("Backup progress:", percent, "%", message) + } + + function onErrorOccurred(errorMessage, errorCode) { + PageController.showBusyIndicator(false) + PageController.showErrorMessage(qsTr("Backup error: %1").arg(errorMessage)) + } + } }