Compare commits

...
Author SHA1 Message Date
NickVs2015 ddf2ea2c3a fix: use stored server credentials for wizard restore 2026-10-21 12:50:16 +03:00
NickVs2015 051754090b merge dev 2026-10-20 15:44:49 +03:00
NickVs2015 80d3123df0 fix: copy oss apk into build type dir for androiddeployqt 2026-10-19 17:49:26 +03:00
NickVs2015 3a1b8895e8 refactor: move backup orchestration into core BackupController 2026-10-19 17:49:26 +03:00
NickVs2015 fa724e3fb1 refactor: split server backup into core BackupController and BackupUiController 2026-10-19 17:49:26 +03:00
NickVs2015 f95807ae6a fix: adapt backup pages to new design system 2026-10-19 17:49:26 +03:00
NickVs2015 36cced7784 fix: remove russian comments 2026-10-19 17:49:26 +03:00
NickVs2015 d361550691 fix: translation 2026-10-19 17:49:26 +03:00
NickVs2015 5b92bbd552 fix: replace ServersModel.getProcessedServerData with ServersUiController API in backup/restore pages 2026-10-19 17:49:26 +03:00
NickVs2015 0f147c9df5 fix: adapt ServersBackupController to new server ID-based API 2026-10-19 17:49:25 +03:00
NickVs2015 0eb32012d9 fix: add translations self-hosted backup 2026-10-19 17:49:25 +03:00
NickVs2015 58e05b62bf - SshSession::resetConnection(): force-disconnect before each new top-level
backup operation so ssh_scp_new() doesn't fail on a reused stale session
  (reproduces as 'Failed to open channel for scp' right after restore)
- Call resetConnection() at entry of createBackup() and uploadBackup()
- Replace recursive findStackView() in PageSetupWizardEasy with upward
  parent traversal (depth+push check) to avoid JS stack overflow on iOS
  when component tree is large (71 VPN managers in test)
2026-10-19 17:49:25 +03:00
NickVs2015 8873c5e3dd fix: adapt ServersBackupController and wizard restore to new codebase after rebase
- Fix includes in ServersBackupController (remove core/defs.h, containers_defs.h;
  add core/utils/containerEnum.h, commonStructs.h, errorCodes.h)
- Inject ServersUiController and ServersController into ServersBackupController
  to replace removed ServersModel API (getProcessedServerIndex, getServerCredentials,
  setDefaultServerIndex, getServerConfig, setDefaultContainer)
- Replace ContainerProps::containerFromString/toString with ContainerUtils equivalents
- Fix SCP permission denied: upload to /tmp first, then sudo mv to backup dir
- Fix double restore: use m_autoRestoreAfterUpload=true always (C++ handles both flows);
  remove redundant onBackupUploaded handler from PageSettingsServerData
- Remove duplicate ServersBackupController Connections from PageSettingsServerData
  (now exclusively handled by PageSettingsServerBackup + PageSettingsServerRestoreMode)
- Remove onBackupRestored from PageSettingsServerBackup (handled by PageSettingsServerRestoreMode)
- Fix wizard restore: remove non-existent setShouldCreateServer calls
- Fix wizard restore: use InstallController.getPortForInstall/defaultTransportProto
  instead of ProtocolProps; pass serverId as 4th arg to InstallController.install()
- Fix QML: replace ServersModel.getServerCredentials with
  ServersUiController.getProcessedServerCredentials()
2026-10-19 17:49:25 +03:00
NickVs2015 3df9c70115 fix: adapt ServersBackupController to new codebase after rebase
- Remove deleted ServerController dependency; implement runHostScript,
  downloadFileFromHost, uploadFileToHostPublic directly using SshSession
  and libssh::Client which are the current SSH abstractions
- Change constructor from std::shared_ptr<Settings> to QPointer<SecureQSettings>
  to match CoreController's settings type
- Register PageSettingsServerBackup, PageSettingsServerBackupRestored,
  PageSettingsServerRestoreMode in qml.qrc (were in resources.qrc which
  was removed from the codebase)
2026-10-19 17:49:25 +03:00
NickVs2015 e2bb3f5982 fix: ios compile 2026-10-19 17:49:25 +03:00
NickVs2015 13abc9cb7a fix: add container copy optimization 2026-10-19 17:49:25 +03:00
NickVs2015 8ed098bf9a fix: add install container and copy config 2026-10-19 17:49:25 +03:00
NickVs2015 39bac7b99d fix: restore configs 2026-10-19 17:49:25 +03:00
NickVs2015 adbca78aa3 feat: add seft-hosted server backup 2026-10-19 17:49:25 +03:00
24 changed files with 1757 additions and 5 deletions

No files matched your search

+5 -5
View File
@@ -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)
+2
View File
@@ -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);
+4
View File
@@ -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) {
+3
View File
@@ -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;
};
+163
View File
@@ -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 &quot;%2&quot;</source>
<translation>%1 на &quot;%2&quot;</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
+2
View File
@@ -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()
}
}
}
}
}
+3
View File
@@ -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>
+3
View File
@@ -27,6 +27,9 @@ namespace PageLoader
PageSettingsNewsNotifications,
PageSettingsNewsDetail,
PageSettingsBackup,
PageSettingsServerBackup,
PageSettingsServerRestoreMode,
PageSettingsServerBackupRestored,
PageSettingsAbout,
PageSettingsLogging,
PageSettingsLogViewer,