mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-10-31 17:17:59 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf2ea2c3a | ||
|
|
051754090b | ||
|
|
80d3123df0 | ||
|
|
3a1b8895e8 | ||
|
|
fa724e3fb1 | ||
|
|
f95807ae6a | ||
|
|
36cced7784 | ||
|
|
d361550691 | ||
|
|
5b92bbd552 | ||
|
|
0f147c9df5 | ||
|
|
0eb32012d9 | ||
|
|
58e05b62bf | ||
|
|
8873c5e3dd | ||
|
|
3df9c70115 | ||
|
|
e2bb3f5982 | ||
|
|
13abc9cb7a | ||
|
|
8ed098bf9a | ||
|
|
39bac7b99d | ||
|
|
adbca78aa3 |
No files matched your search
@@ -128,12 +128,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// androiddeployqt expects:
|
||||
// APK: build/outputs/apk/{base}-{buildType}[-unsigned].apk (no flavor subdir)
|
||||
// AAB: build/outputs/bundle/{buildType}/{base}-{buildType}.aab (no flavor subdir)
|
||||
// androiddeployqt and Qt Creator look for the package in the build type dir, without flavor:
|
||||
// APK: build/outputs/apk/{buildType}/{base}-{buildType}[-unsigned].apk
|
||||
// AAB: build/outputs/bundle/{buildType}/{base}-{buildType}.aab
|
||||
// where {base} = outputBaseName (set by Qt Creator) or "android-build" (CI fallback).
|
||||
// Release APK gets -unsigned suffix (Qt cmake signs it); debug does not.
|
||||
// Copy only oss flavor to the flat output dir that androiddeployqt/Qt Creator expect.
|
||||
// Gradle writes flavor outputs to {flavor}/{buildType}, so copy the oss flavor over.
|
||||
// Play flavor is built via android_play_apk/android_play_aab cmake targets and uses
|
||||
// its native Gradle output paths directly.
|
||||
applicationVariants.all {
|
||||
@@ -146,7 +146,7 @@ android {
|
||||
packageApplicationProvider.configure {
|
||||
doLast {
|
||||
val srcDir = layout.buildDirectory.dir("outputs/apk/oss/$buildTypeName").get().asFile
|
||||
val dstDir = layout.buildDirectory.dir("outputs/apk").get().asFile
|
||||
val dstDir = layout.buildDirectory.dir("outputs/apk/$buildTypeName").get().asFile
|
||||
dstDir.mkdirs()
|
||||
srcDir.listFiles()?.filter { it.name.endsWith(".apk") }?.forEach { apk ->
|
||||
apk.copyTo(File(dstDir, "$base-$buildTypeName$unsignedSuffix.apk"), overwrite = true)
|
||||
|
||||
@@ -25,6 +25,7 @@ set(HEADERS ${HEADERS}
|
||||
${CLIENT_ROOT_DIR}/core/controllers/serversController.h
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/usersController.h
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/installController.h
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/backupController.h
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/exportController.h
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/importController.h
|
||||
${CLIENT_ROOT_DIR}/core/installers/installerBase.h
|
||||
@@ -115,6 +116,7 @@ set(SOURCES ${SOURCES}
|
||||
${CLIENT_ROOT_DIR}/core/controllers/serversController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/usersController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/installController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/backupController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/exportController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/controllers/selfhosted/importController.cpp
|
||||
${CLIENT_ROOT_DIR}/core/installers/installerBase.cpp
|
||||
|
||||
@@ -175,6 +175,7 @@ void CoreController::initCoreControllers()
|
||||
m_updateController = new UpdateController(m_appSettingsRepository, this);
|
||||
|
||||
m_installController = new InstallController(m_serversRepository, m_appSettingsRepository, this);
|
||||
m_backupController = new BackupController(m_serversController, this);
|
||||
m_exportController = new ExportController(m_serversRepository, m_appSettingsRepository, this);
|
||||
m_importCoreController = new ImportController(m_serversRepository, m_appSettingsRepository, this);
|
||||
m_connectionController = new ConnectionController(m_serversRepository, m_appSettingsRepository, m_vpnConnection.get(), this);
|
||||
@@ -228,6 +229,9 @@ void CoreController::initControllers()
|
||||
m_ipSplitTunnelingUiController = new IpSplitTunnelingUiController(m_ipSplitTunnelingController, m_ipSplitTunnelingModel, this);
|
||||
setQmlContextProperty("IpSplitTunnelingController", m_ipSplitTunnelingUiController);
|
||||
|
||||
m_backupUiController = new BackupUiController(m_backupController, m_serversController, m_serversUiController, this);
|
||||
setQmlContextProperty("BackupController", m_backupUiController);
|
||||
|
||||
m_allowedDnsUiController = new AllowedDnsUiController(m_allowedDnsController, m_allowedDnsModel, this);
|
||||
setQmlContextProperty("AllowedDnsController", m_allowedDnsUiController);
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "ui/controllers/logsUiController.h"
|
||||
#include "ui/controllers/serversUiController.h"
|
||||
#include "ui/controllers/ipSplitTunnelingUiController.h"
|
||||
#include "ui/controllers/selfhosted/backupUiController.h"
|
||||
#include "ui/controllers/systemController.h"
|
||||
#include "ui/controllers/languageUiController.h"
|
||||
#include "ui/controllers/updateUiController.h"
|
||||
@@ -44,6 +45,7 @@
|
||||
#include "core/controllers/api/subscriptionController.h"
|
||||
#include "core/controllers/api/newsController.h"
|
||||
#include "core/controllers/selfhosted/installController.h"
|
||||
#include "core/controllers/selfhosted/backupController.h"
|
||||
#include "core/controllers/settingsController.h"
|
||||
#include "core/controllers/localProxyController.h"
|
||||
#include "core/controllers/connectionController.h"
|
||||
@@ -187,6 +189,7 @@ private:
|
||||
AllowedDnsUiController* m_allowedDnsUiController;
|
||||
LanguageUiController* m_languageUiController;
|
||||
UpdateUiController* m_updateUiController;
|
||||
BackupUiController* m_backupUiController;
|
||||
|
||||
SubscriptionUiController* m_subscriptionUiController;
|
||||
ApiNewsUiController* m_apiNewsUiController;
|
||||
@@ -205,6 +208,7 @@ private:
|
||||
UpdateController* m_updateController;
|
||||
InstallController* m_installController;
|
||||
ExportController* m_exportController;
|
||||
BackupController* m_backupController;
|
||||
ConnectionController* m_connectionController;
|
||||
SettingsController* m_settingsController;
|
||||
LocalProxyController* m_localProxyController;
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
#include "backupController.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#if !defined(Q_OS_IOS)
|
||||
#include <QProcess>
|
||||
#endif
|
||||
#ifdef Q_OS_ANDROID
|
||||
#include <QJniObject>
|
||||
#include "platforms/android/android_controller.h"
|
||||
#endif
|
||||
|
||||
#include "core/controllers/serversController.h"
|
||||
#include "core/utils/containers/containerUtils.h"
|
||||
#include "core/utils/errorStrings.h"
|
||||
#include "core/utils/networkUtilities.h"
|
||||
#include "core/utils/utilities.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Backup file name produced by the scripts: "IP_ADDRESS - DD-MM-YYYY_HH-MM-SS.tgz"
|
||||
const char *kBackupNamePattern = "([\\d_]+)\\s+-\\s+(\\d{2}-\\d{2}-\\d{4})_(\\d{2}-\\d{2}-\\d{2})\\.tgz";
|
||||
}
|
||||
|
||||
BackupController::BackupController(ServersController *serversController, QObject *parent)
|
||||
: QObject(parent), m_serversController(serversController), m_backupDir("/var/backups/amnezia")
|
||||
{
|
||||
}
|
||||
|
||||
void BackupController::setBackupDirectory(const QString &directory)
|
||||
{
|
||||
m_backupDir = directory;
|
||||
}
|
||||
|
||||
QString BackupController::backupDirectory() const
|
||||
{
|
||||
return m_backupDir;
|
||||
}
|
||||
|
||||
QString BackupController::lastError() const
|
||||
{
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
void BackupController::createAndDownloadBackup(const ServerCredentials &credentials, bool downloadToDevice,
|
||||
bool deleteFromServer)
|
||||
{
|
||||
QString backupFilename;
|
||||
if (createBackup(credentials, backupFilename) != ErrorCode::NoError) {
|
||||
emit errorOccurred(tr("Failed to create backup: %1").arg(m_lastError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!downloadToDevice) {
|
||||
emit backupCreated(backupFilename);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString localPath = defaultDownloadPath(backupFilename);
|
||||
if (downloadBackup(credentials, backupFilename, localPath) != ErrorCode::NoError) {
|
||||
emit errorOccurred(tr("Failed to download backup: %1").arg(m_lastError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteFromServer && deleteBackup(credentials, backupFilename) != ErrorCode::NoError) {
|
||||
qWarning() << "Failed to delete backup from server:" << m_lastError;
|
||||
}
|
||||
|
||||
emit backupDownloaded(localPath);
|
||||
}
|
||||
|
||||
void BackupController::uploadAndRestore(const ServerCredentials &credentials, const QString &localPath, bool replaceMode)
|
||||
{
|
||||
QString remoteFilename;
|
||||
if (uploadBackup(credentials, localPath, remoteFilename) != ErrorCode::NoError) {
|
||||
emit errorOccurred(tr("Failed to upload backup: %1").arg(m_lastError));
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoreBackup(credentials, remoteFilename, replaceMode) != ErrorCode::NoError) {
|
||||
emit errorOccurred(tr("Failed to restore backup: %1").arg(m_lastError));
|
||||
return;
|
||||
}
|
||||
|
||||
emit backupRestored();
|
||||
}
|
||||
|
||||
void BackupController::setDefaultServerAfterRestore()
|
||||
{
|
||||
const int serversCount = m_serversController->getServersCount();
|
||||
if (serversCount == 0) {
|
||||
emit defaultServerAndContainerSet();
|
||||
return;
|
||||
}
|
||||
|
||||
m_serversController->setDefaultServer(m_serversController->getServerId(serversCount - 1));
|
||||
|
||||
m_containerRetryCount = 0;
|
||||
QTimer::singleShot(500, this, &BackupController::trySetDefaultContainer);
|
||||
}
|
||||
|
||||
void BackupController::trySetDefaultContainer()
|
||||
{
|
||||
const QString serverId = m_serversController->getServerId(m_serversController->getServersCount() - 1);
|
||||
const auto containers = m_serversController->getServerContainersMap(serverId);
|
||||
|
||||
for (auto it = containers.cbegin(); it != containers.cend(); ++it) {
|
||||
if (it.key() != DockerContainer::None) {
|
||||
m_serversController->setDefaultContainer(serverId, it.key());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!containers.isEmpty()) {
|
||||
emit defaultServerAndContainerSet();
|
||||
return;
|
||||
}
|
||||
|
||||
// The containers map is filled asynchronously after restore, poll a few times before giving up
|
||||
m_containerRetryCount++;
|
||||
if (m_containerRetryCount < m_maxContainerRetries) {
|
||||
QTimer::singleShot(500, this, &BackupController::trySetDefaultContainer);
|
||||
} else {
|
||||
m_containerRetryCount = 0;
|
||||
emit defaultServerAndContainerSet();
|
||||
}
|
||||
}
|
||||
|
||||
ErrorCode BackupController::createBackup(const ServerCredentials &credentials, QString &backupFilename)
|
||||
{
|
||||
m_sshSession.resetConnection();
|
||||
emit progressChanged(0, tr("Starting backup creation..."));
|
||||
return runBackupScript(credentials, backupScript(ipForFilename(credentials.hostName)), backupFilename);
|
||||
}
|
||||
|
||||
ErrorCode BackupController::createContainersBackup(const ServerCredentials &credentials,
|
||||
const QList<DockerContainer> &containers, QString &backupFilename)
|
||||
{
|
||||
m_sshSession.resetConnection();
|
||||
emit progressChanged(0, tr("Starting backup for %1 containers...").arg(containers.size()));
|
||||
return runBackupScript(credentials, containersBackupScript(containers, ipForFilename(credentials.hostName)),
|
||||
backupFilename);
|
||||
}
|
||||
|
||||
ErrorCode BackupController::runBackupScript(const ServerCredentials &credentials, const QString &script,
|
||||
QString &backupFilename)
|
||||
{
|
||||
ErrorCode error = runHostScript(credentials, script);
|
||||
if (error != ErrorCode::NoError) {
|
||||
m_lastError = m_error.isEmpty() ? errorString(error) : m_error;
|
||||
return error;
|
||||
}
|
||||
|
||||
QRegularExpressionMatch match = QRegularExpression(kBackupNamePattern).match(m_output);
|
||||
if (!match.hasMatch()) {
|
||||
m_lastError = tr("Failed to parse backup filename from output: %1").arg(m_output.left(200));
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
backupFilename = match.captured(1) + " - " + match.captured(2) + "_" + match.captured(3) + ".tgz";
|
||||
emit progressChanged(100, tr("Backup created successfully"));
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode BackupController::restoreBackup(const ServerCredentials &credentials, const QString &backupFilename,
|
||||
bool replaceMode)
|
||||
{
|
||||
emit progressChanged(0, tr("Starting restore from %1...").arg(backupFilename));
|
||||
|
||||
ErrorCode error = runHostScript(credentials, restoreScript(backupFilename, replaceMode));
|
||||
|
||||
// The script may exit with 0 and still report a failure, so inspect its output too
|
||||
const bool hasError = m_output.contains("[ERROR]") || m_error.contains("[ERROR]")
|
||||
|| m_output.contains("Failed to extract backup") || m_error.contains("Failed to extract backup");
|
||||
if (error != ErrorCode::NoError || hasError) {
|
||||
m_lastError = hasError ? m_output + "\n" + m_error : (m_error.isEmpty() ? errorString(error) : m_error);
|
||||
return error != ErrorCode::NoError ? error : ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
if (!m_output.contains("Restore completed successfully")) {
|
||||
m_lastError = tr("Backup restore did not complete successfully. Output: %1").arg(m_output);
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
emit progressChanged(100, tr("Backup restored successfully"));
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode BackupController::downloadBackup(const ServerCredentials &credentials, const QString &backupFilename,
|
||||
const QString &localPath)
|
||||
{
|
||||
if (backupFilename.isEmpty()) {
|
||||
m_lastError = tr("Backup filename is empty");
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
emit progressChanged(0, tr("Downloading backup..."));
|
||||
|
||||
QDir localDir = QFileInfo(localPath).dir();
|
||||
if (!localDir.exists() && !localDir.mkpath(".")) {
|
||||
m_lastError = tr("Failed to create local directory: %1").arg(localDir.path());
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
libssh::Client sshClient;
|
||||
ErrorCode error = sshClient.connectToHost(credentials);
|
||||
if (error == ErrorCode::NoError) {
|
||||
error = sshClient.scpFileDownload(QString("%1/%2").arg(m_backupDir, backupFilename), localPath);
|
||||
sshClient.disconnectFromHost();
|
||||
}
|
||||
|
||||
if (error != ErrorCode::NoError) {
|
||||
m_lastError = errorString(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
emit progressChanged(100, tr("Backup downloaded successfully"));
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode BackupController::uploadBackup(const ServerCredentials &credentials, const QString &localPath,
|
||||
QString &remoteFilename)
|
||||
{
|
||||
m_sshSession.resetConnection();
|
||||
|
||||
QByteArray data;
|
||||
ErrorCode error = readLocalFile(localPath, data);
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
QString filename = fileNameFromPath(localPath);
|
||||
if (filename.isEmpty()) {
|
||||
filename = "backup.tgz";
|
||||
}
|
||||
// libssh passes the scp path to the remote shell verbatim, spaces would split the argument
|
||||
filename.replace(' ', '_');
|
||||
|
||||
emit progressChanged(0, tr("Uploading backup..."));
|
||||
|
||||
// The backup directory is root-owned, so upload to /tmp first and move with sudo
|
||||
const QString tmpRemotePath = QString("/tmp/amnezia_restore_%1.tgz").arg(Utils::getRandomString(8));
|
||||
const QString remotePath = QString("%1/%2").arg(m_backupDir, filename);
|
||||
|
||||
error = m_sshSession.uploadFileToHost(credentials, data, tmpRemotePath);
|
||||
if (error != ErrorCode::NoError) {
|
||||
m_lastError = errorString(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
error = runHostScript(credentials,
|
||||
QString("mkdir -p \"%1\" && mv \"%2\" \"%3\"").arg(m_backupDir, tmpRemotePath, remotePath));
|
||||
if (error != ErrorCode::NoError) {
|
||||
const QString moveError = m_error;
|
||||
runHostScript(credentials, QString("rm -f \"%1\"").arg(tmpRemotePath));
|
||||
m_lastError = moveError.isEmpty() ? errorString(error) : moveError;
|
||||
return error;
|
||||
}
|
||||
|
||||
remoteFilename = filename;
|
||||
emit progressChanged(100, tr("Backup uploaded successfully"));
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
ErrorCode BackupController::deleteBackup(const ServerCredentials &credentials, const QString &backupFilename)
|
||||
{
|
||||
emit progressChanged(0, tr("Deleting backup..."));
|
||||
|
||||
QString escapedFilename = backupFilename;
|
||||
escapedFilename.replace("'", "'\\''");
|
||||
|
||||
ErrorCode error = runHostScript(credentials, QString("sudo rm -f '%1/%2'").arg(m_backupDir, escapedFilename));
|
||||
if (error != ErrorCode::NoError) {
|
||||
m_lastError = m_error.isEmpty() ? errorString(error) : m_error;
|
||||
return error;
|
||||
}
|
||||
|
||||
emit progressChanged(100, tr("Backup deleted"));
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
QStringList BackupController::scanBackupForContainers(const QString &localPath)
|
||||
{
|
||||
QStringList containers;
|
||||
|
||||
#ifdef Q_OS_IOS
|
||||
Q_UNUSED(localPath);
|
||||
// The iOS sandbox cannot spawn tar; the server-side restore only touches what is in the archive anyway
|
||||
containers << QStringLiteral("amnezia-awg2") << QStringLiteral("amnezia-wireguard") << QStringLiteral("amnezia-xray")
|
||||
<< QStringLiteral("amnezia-openvpn-cloak") << QStringLiteral("amnezia-awg");
|
||||
return containers;
|
||||
#else
|
||||
QString actualPath = localPath;
|
||||
|
||||
#ifdef Q_OS_ANDROID
|
||||
if (localPath.startsWith("content://")) {
|
||||
QByteArray data;
|
||||
if (readLocalFile(localPath, data) != ErrorCode::NoError) {
|
||||
return containers;
|
||||
}
|
||||
|
||||
actualPath = QDir::temp().filePath("backup_scan_temp.tgz");
|
||||
QFile tempFile(actualPath);
|
||||
if (!tempFile.open(QIODevice::WriteOnly)) {
|
||||
qWarning() << "Failed to create temp file for scanning";
|
||||
return containers;
|
||||
}
|
||||
tempFile.write(data);
|
||||
tempFile.close();
|
||||
}
|
||||
#endif
|
||||
|
||||
QProcess process;
|
||||
process.start("tar", QStringList() << "-tzf" << actualPath);
|
||||
process.waitForFinished(5000);
|
||||
|
||||
if (process.exitCode() != 0) {
|
||||
qWarning() << "Failed to read backup archive:" << process.readAllStandardError();
|
||||
return containers;
|
||||
}
|
||||
|
||||
QSet<QString> foundContainers;
|
||||
const QStringList lines = QString(process.readAllStandardOutput()).split('\n', Qt::SkipEmptyParts);
|
||||
for (const QString &line : lines) {
|
||||
const QStringList parts = line.split('/');
|
||||
for (const QString &part : parts) {
|
||||
if (part.startsWith("amnezia-")) {
|
||||
foundContainers.insert(part);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
containers = foundContainers.values();
|
||||
qDebug() << "Found containers in backup:" << containers;
|
||||
return containers;
|
||||
#endif
|
||||
}
|
||||
|
||||
BackupController::BackupFileInfo BackupController::backupFileInfo(const QString &backupFilePath)
|
||||
{
|
||||
BackupFileInfo info;
|
||||
info.fileName = fileNameFromPath(backupFilePath);
|
||||
if (info.fileName.isEmpty()) {
|
||||
info.fileName = "backup.tgz";
|
||||
}
|
||||
|
||||
QRegularExpressionMatch match = QRegularExpression("^([\\d_]+)\\s*-").match(info.fileName);
|
||||
if (match.hasMatch()) {
|
||||
info.serverIp = match.captured(1).replace('_', '.');
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
QString BackupController::defaultDownloadPath(const QString &backupFilename)
|
||||
{
|
||||
#ifdef Q_OS_ANDROID
|
||||
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());
|
||||
const QString downloadsPath = mediaDir.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;").toString();
|
||||
return QDir(downloadsPath).filePath(backupFilename);
|
||||
#elif defined(Q_OS_IOS)
|
||||
return QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath(backupFilename);
|
||||
#else
|
||||
QString documentsPath = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
|
||||
if (documentsPath.isEmpty()) {
|
||||
documentsPath = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
||||
}
|
||||
return QDir(documentsPath).filePath(backupFilename);
|
||||
#endif
|
||||
}
|
||||
|
||||
ErrorCode BackupController::runHostScript(const ServerCredentials &credentials, const QString &script)
|
||||
{
|
||||
m_output.clear();
|
||||
m_error.clear();
|
||||
|
||||
const QString fileName = "/tmp/amnezia_" + Utils::getRandomString(16) + ".sh";
|
||||
ErrorCode error = m_sshSession.uploadFileToHost(credentials, script.toUtf8(), fileName);
|
||||
if (error != ErrorCode::NoError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
auto onStdOut = [this](const QString &data, libssh::Client &) -> ErrorCode {
|
||||
m_output += data;
|
||||
qDebug().noquote() << "[BACKUP]" << data;
|
||||
|
||||
if (data.contains("Starting backup")) {
|
||||
emit progressChanged(10, tr("Starting backup..."));
|
||||
} else if (data.contains("Backing up")) {
|
||||
emit progressChanged(50, tr("Backing up containers..."));
|
||||
} else if (data.contains("Backup created")) {
|
||||
emit progressChanged(90, tr("Finalizing..."));
|
||||
} else if (data.contains("Starting restore")) {
|
||||
emit progressChanged(10, tr("Starting restore..."));
|
||||
} else if (data.contains("Restoring")) {
|
||||
emit progressChanged(50, tr("Restoring containers..."));
|
||||
} else if (data.contains("Restore completed successfully")) {
|
||||
emit progressChanged(90, tr("Finalizing restore..."));
|
||||
}
|
||||
return ErrorCode::NoError;
|
||||
};
|
||||
|
||||
auto onStdErr = [this](const QString &data, libssh::Client &) -> ErrorCode {
|
||||
m_error += data;
|
||||
qDebug().noquote() << "[BACKUP ERROR]" << data;
|
||||
return ErrorCode::NoError;
|
||||
};
|
||||
|
||||
error = m_sshSession.runScript(credentials, QString("sudo bash %1").arg(fileName), onStdOut, onStdErr);
|
||||
m_sshSession.runScript(credentials, QString("sudo rm -f %1").arg(fileName));
|
||||
return error;
|
||||
}
|
||||
|
||||
ErrorCode BackupController::readLocalFile(const QString &localPath, QByteArray &data)
|
||||
{
|
||||
#ifdef Q_OS_ANDROID
|
||||
if (localPath.startsWith("content://")) {
|
||||
const int fd = AndroidController::instance()->getFd(localPath);
|
||||
if (fd < 0) {
|
||||
m_lastError = tr("Failed to read backup file from Android storage");
|
||||
return ErrorCode::ReadError;
|
||||
}
|
||||
|
||||
QFile file;
|
||||
const bool opened = file.open(fd, QIODevice::ReadOnly);
|
||||
if (opened) {
|
||||
data = file.readAll();
|
||||
}
|
||||
AndroidController::instance()->closeFd();
|
||||
|
||||
if (!opened) {
|
||||
m_lastError = tr("Failed to read backup file from Android storage");
|
||||
return ErrorCode::ReadError;
|
||||
}
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
#endif
|
||||
|
||||
QFileInfo fileInfo(localPath);
|
||||
if (!fileInfo.exists() || !fileInfo.isFile()) {
|
||||
m_lastError = tr("Local file does not exist: %1").arg(localPath);
|
||||
return ErrorCode::InternalError;
|
||||
}
|
||||
|
||||
QFile file(localPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
m_lastError = tr("Failed to read backup file: %1").arg(localPath);
|
||||
return ErrorCode::ReadError;
|
||||
}
|
||||
data = file.readAll();
|
||||
return ErrorCode::NoError;
|
||||
}
|
||||
|
||||
QString BackupController::fileNameFromPath(const QString &path)
|
||||
{
|
||||
QString fileName;
|
||||
|
||||
#ifdef Q_OS_ANDROID
|
||||
if (path.startsWith("content://")) {
|
||||
fileName = AndroidController::instance()->getFileName(path);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (fileName.isEmpty()) {
|
||||
fileName = QFileInfo(path).fileName();
|
||||
}
|
||||
|
||||
if (fileName.isEmpty()) {
|
||||
const QString localPath = path.startsWith("file://") ? QUrl(path).toLocalFile() : path;
|
||||
const QStringList parts = localPath.split('/');
|
||||
if (!parts.isEmpty()) {
|
||||
fileName = QUrl::fromPercentEncoding(parts.last().toUtf8());
|
||||
}
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
QString BackupController::ipForFilename(const QString &hostName)
|
||||
{
|
||||
QString serverIp = NetworkUtilities::getIPAddress(hostName);
|
||||
if (serverIp.isEmpty()) {
|
||||
serverIp = hostName;
|
||||
}
|
||||
return serverIp.replace(".", "_");
|
||||
}
|
||||
|
||||
QString BackupController::backupScript(const QString &ipAddress) const
|
||||
{
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
IP_ADDRESS=%2
|
||||
DATE=$(date +%d-%m-%Y)
|
||||
TIME=$(date +%H-%M-%S)
|
||||
BACKUP_FILENAME="$IP_ADDRESS - ${DATE}_${TIME}.tgz"
|
||||
BACKUP_SUBDIR="$BACKUP_DIR/backup_temp_$$"
|
||||
|
||||
echo "[INFO] Starting backup..."
|
||||
|
||||
mkdir -p "$BACKUP_SUBDIR"
|
||||
|
||||
CONTAINERS=(
|
||||
"amnezia-awg"
|
||||
"amnezia-awg2"
|
||||
"amnezia-openvpn"
|
||||
"amnezia-xray"
|
||||
"amnezia-wireguard"
|
||||
"amnezia-ipsec"
|
||||
"amnezia-cloak"
|
||||
"amnezia-shadowsocks"
|
||||
)
|
||||
|
||||
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"
|
||||
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_FILENAME" -C "$BACKUP_SUBDIR" . 2>/dev/null
|
||||
rm -rf "$BACKUP_SUBDIR"
|
||||
|
||||
echo "[INFO] Backup created: $BACKUP_FILENAME"
|
||||
)")
|
||||
.arg(m_backupDir, ipAddress);
|
||||
}
|
||||
|
||||
QString BackupController::containersBackupScript(const QList<DockerContainer> &containers, const QString &ipAddress) const
|
||||
{
|
||||
QStringList names;
|
||||
for (const DockerContainer &container : containers) {
|
||||
names << QString("\"%1\"").arg(ContainerUtils::containerToString(container));
|
||||
}
|
||||
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
IP_ADDRESS=%2
|
||||
DATE=$(date +%d-%m-%Y)
|
||||
TIME=$(date +%H-%M-%S)
|
||||
BACKUP_FILENAME="$IP_ADDRESS - ${DATE}_${TIME}.tgz"
|
||||
BACKUP_SUBDIR="$BACKUP_DIR/backup_temp_$$"
|
||||
|
||||
echo "[INFO] Starting backup for containers..."
|
||||
|
||||
mkdir -p "$BACKUP_SUBDIR"
|
||||
|
||||
CONTAINERS=(%3)
|
||||
|
||||
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"
|
||||
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_FILENAME" -C "$BACKUP_SUBDIR" . 2>/dev/null
|
||||
rm -rf "$BACKUP_SUBDIR"
|
||||
|
||||
echo "[INFO] Backup created: $BACKUP_FILENAME"
|
||||
)")
|
||||
.arg(m_backupDir, ipAddress, names.join(' '));
|
||||
}
|
||||
|
||||
QString BackupController::restoreScript(const QString &backupFilename, bool replaceMode) const
|
||||
{
|
||||
QString escapedFilename = backupFilename;
|
||||
escapedFilename.replace("'", "'\\''");
|
||||
|
||||
return QString(R"(
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
BACKUP_DIR=%1
|
||||
BACKUP_FILE='%2'
|
||||
REPLACE_MODE=%3
|
||||
TEMP_DIR="/tmp/amnezia_restore_$$"
|
||||
|
||||
echo "[INFO] Starting restore from $BACKUP_FILE..."
|
||||
if [ "$REPLACE_MODE" = "1" ]; then
|
||||
echo "[INFO] Using replace mode: containers will be cleared before restore"
|
||||
else
|
||||
echo "[INFO] Using add mode: data will be added to existing containers"
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
echo "[ERROR] Backup file not found: $BACKUP_DIR/$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TEMP_DIR"
|
||||
EXTRACT_OUTPUT=$(tar -xzf "$BACKUP_DIR/$BACKUP_FILE" -C "$TEMP_DIR" 2>&1)
|
||||
EXTRACT_EXIT_CODE=$?
|
||||
if [ $EXTRACT_EXIT_CODE -ne 0 ]; then
|
||||
echo "[ERROR] Failed to extract backup archive: $EXTRACT_OUTPUT"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Archive root is either backup_temp_* or the container directories themselves
|
||||
BACKUP_SUBDIR=$(ls -d "$TEMP_DIR"/backup_* 2>/dev/null | head -1)
|
||||
if [ -z "$BACKUP_SUBDIR" ]; then
|
||||
if ls -d "$TEMP_DIR"/amnezia-* 2>/dev/null | head -1 > /dev/null; then
|
||||
BACKUP_SUBDIR="$TEMP_DIR"
|
||||
else
|
||||
echo "[ERROR] Failed to extract backup: backup directory not found in archive"
|
||||
ls -la "$TEMP_DIR" || true
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
if [ "$REPLACE_MODE" = "1" ]; then
|
||||
echo "[INFO] Clearing container $container_name before restore..."
|
||||
TEMP_CLEAR_DIR="/tmp/clear_amnezia_$$"
|
||||
mkdir -p "$TEMP_CLEAR_DIR/amnezia"
|
||||
sudo docker cp "$TEMP_CLEAR_DIR/amnezia" "$container_name:/opt/" 2>/dev/null || true
|
||||
rm -rf "$TEMP_CLEAR_DIR"
|
||||
fi
|
||||
|
||||
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, escapedFilename, replaceMode ? "1" : "0");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#ifndef BACKUPCONTROLLER_H
|
||||
#define BACKUPCONTROLLER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include "core/utils/commonStructs.h"
|
||||
#include "core/utils/containerEnum.h"
|
||||
#include "core/utils/errorCodes.h"
|
||||
#include "core/utils/selfhosted/sshSession.h"
|
||||
|
||||
class ServersController;
|
||||
|
||||
using namespace amnezia;
|
||||
|
||||
class BackupController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
struct BackupFileInfo
|
||||
{
|
||||
QString fileName;
|
||||
QString serverIp;
|
||||
};
|
||||
|
||||
explicit BackupController(ServersController *serversController, QObject *parent = nullptr);
|
||||
|
||||
void setBackupDirectory(const QString &directory);
|
||||
QString backupDirectory() const;
|
||||
|
||||
void createAndDownloadBackup(const ServerCredentials &credentials, bool downloadToDevice, bool deleteFromServer);
|
||||
void uploadAndRestore(const ServerCredentials &credentials, const QString &localPath, bool replaceMode);
|
||||
void setDefaultServerAfterRestore();
|
||||
|
||||
ErrorCode createBackup(const ServerCredentials &credentials, QString &backupFilename);
|
||||
ErrorCode createContainersBackup(const ServerCredentials &credentials, const QList<DockerContainer> &containers,
|
||||
QString &backupFilename);
|
||||
ErrorCode restoreBackup(const ServerCredentials &credentials, const QString &backupFilename, bool replaceMode);
|
||||
ErrorCode downloadBackup(const ServerCredentials &credentials, const QString &backupFilename, const QString &localPath);
|
||||
ErrorCode uploadBackup(const ServerCredentials &credentials, const QString &localPath, QString &remoteFilename);
|
||||
ErrorCode deleteBackup(const ServerCredentials &credentials, const QString &backupFilename);
|
||||
|
||||
QStringList scanBackupForContainers(const QString &localPath);
|
||||
|
||||
static BackupFileInfo backupFileInfo(const QString &backupFilePath);
|
||||
static QString defaultDownloadPath(const QString &backupFilename);
|
||||
|
||||
QString lastError() const;
|
||||
|
||||
signals:
|
||||
void backupCreated(const QString &backupFilename);
|
||||
void backupDownloaded(const QString &localPath);
|
||||
void backupRestored();
|
||||
void defaultServerAndContainerSet();
|
||||
void progressChanged(int percent, const QString &message);
|
||||
void errorOccurred(const QString &errorMessage);
|
||||
|
||||
private:
|
||||
void trySetDefaultContainer();
|
||||
|
||||
ErrorCode runBackupScript(const ServerCredentials &credentials, const QString &script, QString &backupFilename);
|
||||
ErrorCode runHostScript(const ServerCredentials &credentials, const QString &script);
|
||||
ErrorCode readLocalFile(const QString &localPath, QByteArray &data);
|
||||
|
||||
static QString fileNameFromPath(const QString &path);
|
||||
static QString ipForFilename(const QString &hostName);
|
||||
|
||||
QString backupScript(const QString &ipAddress) const;
|
||||
QString containersBackupScript(const QList<DockerContainer> &containers, const QString &ipAddress) const;
|
||||
QString restoreScript(const QString &backupFilename, bool replaceMode) const;
|
||||
|
||||
ServersController *m_serversController;
|
||||
SshSession m_sshSession;
|
||||
QString m_backupDir;
|
||||
QString m_output;
|
||||
QString m_error;
|
||||
QString m_lastError;
|
||||
|
||||
int m_containerRetryCount = 0;
|
||||
static constexpr int m_maxContainerRetries = 3;
|
||||
};
|
||||
|
||||
#endif // BACKUPCONTROLLER_H
|
||||
@@ -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<ErrorCode> watcher;
|
||||
connect(&watcher, &QFutureWatcher<ErrorCode>::finished, this, &Client::scpFileDownloadFinished);
|
||||
QFuture<ErrorCode> 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<size_t>(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) {
|
||||
|
||||
@@ -38,6 +38,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<QString()> &passphraseCallback);
|
||||
private:
|
||||
ErrorCode closeChannel();
|
||||
@@ -54,6 +56,7 @@ namespace libssh {
|
||||
signals:
|
||||
void writeToChannelFinished();
|
||||
void scpFileCopyFinished();
|
||||
void scpFileDownloadFinished();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ SshSession::~SshSession()
|
||||
m_sshClient.disconnectFromHost();
|
||||
}
|
||||
|
||||
void SshSession::resetConnection()
|
||||
{
|
||||
m_sshClient.disconnectFromHost();
|
||||
}
|
||||
|
||||
ErrorCode SshSession::runScript(const ServerCredentials &credentials, QString script,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdOut,
|
||||
const std::function<ErrorCode(const QString &, libssh::Client &)> &cbReadStdErr)
|
||||
|
||||
@@ -47,6 +47,9 @@ public:
|
||||
ErrorCode uploadFileToHost(const ServerCredentials &credentials, const QByteArray &data, const QString &remotePath,
|
||||
libssh::ScpOverwriteMode overwriteMode = libssh::ScpOverwriteMode::ScpOverwriteExisting);
|
||||
|
||||
/** Force-close the current SSH connection so the next operation starts fresh. */
|
||||
void resetConnection();
|
||||
|
||||
private:
|
||||
libssh::Client m_sshClient;
|
||||
};
|
||||
|
||||
@@ -5034,6 +5034,16 @@ Create one from the current settings.</source>
|
||||
<source>Add them to the application if they were not displayed</source>
|
||||
<translation>Добавить их в приложение, если они не отображаются</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSettingsServerData.qml" line="126"/>
|
||||
<source>Backup</source>
|
||||
<translation>Резервное копирование</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSettingsServerData.qml" line="127"/>
|
||||
<source>Local copy of VPN protocols, services, all server settings and users</source>
|
||||
<translation>Локальная копия VPN-протоколов, сервисов, всех настроек сервера и пользователей</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../ui/qml/Pages2/PageSettingsServerData.qml" line="115"/>
|
||||
<source>Reboot server</source>
|
||||
@@ -5785,6 +5795,34 @@ Create one from the current settings.</source>
|
||||
<source>Skip setup</source>
|
||||
<translation>Пропустить настройку</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No containers found in backup file</source>
|
||||
<translation>В файле резервной копии не найдено контейнеров</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Installing %1 (%2/%3)...</source>
|
||||
<translation>Устанавливается %1 (%2/%3)...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>backup.tgz</source>
|
||||
<translation>backup.tgz</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>RestoredServer</source>
|
||||
<translation>Восстановленный сервер</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restore from backup</source>
|
||||
<translation>Восстановить из резервной копии</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restoration of VPN protocols, services, all server settings and users</source>
|
||||
<translation>Восстановление VPN-протоколов, сервисов, всех настроек сервера и пользователей</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Select Backup to Restore</source>
|
||||
<translation>Выберите файл резервной копии</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>PageSetupWizardInstalling</name>
|
||||
@@ -7668,6 +7706,131 @@ FileZilla или другие SFTP-клиенты, а также смонтир
|
||||
<translation>Неверный формат JSON</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>PageSettingsServerBackup</name>
|
||||
<message>
|
||||
<source>Backup</source>
|
||||
<translation>Резервное копирование</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Local copy of VPN protocols, services, all server settings and users.</source>
|
||||
<translation>Локальная копия VPN-протоколов, сервисов, всех настроек сервера и пользователей.</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>More about backups</source>
|
||||
<translation>Подробнее о резервных копиях</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Create backup</source>
|
||||
<translation>Создать резервную копию</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restore from backup</source>
|
||||
<translation>Восстановить из резервной копии</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Create backup and download to device?</source>
|
||||
<translation>Создать резервную копию и скачать на устройство?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup will be created on server and automatically downloaded to your device</source>
|
||||
<translation>Резервная копия будет создана на сервере и автоматически скачана на ваше устройство</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Create server configuration backup?</source>
|
||||
<translation>Создать резервную копию конфигурации сервера?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>This will create a backup of your server containers configuration on the server</source>
|
||||
<translation>На сервере будет создана резервная копия конфигурации контейнеров</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Create</source>
|
||||
<translation>Создать</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cancel</source>
|
||||
<translation>Отмена</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Select Backup to Restore</source>
|
||||
<translation>Выберите файл резервной копии</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup files (*.tar.gz *.backup *.tgz *.gz)</source>
|
||||
<translation>Файлы резервных копий (*.tar.gz *.backup *.tgz *.gz)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Server</source>
|
||||
<translation>Сервер</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup created successfully: %1</source>
|
||||
<translation>Резервная копия успешно создана: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup downloaded successfully!\n\nSaved to:\n%1</source>
|
||||
<translation>Резервная копия успешно скачана!\n\nСохранена в:\n%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup error: %1</source>
|
||||
<translation>Ошибка резервного копирования: %1</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>PageSettingsServerRestoreMode</name>
|
||||
<message>
|
||||
<source>%1 on %2</source>
|
||||
<translation>%1 на %2</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restore from backup</source>
|
||||
<translation>Восстановление из резервной копии</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Add data from backup</source>
|
||||
<translation>Добавить данные из резервной копии</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>If the same protocols are already installed on the server, they will be updated. Created users and access will be saved</source>
|
||||
<translation>Если на сервере уже установлены такие же протоколы, они будут обновлены. Созданные пользователи и доступы сохранятся</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Replace</source>
|
||||
<translation>Заменить</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>All installed protocols, users and their access will not be saved</source>
|
||||
<translation>Все установленные протоколы, пользователи и их доступы не будут сохранены</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup restore error: %1</source>
|
||||
<translation>Ошибка восстановления из резервной копии: %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Server</source>
|
||||
<translation>Сервер</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>PageSettingsServerBackupRestored</name>
|
||||
<message>
|
||||
<source>%1 on "%2"</source>
|
||||
<translation>%1 на "%2"</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup restored</source>
|
||||
<translation>Резервная копия восстановлена</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>To home</source>
|
||||
<translation>На главную</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>To server settings</source>
|
||||
<translation>К настройкам сервера</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>main2</name>
|
||||
<message>
|
||||
|
||||
@@ -61,6 +61,7 @@ signals:
|
||||
void goToPageSettings();
|
||||
void goToPageViewConfig();
|
||||
void goToPageSettingsServerServices();
|
||||
void goToPageSettingsServerManagement();
|
||||
void goToPageSettingsBackup();
|
||||
void goToShareConnectionPage(QString headerText, QString configContentHeaderText, QString configCaption, QString configExtension,
|
||||
QString configFileName);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "backupUiController.h"
|
||||
|
||||
#include "core/controllers/serversController.h"
|
||||
#include "ui/controllers/serversUiController.h"
|
||||
#ifdef Q_OS_IOS
|
||||
#include "platforms/ios/ios_controller.h"
|
||||
#endif
|
||||
|
||||
BackupUiController::BackupUiController(BackupController *backupController, ServersController *serversController,
|
||||
ServersUiController *serversUiController, QObject *parent)
|
||||
: QObject(parent),
|
||||
m_backupController(backupController),
|
||||
m_serversController(serversController),
|
||||
m_serversUiController(serversUiController)
|
||||
{
|
||||
connect(m_backupController, &BackupController::backupCreated, this, &BackupUiController::backupCreated);
|
||||
connect(m_backupController, &BackupController::backupDownloaded, this, &BackupUiController::onBackupDownloaded);
|
||||
connect(m_backupController, &BackupController::backupRestored, this, &BackupUiController::backupRestored);
|
||||
connect(m_backupController, &BackupController::defaultServerAndContainerSet, this,
|
||||
&BackupUiController::defaultServerAndContainerSet);
|
||||
connect(m_backupController, &BackupController::progressChanged, this, &BackupUiController::progressChanged);
|
||||
connect(m_backupController, &BackupController::errorOccurred, this, &BackupUiController::errorOccurred);
|
||||
}
|
||||
|
||||
void BackupUiController::createBackupWithDownload(bool downloadToDevice, bool deleteFromServer)
|
||||
{
|
||||
ServerCredentials credentials;
|
||||
if (serverCredentials(false, credentials)) {
|
||||
m_backupController->createAndDownloadBackup(credentials, downloadToDevice, deleteFromServer);
|
||||
}
|
||||
}
|
||||
|
||||
void BackupUiController::startRestore(const QString &backupFilePath, bool replaceMode)
|
||||
{
|
||||
ServerCredentials credentials;
|
||||
if (serverCredentials(true, credentials)) {
|
||||
m_backupController->uploadAndRestore(credentials, backupFilePath, replaceMode);
|
||||
}
|
||||
}
|
||||
|
||||
void BackupUiController::setDefaultServerAfterRestore()
|
||||
{
|
||||
const int serversCount = m_serversUiController->getServersCount();
|
||||
if (serversCount > 0) {
|
||||
m_serversUiController->setProcessedServerId(m_serversUiController->getServerId(serversCount - 1));
|
||||
}
|
||||
m_backupController->setDefaultServerAfterRestore();
|
||||
}
|
||||
|
||||
QStringList BackupUiController::scanBackupForContainers(const QString &localPath)
|
||||
{
|
||||
return m_backupController->scanBackupForContainers(localPath);
|
||||
}
|
||||
|
||||
QVariantMap BackupUiController::getBackupFileInfo(const QString &backupFilePath)
|
||||
{
|
||||
const BackupController::BackupFileInfo info = BackupController::backupFileInfo(backupFilePath);
|
||||
return { { "fileName", info.fileName }, { "serverIp", info.serverIp } };
|
||||
}
|
||||
|
||||
void BackupUiController::onBackupDownloaded(const QString &localPath)
|
||||
{
|
||||
#ifdef Q_OS_IOS
|
||||
IosController::Instance()->shareText(QStringList { localPath });
|
||||
#endif
|
||||
emit backupDownloaded(localPath);
|
||||
}
|
||||
|
||||
bool BackupUiController::serverCredentials(bool fallbackToDefault, ServerCredentials &credentials)
|
||||
{
|
||||
QString serverId = m_serversUiController->getProcessedServerId();
|
||||
if (m_serversController->indexOfServerId(serverId) < 0 && fallbackToDefault) {
|
||||
serverId = m_serversController->getDefaultServerId();
|
||||
}
|
||||
|
||||
if (m_serversController->indexOfServerId(serverId) < 0) {
|
||||
emit errorOccurred(tr("No server selected"));
|
||||
return false;
|
||||
}
|
||||
|
||||
credentials = m_serversController->getServerCredentials(serverId);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#ifndef BACKUPUICONTROLLER_H
|
||||
#define BACKUPUICONTROLLER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include "core/controllers/selfhosted/backupController.h"
|
||||
|
||||
class ServersController;
|
||||
class ServersUiController;
|
||||
|
||||
class BackupUiController : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit BackupUiController(BackupController *backupController, ServersController *serversController,
|
||||
ServersUiController *serversUiController, QObject *parent = nullptr);
|
||||
|
||||
public slots:
|
||||
void createBackupWithDownload(bool downloadToDevice = true, bool deleteFromServer = true);
|
||||
void startRestore(const QString &backupFilePath, bool replaceMode);
|
||||
void setDefaultServerAfterRestore();
|
||||
|
||||
QStringList scanBackupForContainers(const QString &localPath);
|
||||
QVariantMap getBackupFileInfo(const QString &backupFilePath);
|
||||
|
||||
signals:
|
||||
void backupCreated(const QString &backupFilename);
|
||||
void backupDownloaded(const QString &localPath);
|
||||
void backupRestored();
|
||||
void defaultServerAndContainerSet();
|
||||
void progressChanged(int percent, const QString &message);
|
||||
void errorOccurred(const QString &errorMessage);
|
||||
|
||||
private:
|
||||
void onBackupDownloaded(const QString &localPath);
|
||||
bool serverCredentials(bool fallbackToDefault, ServerCredentials &credentials);
|
||||
|
||||
BackupController *m_backupController;
|
||||
ServersController *m_serversController;
|
||||
ServersUiController *m_serversUiController;
|
||||
};
|
||||
|
||||
#endif // BACKUPUICONTROLLER_H
|
||||
@@ -171,6 +171,37 @@ void SystemController::setQmlRoot(QObject *qmlRoot)
|
||||
m_qmlRoot = qmlRoot;
|
||||
}
|
||||
|
||||
QString SystemController::getFileNameFromPath(const QString &filePath)
|
||||
{
|
||||
if (filePath.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
#ifdef Q_OS_ANDROID
|
||||
if (filePath.startsWith("content://")) {
|
||||
QString fileName = AndroidController::instance()->getFileName(filePath);
|
||||
if (!fileName.isEmpty()) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString fileName = fileInfo.fileName();
|
||||
|
||||
if (fileName.isEmpty()) {
|
||||
QStringList parts = filePath.split('/');
|
||||
if (!parts.isEmpty()) {
|
||||
fileName = parts.last();
|
||||
if (fileName.contains('%')) {
|
||||
fileName = QUrl::fromPercentEncoding(fileName.toUtf8());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
bool SystemController::isAuthenticated()
|
||||
{
|
||||
#ifdef Q_OS_ANDROID
|
||||
|
||||
@@ -19,6 +19,8 @@ public:
|
||||
public slots:
|
||||
QString getFileName(const QString &acceptLabel, const QString &nameFilter, const QString &selectedFile = "",
|
||||
const bool isSaveMode = false, const QString &defaultSuffix = "");
|
||||
|
||||
Q_INVOKABLE QString getFileNameFromPath(const QString &filePath);
|
||||
|
||||
void setQmlRoot(QObject *qmlRoot);
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
import "./"
|
||||
import "../Controls2"
|
||||
import "../Controls2/TextTypes"
|
||||
import "../Config"
|
||||
|
||||
PageType {
|
||||
id: root
|
||||
|
||||
FlickableType {
|
||||
id: flickable
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: bottomBar.top
|
||||
|
||||
contentHeight: content.implicitHeight + 16
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
spacing: 0
|
||||
|
||||
NavigationBarType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
title: qsTr("Backup")
|
||||
description: qsTr("Local copy of VPN protocols, services, all server settings and users.")
|
||||
}
|
||||
|
||||
LinkButtonType {
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: qsTr("More about backups")
|
||||
|
||||
onClicked: {
|
||||
// TODO: Open help page or show more info
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomBarType {
|
||||
id: bottomBar
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
filled: true
|
||||
|
||||
ButtonType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: qsTr("Create backup")
|
||||
|
||||
onClicked: createBackup(true)
|
||||
}
|
||||
|
||||
ButtonType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
variant: ButtonType.Secondary
|
||||
|
||||
text: qsTr("Restore from backup")
|
||||
|
||||
onClicked: restoreBackup()
|
||||
}
|
||||
}
|
||||
|
||||
function createBackup(shouldDownload) {
|
||||
var downloadAfterCreate = (shouldDownload !== undefined) ? shouldDownload : true
|
||||
|
||||
var headerText = downloadAfterCreate ?
|
||||
qsTr("Create backup and download to device?") :
|
||||
qsTr("Create server configuration backup?")
|
||||
var descriptionText = downloadAfterCreate ?
|
||||
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)
|
||||
BackupController.createBackupWithDownload(downloadAfterCreate, true)
|
||||
}
|
||||
var noButtonFunction = function() {}
|
||||
|
||||
showQuestionDrawer(headerText, descriptionText, yesButtonText, noButtonText, yesButtonFunction, noButtonFunction)
|
||||
}
|
||||
|
||||
function restoreBackup() {
|
||||
var filter = GC.isMobile() ? "*.gz *.tgz *.tar.gz" : "Backup files (*.tar.gz *.backup *.tgz *.gz)"
|
||||
var localPath = SystemController.getFileName(qsTr("Select Backup to Restore"), filter, "", false, "")
|
||||
|
||||
if (!localPath || localPath.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
var fileInfo = BackupController.getBackupFileInfo(localPath)
|
||||
var fileName = fileInfo.fileName || "backup.tgz"
|
||||
var serverIp = fileInfo.serverIp || ""
|
||||
|
||||
if (!serverIp || serverIp.length === 0) {
|
||||
serverIp = ServersUiController.serverHostName(ServersUiController.processedServerId) || ""
|
||||
}
|
||||
|
||||
var serverName = ServersUiController.serverName(ServersUiController.processedServerId) || qsTr("Server")
|
||||
|
||||
var parentItem = root.parent
|
||||
while (parentItem && parentItem.objectName !== "tabBarStackView") {
|
||||
parentItem = parentItem.parent
|
||||
}
|
||||
|
||||
if (parentItem && typeof parentItem.push === "function") {
|
||||
parentItem.push(PageController.getPagePath(PageEnum.PageSettingsServerRestoreMode), {
|
||||
"backupFilePath": localPath,
|
||||
"backupFileName": fileName,
|
||||
"serverName": serverName,
|
||||
"serverIp": serverIp
|
||||
})
|
||||
} else {
|
||||
console.warn("Could not find StackView to navigate to restore mode page")
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BackupController
|
||||
|
||||
function onBackupCreated(backupFilename) {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showNotificationMessage(qsTr("Backup created successfully: %1").arg(backupFilename))
|
||||
}
|
||||
|
||||
function onBackupDownloaded(localPath) {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showNotificationMessage(qsTr("Backup downloaded successfully!\n\nSaved to:\n%1").arg(localPath))
|
||||
}
|
||||
|
||||
function onProgressChanged(percent, message) {
|
||||
console.log("Backup progress:", percent, "%", message)
|
||||
}
|
||||
|
||||
function onErrorOccurred(errorMessage) {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showErrorMessage(qsTr("Backup error: %1").arg(errorMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
import "./"
|
||||
import "../Controls2"
|
||||
import "../Controls2/TextTypes"
|
||||
import "../Config"
|
||||
|
||||
PageType {
|
||||
id: root
|
||||
|
||||
property string backupFileName: ""
|
||||
property string serverName: ""
|
||||
property string serverIp: ""
|
||||
property bool isFromSetupWizard: false
|
||||
|
||||
FlickableType {
|
||||
id: flickable
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: bottomBar.top
|
||||
|
||||
contentHeight: content.implicitHeight + 16
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
spacing: 0
|
||||
|
||||
NavigationBarType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
backButtonVisible: false
|
||||
|
||||
title: qsTr("Backup restored")
|
||||
description: {
|
||||
var baseText = qsTr("%1 on \"%2\"").arg(backupFileName).arg(serverName)
|
||||
if (serverIp && serverIp.length > 0) {
|
||||
return baseText + ", " + serverIp
|
||||
}
|
||||
return baseText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomBarType {
|
||||
id: bottomBar
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
filled: true
|
||||
|
||||
ButtonType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: qsTr("To home")
|
||||
|
||||
onClicked: PageController.goToPageHome()
|
||||
}
|
||||
|
||||
ButtonType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
variant: ButtonType.Secondary
|
||||
|
||||
text: qsTr("To server settings")
|
||||
|
||||
onClicked: {
|
||||
PageController.goToPage(PageEnum.PageSettingsServerInfo)
|
||||
PageController.goToPageSettingsServerManagement()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ PageType {
|
||||
|
||||
property list<QtObject> serverActions: [
|
||||
check,
|
||||
backupSection,
|
||||
reboot,
|
||||
remove,
|
||||
clear,
|
||||
@@ -108,6 +109,18 @@ PageType {
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: backupSection
|
||||
|
||||
property bool isVisible: root.isServerWithWriteAccess
|
||||
readonly property string title: qsTr("Backup")
|
||||
readonly property string description: qsTr("Local copy of VPN protocols, services, all server settings and users")
|
||||
readonly property bool isDestructive: false
|
||||
readonly property var clickedHandler: function() {
|
||||
PageController.goToPage(PageEnum.PageSettingsServerBackup)
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: reboot
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ PageType {
|
||||
function onGoToPageSettingsServerServices() {
|
||||
tabBar.setCurrentIndex(root.pageSettingsServerServices)
|
||||
}
|
||||
|
||||
function onGoToPageSettingsServerManagement() {
|
||||
tabBar.setCurrentIndex(root.pageSettingsServerData)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
import PageEnum 1.0
|
||||
import Style 1.0
|
||||
|
||||
import "./"
|
||||
import "../Controls2"
|
||||
import "../Controls2/TextTypes"
|
||||
import "../Config"
|
||||
|
||||
PageType {
|
||||
id: root
|
||||
|
||||
property string backupFilePath: ""
|
||||
property string backupFileName: ""
|
||||
property string serverName: ""
|
||||
property string serverIp: ""
|
||||
property bool isFromSetupWizard: false
|
||||
|
||||
property bool restoreReplaceMode: false
|
||||
|
||||
FlickableType {
|
||||
id: flickable
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
contentHeight: content.implicitHeight + 16
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
spacing: 0
|
||||
|
||||
NavigationBarType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
title: qsTr("Restore from backup")
|
||||
description: (serverIp && serverIp.length > 0) ? qsTr("%1 on %2").arg(backupFileName).arg(serverIp) : backupFileName
|
||||
}
|
||||
|
||||
ListItemType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: qsTr("Add data from backup")
|
||||
subtitle: qsTr("If the same protocols are already installed on the server, they will be updated. Created users and access will be saved")
|
||||
maximumLineCount: 3
|
||||
trailingIconSource: "qrc:/images/controls/chevron-right.svg"
|
||||
|
||||
onClicked: startRestore(false)
|
||||
}
|
||||
|
||||
ListItemType {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 16
|
||||
Layout.rightMargin: 16
|
||||
|
||||
text: qsTr("Replace")
|
||||
subtitle: qsTr("All installed protocols, users and their access will not be saved")
|
||||
destructive: true
|
||||
trailingIconSource: "qrc:/images/controls/chevron-right.svg"
|
||||
showSeparator: false
|
||||
|
||||
onClicked: startRestore(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startRestore(replaceMode) {
|
||||
restoreReplaceMode = replaceMode
|
||||
PageController.showBusyIndicator(true)
|
||||
|
||||
BackupController.startRestore(backupFilePath, replaceMode)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: BackupController
|
||||
|
||||
function onBackupRestored() {
|
||||
if (isFromSetupWizard) {
|
||||
BackupController.setDefaultServerAfterRestore()
|
||||
} else {
|
||||
PageController.showBusyIndicator(false)
|
||||
navigateToRestoredPage()
|
||||
}
|
||||
}
|
||||
|
||||
function onDefaultServerAndContainerSet() {
|
||||
PageController.showBusyIndicator(false)
|
||||
navigateToRestoredPage()
|
||||
}
|
||||
|
||||
function onErrorOccurred(errorMessage) {
|
||||
PageController.showBusyIndicator(false)
|
||||
PageController.showErrorMessage(qsTr("Backup restore error: %1").arg(errorMessage))
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToRestoredPage() {
|
||||
var actualServerName = serverName
|
||||
if (root.isFromSetupWizard && ServersUiController.getServersCount() > 0) {
|
||||
var lastServerId = ServersUiController.getServerId(ServersUiController.getServersCount() - 1)
|
||||
actualServerName = ServersUiController.serverName(lastServerId) || qsTr("Server")
|
||||
} else if (!serverName || serverName.length === 0) {
|
||||
actualServerName = ServersUiController.serverName(ServersUiController.processedServerId) || qsTr("Server")
|
||||
}
|
||||
|
||||
var parentItem = root.parent
|
||||
|
||||
if (root.isFromSetupWizard) {
|
||||
while (parentItem && typeof parentItem.push !== "function") {
|
||||
parentItem = parentItem.parent
|
||||
}
|
||||
if (parentItem && typeof parentItem.push === "function") {
|
||||
parentItem.push(PageController.getPagePath(PageEnum.PageSettingsServerBackupRestored), {
|
||||
"backupFileName": backupFileName,
|
||||
"serverName": actualServerName,
|
||||
"serverIp": serverIp,
|
||||
"isFromSetupWizard": true
|
||||
})
|
||||
}
|
||||
} else {
|
||||
while (parentItem && parentItem.objectName !== "tabBarStackView") {
|
||||
parentItem = parentItem.parent
|
||||
}
|
||||
if (parentItem && typeof parentItem.push === "function") {
|
||||
parentItem.push(PageController.getPagePath(PageEnum.PageSettingsServerBackupRestored), {
|
||||
"backupFileName": backupFileName,
|
||||
"serverName": actualServerName,
|
||||
"serverIp": serverIp,
|
||||
"isFromSetupWizard": false
|
||||
})
|
||||
} else {
|
||||
console.warn("Could not find StackView to navigate to restored page")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,126 @@ PageType {
|
||||
property int containerDefaultPort
|
||||
property int containerDefaultTransportProto
|
||||
|
||||
property bool isRestoreFromBackup: false
|
||||
property string backupFilePath: ""
|
||||
property bool waitingForServerToAdd: false
|
||||
|
||||
property var containersToInstall: []
|
||||
property int currentContainerIndex: 0
|
||||
property bool isInstallingContainers: false
|
||||
|
||||
Connections {
|
||||
target: InstallController
|
||||
|
||||
function onInstallServerFinished(finishedMessage) {
|
||||
if (root.waitingForServerToAdd && root.isRestoreFromBackup && root.backupFilePath.length > 0) {
|
||||
root.waitingForServerToAdd = false
|
||||
ServersUiController.setProcessedServerId(ServersUiController.getServerId(ServersUiController.getServersCount() - 1))
|
||||
root.isInstallingContainers = true
|
||||
installNextContainer()
|
||||
}
|
||||
}
|
||||
|
||||
function onInstallContainerFinished(finishedMessage, isServiceInstall) {
|
||||
if (root.isInstallingContainers) {
|
||||
root.currentContainerIndex++
|
||||
|
||||
if (root.currentContainerIndex < root.containersToInstall.length) {
|
||||
installNextContainer()
|
||||
} else {
|
||||
root.isInstallingContainers = false
|
||||
PageController.showBusyIndicator(false)
|
||||
navigationTimer.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installNextContainer() {
|
||||
if (root.currentContainerIndex >= root.containersToInstall.length) {
|
||||
return
|
||||
}
|
||||
|
||||
var containerName = root.containersToInstall[root.currentContainerIndex]
|
||||
var dockerContainer = ContainerProps.containerFromString(containerName)
|
||||
|
||||
if (dockerContainer === 0) {
|
||||
console.log("Unknown container:", containerName, "skipping...")
|
||||
root.currentContainerIndex++
|
||||
installNextContainer()
|
||||
return
|
||||
}
|
||||
|
||||
var defaultProtocol = ContainerProps.defaultProtocol(dockerContainer)
|
||||
var defaultPort = InstallController.getPortForInstall(defaultProtocol)
|
||||
var defaultTransport = InstallController.defaultTransportProto(defaultProtocol)
|
||||
|
||||
var serverId = ServersUiController.processedServerId
|
||||
|
||||
PageController.showBusyIndicator(true)
|
||||
PageController.showNotificationMessage(qsTr("Installing %1 (%2/%3)...")
|
||||
.arg(containerName)
|
||||
.arg(root.currentContainerIndex + 1)
|
||||
.arg(root.containersToInstall.length))
|
||||
|
||||
console.log("Installing container:", containerName, "serverId:", serverId)
|
||||
ServersUiController.processedContainerIndex = dockerContainer
|
||||
InstallController.install(dockerContainer, defaultPort, defaultTransport, serverId)
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: navigationTimer
|
||||
interval: 500
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (root.backupFilePath.length > 0 && root.isRestoreFromBackup) {
|
||||
var fileName = SystemController.getFileNameFromPath(root.backupFilePath)
|
||||
if (!fileName || fileName === undefined || fileName.length === 0) {
|
||||
var fallbackName = root.backupFilePath.split('/').pop()
|
||||
fileName = (fallbackName && fallbackName.length > 0) ? fallbackName : qsTr("backup.tgz")
|
||||
}
|
||||
fileName = String(fileName)
|
||||
|
||||
var serverIp = ""
|
||||
var ipMatch = fileName.match(/^([\d_]+)\s*-/)
|
||||
if (ipMatch && ipMatch.length > 1) {
|
||||
serverIp = ipMatch[1].replace(/_/g, ".")
|
||||
}
|
||||
if (!serverIp || serverIp.length === 0) {
|
||||
serverIp = ServersUiController.serverHostName(ServersUiController.processedServerId)
|
||||
}
|
||||
|
||||
PageController.goToPage(PageEnum.PageSetupWizardInstalling)
|
||||
|
||||
Qt.callLater(function() {
|
||||
var pagePath = "qrc:/ui/qml/Pages2/PageSettingsServerRestoreMode.qml"
|
||||
|
||||
// Walk up to the containing StackView instead of a recursive downward search,
|
||||
// which overflows the JS stack on iOS with a large component tree.
|
||||
var stackView = root.parent
|
||||
while (stackView) {
|
||||
if (typeof stackView.push === "function" && stackView.hasOwnProperty("depth")) {
|
||||
break
|
||||
}
|
||||
stackView = stackView.parent
|
||||
}
|
||||
|
||||
if (stackView) {
|
||||
stackView.push(pagePath, {
|
||||
"backupFilePath": root.backupFilePath,
|
||||
"backupFileName": fileName,
|
||||
"serverName": "",
|
||||
"serverIp": serverIp,
|
||||
"isFromSetupWizard": true
|
||||
})
|
||||
} else {
|
||||
console.error("Could not find StackView")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SortFilterProxyModel {
|
||||
id: proxyContainersModel
|
||||
|
||||
@@ -123,6 +243,40 @@ PageType {
|
||||
checked = true
|
||||
}
|
||||
}
|
||||
|
||||
SelectableListItemType {
|
||||
Layout.fillWidth: true
|
||||
|
||||
text: qsTr("Restore from backup")
|
||||
subtitle: qsTr("Restoration of VPN protocols, services, all server settings and users")
|
||||
|
||||
ButtonGroup.group: buttonGroup
|
||||
|
||||
onClicked: {
|
||||
var filter = GC.isMobile() ? "*.gz *.tgz *.tar.gz" : "Backup files (*.tar.gz *.backup *.tgz *.gz)"
|
||||
var localPath = SystemController.getFileName(qsTr("Select Backup to Restore"), filter, "", false, "")
|
||||
|
||||
if (!localPath || localPath.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
root.backupFilePath = localPath
|
||||
root.isRestoreFromBackup = true
|
||||
|
||||
var foundContainers = BackupController.scanBackupForContainers(localPath)
|
||||
if (foundContainers.length === 0) {
|
||||
PageController.showErrorMessage(qsTr("No containers found in backup file"))
|
||||
root.isRestoreFromBackup = false
|
||||
return
|
||||
}
|
||||
|
||||
root.containersToInstall = foundContainers
|
||||
root.currentContainerIndex = 0
|
||||
|
||||
root.waitingForServerToAdd = true
|
||||
InstallController.addEmptyServer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,11 @@
|
||||
<file>Pages2/PageSettingsLocalProxy.qml</file>
|
||||
<file>Pages2/PageSettingsLogging.qml</file>
|
||||
<file>Pages2/PageSettingsLogViewer.qml</file>
|
||||
<file>Pages2/PageSettingsServerBackup.qml</file>
|
||||
<file>Pages2/PageSettingsServerBackupRestored.qml</file>
|
||||
<file>Pages2/PageSettingsServerData.qml</file>
|
||||
<file>Pages2/PageSettingsServerInfo.qml</file>
|
||||
<file>Pages2/PageSettingsServerRestoreMode.qml</file>
|
||||
<file>Pages2/PageSettingsServerProtocol.qml</file>
|
||||
<file>Pages2/PageSettingsServerProtocols.qml</file>
|
||||
<file>Pages2/PageSettingsServerServices.qml</file>
|
||||
|
||||
@@ -27,6 +27,9 @@ namespace PageLoader
|
||||
PageSettingsNewsNotifications,
|
||||
PageSettingsNewsDetail,
|
||||
PageSettingsBackup,
|
||||
PageSettingsServerBackup,
|
||||
PageSettingsServerRestoreMode,
|
||||
PageSettingsServerBackupRestored,
|
||||
PageSettingsAbout,
|
||||
PageSettingsLogging,
|
||||
PageSettingsLogViewer,
|
||||
|
||||
Reference in new issue
Block a user