Merge remote-tracking branch 'origin/master' into feature/improve-compact-block-use-check

This commit is contained in:
Kenneth Skovhede
2025-04-01 15:54:54 +02:00
1212 changed files with 111289 additions and 50411 deletions
+613
View File
@@ -0,0 +1,613 @@
name: Backend Live Tests
on: [pull_request, workflow_dispatch]
concurrency:
group: duplicati-ci-livetests
cancel-in-progress: false
jobs:
check_secrets:
runs-on: ubuntu-latest
outputs:
s3_secrets_available: "${{ steps.check_s3.outputs.available }}"
dropbox_secrets_available: "${{ steps.check_dropbox.outputs.available }}"
azure_secrets_available: "${{ steps.check_azure.outputs.available }}"
testcontainers_secrets_available: "${{ steps.check_testcontainers.outputs.available }}"
google_secrets_available: "${{ steps.check_google.outputs.available }}"
pcloud_secrets_available: "${{ steps.check_pcloud.outputs.available }}"
b2_secrets_available: "${{ steps.check_b2.outputs.available }}"
box_secrets_available: "${{ steps.check_box.outputs.available }}"
filen_secrets_available: "${{ steps.check_filen.outputs.available }}"
steps:
- id: check_pcloud
name: Check pCloud secrets
shell: bash
run: |
echo "Starting pCloud secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_PCLOUD_SERVER }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_PCLOUD_TOKEN }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_PCLOUD_FOLDER }}" ]]; then
echo "All pCloud secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some pCloud secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "pCloud check completed"
- id: check_s3
name: Check S3 secrets
shell: bash
run: |
echo "Starting S3 secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_S3_BUCKETNAME }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_S3_KEY }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_S3_REGION }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_S3_SECRET }}" ]]; then
echo "All S3 secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some S3 secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "S3 check completed"
- id: check_dropbox
name: Check Dropbox secrets
shell: bash
run: |
echo "Starting Dropbox secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_DROPBOX_FOLDER }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_DROPBOX_TOKEN }}" ]]; then
echo "All Dropbox secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some Dropbox secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Dropbox check completed"
- id: check_azure
name: Check Azure secrets
shell: bash
run: |
echo "Starting Azure secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_AZURE_ACCOUNTNAME }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_AZURE_ACCESSKEY }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_AZURE_CONTAINERNAME }}" ]]; then
echo "All Azure secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some Azure secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Azure check completed"
- id: check_testcontainers
name: Check TestContainers secret
shell: bash
run: |
echo "Starting TestContainers secrets check..."
if [[ -n "${{ secrets.TC_CLOUD_TOKEN }}" ]]; then
echo "All TestContainers secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing TestContainers token"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Testcontainers check completed"
- id: check_google
name: Check Google secrets
shell: bash
run: |
echo "Starting Google secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_GOOGLEDRIVE_TOKEN }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_GOOGLEDRIVE_FOLDER }}" ]]; then
echo "All Google secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some Google secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Google check completed"
- id: check_b2
name: Check B2 secrets
shell: bash
run: |
echo "Starting B2 secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_B2_BUCKET }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_B2_FOLDER }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_B2_USERNAME }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_B2_PASSWORD }}" ]]; then
echo "All B2 secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some B2 secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "B2 check completed"
- id: check_box
name: Check Box.com secrets
shell: bash
run: |
echo "Starting Box.com secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_BOX_FOLDER }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_BOX_AUTHID }}" ]]; then
echo "All Box.com secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some Box.com secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Box.com check completed"
- id: check_filen
name: Check Filen.io secrets
shell: bash
run: |
echo "Starting Filen.io secrets check..."
if [[ -n "${{ secrets.TESTCREDENTIAL_FILEN_FOLDER }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_FILEN_USERNAME }}" ]] && \
[[ -n "${{ secrets.TESTCREDENTIAL_FILEN_PASSWORD }}" ]]; then
echo "All Filen.io secrets found"
echo "available=true" >> $GITHUB_OUTPUT
else
echo "Missing some Filen.io secrets"
echo "available=false" >> $GITHUB_OUTPUT
fi
echo "Filen.io check completed"
- name: Debug Output
shell: bash
run: |
echo "S3 Available: ${{ steps.check_s3.outputs.available }}"
echo "Dropbox Available: ${{ steps.check_dropbox.outputs.available }}"
echo "TestContainers Available: ${{ steps.check_testcontainers.outputs.available }}"
echo "Google Available: ${{ steps.check_google.outputs.available }}"
echo "pCloud Available: ${{ steps.check_pcloud.outputs.available }}"
echo "Azure Available: ${{ steps.check_azure.outputs.available }}"
echo "B2 Available: ${{ steps.check_b2.outputs.available }}"
echo "Box.com Available: ${{ steps.check_box.outputs.available }}"
echo "Filen.io Available: ${{ steps.check_filen.outputs.available }}"
test_ftp:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
name: FTP Tests
runs-on: "${{ matrix.os }}"
strategy:
fail-fast: false
max-parallel: 1
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
token: "${{ secrets.TC_CLOUD_TOKEN }}"
- name: Run FTP tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
run: >-
dotnet test --no-build --filter="ClassName~FtpTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_pcloud:
needs: check_secrets
if: needs.check_secrets.outputs.pcloud_secrets_available == 'true'
name: pCloud Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run pCloud tests
env:
TESTCREDENTIAL_PCLOUD_SERVER: "${{ secrets.TESTCREDENTIAL_PCLOUD_SERVER }}"
TESTCREDENTIAL_PCLOUD_TOKEN: "${{ secrets.TESTCREDENTIAL_PCLOUD_TOKEN }}"
TESTCREDENTIAL_PCLOUD_FOLDER: "${{ secrets.TESTCREDENTIAL_PCLOUD_FOLDER }}"
run: >-
dotnet test --no-build --filter="ClassName~pCloudTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_s3:
needs: check_secrets
if: needs.check_secrets.outputs.s3_secrets_available == 'true'
name: S3 Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run S3 tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
TESTCREDENTIAL_S3_BUCKETNAME: "${{ secrets.TESTCREDENTIAL_S3_BUCKETNAME }}"
TESTCREDENTIAL_S3_KEY: "${{ secrets.TESTCREDENTIAL_S3_KEY }}"
TESTCREDENTIAL_S3_REGION: "${{ secrets.TESTCREDENTIAL_S3_REGION }}"
TESTCREDENTIAL_S3_SECRET: "${{ secrets.TESTCREDENTIAL_S3_SECRET }}"
run: >-
dotnet test --no-build --filter="ClassName~S3Tests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_azure:
needs: check_secrets
if: needs.check_secrets.outputs.azure_secrets_available == 'true'
name: Azure Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run Azure tests
env:
READ_WRITE_TIMEOUT_SECONDS: 18000
TESTCREDENTIAL_AZURE_ACCOUNTNAME: "${{ secrets.TESTCREDENTIAL_AZURE_ACCOUNTNAME }}"
TESTCREDENTIAL_AZURE_ACCESSKEY: "${{ secrets.TESTCREDENTIAL_AZURE_ACCESSKEY }}"
TESTCREDENTIAL_AZURE_CONTAINERNAME: "${{ secrets.TESTCREDENTIAL_AZURE_CONTAINERNAME }}"
run: >-
dotnet test --no-build --filter="ClassName~AzureTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_webdav:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
name: Webdav Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
token: "${{ secrets.TC_CLOUD_TOKEN }}"
- name: Run Webdav tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
run: >-
dotnet test --no-build --filter="ClassName~WebDavTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_dropbox:
needs: check_secrets
if: needs.check_secrets.outputs.dropbox_secrets_available == 'true'
name: DropBox Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run Dropbox tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
TESTCREDENTIAL_DROPBOX_FOLDER: "${{ secrets.TESTCREDENTIAL_DROPBOX_FOLDER }}"
TESTCREDENTIAL_DROPBOX_TOKEN: "${{ secrets.TESTCREDENTIAL_DROPBOX_TOKEN }}"
run: >-
dotnet test --no-build --filter="ClassName~DropBoxTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_ssh:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
name: SSH Tests
runs-on: "${{ matrix.os }}"
strategy:
fail-fast: false
max-parallel: 1
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
token: "${{ secrets.TC_CLOUD_TOKEN }}"
- name: Run SSH tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
run: >-
dotnet test --no-build --filter="ClassName~SshTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_googledrive:
needs: check_secrets
if: needs.check_secrets.outputs.google_secrets_available == 'true'
name: Google Drive Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run Googledrive tests
env:
TESTCREDENTIAL_GOOGLEDRIVE_TOKEN: "${{ secrets.TESTCREDENTIAL_GOOGLEDRIVE_TOKEN }}"
TESTCREDENTIAL_GOOGLEDRIVE_FOLDER: "${{ secrets.TESTCREDENTIAL_GOOGLEDRIVE_FOLDER }}"
run: >-
dotnet test --no-build --filter="ClassName~GoogleDriveTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_cifs:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
name: CIFS Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run CIFS tests
run: >-
dotnet test --no-build --filter="ClassName~CIFSTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_b2:
needs: check_secrets
if: needs.check_secrets.outputs.b2_secrets_available == 'true'
name: Backblaze B2 Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run B2 tests
env:
TESTCREDENTIAL_B2_BUCKET: "${{ secrets.TESTCREDENTIAL_B2_BUCKET }}"
TESTCREDENTIAL_B2_FOLDER: "${{ secrets.TESTCREDENTIAL_B2_FOLDER }}"
TESTCREDENTIAL_B2_USERNAME: "${{ secrets.TESTCREDENTIAL_B2_USERNAME }}"
TESTCREDENTIAL_B2_PASSWORD: "${{ secrets.TESTCREDENTIAL_B2_PASSWORD }}"
run: >-
dotnet test --no-build --filter="ClassName~B2Tests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_box:
needs: check_secrets
if: needs.check_secrets.outputs.box_secrets_available == 'true'
name: Box.com Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run Box.com tests
env:
TESTCREDENTIAL_BOX_FOLDER: "${{ secrets.TESTCREDENTIAL_BOX_FOLDER }}"
TESTCREDENTIAL_BOX_AUTHID: "${{ secrets.TESTCREDENTIAL_BOX_AUTHID }}"
run: >-
dotnet test --no-build --filter="ClassName~Box.BoxTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
test_filen:
needs: check_secrets
if: needs.check_secrets.outputs.filen_secrets_available == 'true'
name: Filen.io Tests
runs-on: "${{ matrix.os }}"
strategy:
max-parallel: 1
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
steps:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
- name: Run Filen.io tests
env:
TESTCREDENTIAL_FILEN_FOLDER: "${{ secrets.TESTCREDENTIAL_FILEN_FOLDER }}"
TESTCREDENTIAL_FILEN_USERNAME: "${{ secrets.TESTCREDENTIAL_FILEN_USERNAME }}"
TESTCREDENTIAL_FILEN_PASSWORD: "${{ secrets.TESTCREDENTIAL_FILEN_PASSWORD }}"
run: >-
dotnet test --no-build --filter="ClassName~Filen.FilenTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
+8 -7
View File
@@ -1,6 +1,6 @@
name: Tests
on: [push, pull_request]
on: [pull_request, workflow_dispatch]
jobs:
unit_tests:
@@ -29,9 +29,10 @@ jobs:
- name: Run unit tests
run: dotnet test --no-build --verbosity minimal Duplicati.sln
selenium:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Selenium
run: pipeline/selenium/test.sh
# Disabled, as a new test needs to be written for the new UI
# selenium:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Selenium
# run: pipeline/selenium/test.sh
+2 -1
View File
@@ -19,4 +19,5 @@ changelog-news.txt
.vs/
/publish/
ReleaseBuilder/build-temp/
ReleaseBuilder/Resources/Windows/binfiles.wxs
ReleaseBuilder/Resources/Windows/TrayIcon/binfiles.wxs
ReleaseBuilder/Resources/Windows/Agent/binfiles.wxs
+125 -107
View File
@@ -1,108 +1,126 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch TrayIcon executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.GUI.TrayIcon/bin/Debug/net8.0/Duplicati.GUI.TrayIcon",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch Server executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Server/bin/Debug/net8.0/Duplicati.Server",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch CommandLine executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine/bin/Debug/net8.0/Duplicati.CommandLine",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch AutoUpdater executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.AutoUpdater/bin/Debug/net8.0/Duplicati.CommandLine.AutoUpdater",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch RecoveryTool executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.RecoveryTool/bin/Debug/net8.0/Duplicati.CommandLine.RecoveryTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch ServerUtil executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.ServerUtil/bin/Debug/net8.0/Duplicati.CommandLine.ServerUtil",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch BackendTool executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTool/bin/Debug/net8.0/Duplicati.CommandLine.BackendTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch Agent executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Agent/bin/Debug/net8.0/Duplicati.Agent",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch BackendTester executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTester/bin/Debug/net8.0/Duplicati.CommandLine.BackendTester",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch TrayIcon executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.GUI.TrayIcon/bin/Debug/net8.0/Duplicati.GUI.TrayIcon",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch Server executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Server/bin/Debug/net8.0/Duplicati.Server",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch CommandLine executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine/bin/Debug/net8.0/Duplicati.CommandLine",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch AutoUpdater executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.AutoUpdater/bin/Debug/net8.0/Duplicati.CommandLine.AutoUpdater",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch RecoveryTool executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.RecoveryTool/bin/Debug/net8.0/Duplicati.CommandLine.RecoveryTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch ServerUtil executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.ServerUtil/bin/Debug/net8.0/Duplicati.CommandLine.ServerUtil",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch BackendTool executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTool/bin/Debug/net8.0/Duplicati.CommandLine.BackendTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch Agent executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Agent/bin/Debug/net8.0/Duplicati.Agent",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch BackendTester executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTester/bin/Debug/net8.0/Duplicati.CommandLine.BackendTester",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch SecretProvider executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.SecretTool/bin/Debug/net8.0/Duplicati.CommandLine.SecretTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": "Launch SourceTool executable",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.SourceTool/bin/Debug/net8.0/Duplicati.CommandLine.SourceTool",
"args": []
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "dotnet: build",
"program": "${workspaceFolder}/bin/Debug/net9.0/LicenseUpdater.dll",
"args": ["../.."],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "dotnet",
"task": "build",
"group": "build",
"problemMatcher": [],
"label": "dotnet: build"
}
]
}
+13 -2
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -54,9 +54,15 @@ DEALINGS IN THE SOFTWARE.".Replace("YYYY", DateTime.UtcNow.Year.ToString());
public static Regex CS_MIT_MATCH = new Regex(@"(?<license>//\s+Copyright \(C\) \d{4}, The Duplicati Team.+(?:copyright notice and this permission notice).+IN THE SOFTWARE\.)", RE_OPTS);
public static Regex CSPROJ_COPYRIGHT_MATCH = new Regex(@"<Copyright>(.*?)<\/Copyright>");
public static string GetLicenseTextWithPrefixedLines(string linePrefix = "// ")
=> string.Join("\n", Fragments.NEW_LICENSE.Split("\n").Select(x => linePrefix + x).Append(string.Empty));
private static readonly string LicenseTextWithPrefixedLines = GetLicenseTextWithPrefixedLines();
public static readonly string CopyrightText = $"Copyright © {DateTime.UtcNow.Year.ToString()} Team Duplicati, MIT license";
public static bool MatchAndReplace(ref string data)
{
foreach (var m in new[] { CS_REGION_MATCH, CS_MIT_MATCH })
@@ -65,11 +71,16 @@ DEALINGS IN THE SOFTWARE.".Replace("YYYY", DateTime.UtcNow.Year.ToString());
if (match.Success && match.Groups["license"].Success)
{
var g = match.Groups["license"];
var newLicense = LicenseTextWithPrefixedLines;
if (data[(g.Index + g.Length)..].StartsWith('\n') || data[(g.Index + g.Length)..].StartsWith("\r\n"))
newLicense = newLicense.TrimEnd();
data = string.Join(
string.Empty,
data.Substring(0, g.Index),
GetLicenseTextWithPrefixedLines(),
newLicense,
data.Substring(g.Index + g.Length)
);
@@ -6,7 +6,7 @@
<RootNamespace>license_upgrader</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
</Project>
+12 -2
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -30,7 +30,8 @@ if (!Directory.Exists(startpath))
throw new Exception($"Start path not found: {startpath}");
var target_extensions = new[] {
".cs"
".cs",
".csproj"
// ".html",
// ".js",
// ".css"
@@ -72,6 +73,15 @@ foreach (var file in candidates)
continue;
}
if (string.Equals(Path.GetExtension(file), ".csproj", StringComparison.OrdinalIgnoreCase))
{
data = Fragments.CSPROJ_COPYRIGHT_MATCH.Replace(data, $"<Copyright>{Fragments.CopyrightText}</Copyright>");
File.WriteAllText(file, data);
// Console.WriteLine($"{file} updated!");
continue;
}
Console.WriteLine($"{file} skipped, no match");
}
+97
View File
@@ -154,6 +154,36 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Agent", "Executab
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.RemoteControl", "Duplicati\Library\RemoteControl\Duplicati.Library.RemoteControl.csproj", "{BB3A6E17-FC2E-42E3-B697-10D0972AFE81}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.SecretProvider", "Duplicati\Library\SecretProvider\Duplicati.Library.SecretProvider.csproj", "{37A0B1B9-32F3-47C8-86E9-C10507C4ED4C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SecretTool", "Duplicati\CommandLine\SecretTool\Duplicati.CommandLine.SecretTool.csproj", "{D6417FD3-1645-4AB4-9F42-A1EC4A262B37}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SecretTool", "Executables\net8\Duplicati.CommandLine.SecretTool\Duplicati.CommandLine.SecretTool.csproj", "{E1D32EF7-368E-4148-9367-B328E78C871B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.pCloud", "Duplicati\Library\Backend\pCloud\Duplicati.Library.Backend.pCloud.csproj", "{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Crashlog", "Duplicati\Library\Crashlog\Duplicati.Library.Crashlog.csproj", "{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.SMB", "Duplicati\Library\Backend\SMB\Duplicati.Library.Backend.SMB.csproj", "{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.SourceProviders", "Duplicati\Library\SourceProviders\Duplicati.Library.SourceProviders.csproj", "{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SourceProviders", "SourceProviders", "{424CEF73-4984-430E-9C8B-E61CC0F22074}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.SourceProvider.Builtin", "Duplicati\Library\SourceProvider\Builtin\Duplicati.Library.SourceProvider.Builtin.csproj", "{6D9742A7-6F32-4571-8276-79716168B525}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{15388C37-9218-4818-972E-738EEA8F1602}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RemoteSynchronization", "Tools\RemoteSynchronization\RemoteSynchronization.csproj", "{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SyncTool", "Executables\net8\Duplicati.CommandLine.SyncTool\Duplicati.CommandLine.SyncTool.csproj", "{48D32674-0FE9-4407-B102-4AE46D93595F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SourceTool", "Duplicati\CommandLine\SourceTool\Duplicati.CommandLine.SourceTool.csproj", "{2873BCE2-2EA6-419D-BFFA-693FDB34926E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.CommandLine.SourceTool", "Executables\net8\Duplicati.CommandLine.SourceTool\Duplicati.CommandLine.SourceTool.csproj", "{4003BF90-6681-4155-9D06-639ED23FE3AB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Duplicati.Library.Backend.Filen", "Duplicati\Library\Backend\Filen\Duplicati.Library.Backend.Filen.csproj", "{BD7F4E52-1898-4C4C-8722-FFE87B06718B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -400,6 +430,10 @@ Global
{C1D4D665-23A3-4216-9CD1-D67AE9AAAA4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1D4D665-23A3-4216-9CD1-D67AE9AAAA4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1D4D665-23A3-4216-9CD1-D67AE9AAAA4C}.Release|Any CPU.Build.0 = Release|Any CPU
{4EB3DABC-D412-4C12-8876-41A1427A389E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4EB3DABC-D412-4C12-8876-41A1427A389E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4EB3DABC-D412-4C12-8876-41A1427A389E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4EB3DABC-D412-4C12-8876-41A1427A389E}.Release|Any CPU.Build.0 = Release|Any CPU
{FE6FD36C-E171-4599-8D55-62DA579C0864}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE6FD36C-E171-4599-8D55-62DA579C0864}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE6FD36C-E171-4599-8D55-62DA579C0864}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -432,6 +466,58 @@ Global
{BB3A6E17-FC2E-42E3-B697-10D0972AFE81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BB3A6E17-FC2E-42E3-B697-10D0972AFE81}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BB3A6E17-FC2E-42E3-B697-10D0972AFE81}.Release|Any CPU.Build.0 = Release|Any CPU
{37A0B1B9-32F3-47C8-86E9-C10507C4ED4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37A0B1B9-32F3-47C8-86E9-C10507C4ED4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37A0B1B9-32F3-47C8-86E9-C10507C4ED4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37A0B1B9-32F3-47C8-86E9-C10507C4ED4C}.Release|Any CPU.Build.0 = Release|Any CPU
{D6417FD3-1645-4AB4-9F42-A1EC4A262B37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D6417FD3-1645-4AB4-9F42-A1EC4A262B37}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D6417FD3-1645-4AB4-9F42-A1EC4A262B37}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D6417FD3-1645-4AB4-9F42-A1EC4A262B37}.Release|Any CPU.Build.0 = Release|Any CPU
{E1D32EF7-368E-4148-9367-B328E78C871B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E1D32EF7-368E-4148-9367-B328E78C871B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E1D32EF7-368E-4148-9367-B328E78C871B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E1D32EF7-368E-4148-9367-B328E78C871B}.Release|Any CPU.Build.0 = Release|Any CPU
{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE}.Release|Any CPU.Build.0 = Release|Any CPU
{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332}.Release|Any CPU.Build.0 = Release|Any CPU
{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B}.Release|Any CPU.Build.0 = Release|Any CPU
{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A2C7A9F-3EC8-4DE5-A9FA-9E4BCF955EF3}.Release|Any CPU.Build.0 = Release|Any CPU
{6D9742A7-6F32-4571-8276-79716168B525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D9742A7-6F32-4571-8276-79716168B525}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D9742A7-6F32-4571-8276-79716168B525}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D9742A7-6F32-4571-8276-79716168B525}.Release|Any CPU.Build.0 = Release|Any CPU
{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D}.Release|Any CPU.Build.0 = Release|Any CPU
{48D32674-0FE9-4407-B102-4AE46D93595F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48D32674-0FE9-4407-B102-4AE46D93595F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48D32674-0FE9-4407-B102-4AE46D93595F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48D32674-0FE9-4407-B102-4AE46D93595F}.Release|Any CPU.Build.0 = Release|Any CPU
{2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2873BCE2-2EA6-419D-BFFA-693FDB34926E}.Release|Any CPU.Build.0 = Release|Any CPU
{4003BF90-6681-4155-9D06-639ED23FE3AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4003BF90-6681-4155-9D06-639ED23FE3AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4003BF90-6681-4155-9D06-639ED23FE3AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4003BF90-6681-4155-9D06-639ED23FE3AB}.Release|Any CPU.Build.0 = Release|Any CPU
{BD7F4E52-1898-4C4C-8722-FFE87B06718B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD7F4E52-1898-4C4C-8722-FFE87B06718B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD7F4E52-1898-4C4C-8722-FFE87B06718B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BD7F4E52-1898-4C4C-8722-FFE87B06718B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -487,6 +573,17 @@ Global
{3476A88B-4123-45F4-AC96-700B747367EB} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B}
{0C1AD03C-89D8-4C47-82FF-25D5470055B4} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B}
{A2957269-C11F-44CE-B355-CC9BA342295D} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
{D6417FD3-1645-4AB4-9F42-A1EC4A262B37} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B}
{E1D32EF7-368E-4148-9367-B328E78C871B} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
{CA1A0EE8-DFFF-42AF-B15D-EF538357A2DE} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4}
{8ACA2736-3C69-4FA5-BCF9-5EDF50CAF332} = {566EBBDA-19A4-4056-A615-D901D57D2439}
{836E0557-B40C-4DC7-9A2A-5C062F9ACC6B} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4}
{6D9742A7-6F32-4571-8276-79716168B525} = {424CEF73-4984-430E-9C8B-E61CC0F22074}
{D3A7E41E-279D-4E0A-A2DB-5E8002E02B6D} = {15388C37-9218-4818-972E-738EEA8F1602}
{48D32674-0FE9-4407-B102-4AE46D93595F} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
{2873BCE2-2EA6-419D-BFFA-693FDB34926E} = {D19A38DD-68F1-4EF5-BF5F-8966CE0D9A5B}
{4003BF90-6681-4155-9D06-639ED23FE3AB} = {6B46F6B1-1898-49B8-ADA7-5CAF68EB77E3}
{BD7F4E52-1898-4C4C-8722-FFE87B06718B} = {E1A9B303-F281-45C5-A4F6-CADD9DE3F3C4}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8B40BAFE-D862-4397-9495-8F5EAF5CE80C}
+1 -1
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
+1 -1
View File
@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.Agent.Implementation</AssemblyName>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.Agent</RootNamespace>
</PropertyGroup>
+266 -84
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -20,15 +20,21 @@
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.NamingConventionBinder;
using System.CommandLine.Parsing;
using System.Reactive.Linq;
using System.Security.Cryptography;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Encryption;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
using Duplicati.Library.Main;
using Duplicati.Library.RemoteControl;
using Duplicati.Library.RestAPI;
using Duplicati.Library.Utility;
using Duplicati.Server;
using Duplicati.WebserverCore.Abstractions;
using Duplicati.WebserverCore.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Duplicati.Agent;
@@ -40,20 +46,16 @@ public static class Program
/// </summary>
private static readonly string LogTag = Log.LogTagFromType(typeof(Program));
/// <summary>
/// Create a random pre-shared key for the agent
/// </summary>
private static string? PreSharedKey = null;
/// <summary>
/// Commandline arguments for the agent
/// </summary>
/// <param name="RegistrationUrl">The server URL to connect to</param>
/// <param name="AgentRegistrationUrl">The server URL to connect to</param>
/// <param name="AgentSettingsFile">The file to use for the agent settings</param>
/// <param name="AgentSettingsFilePassphrase">The passphrase for the agent settings file</param>
/// <param name="WebserviceListenInterface">The interface to listen on for the webserver</param>
/// <param name="WebservicePort">The port to listen on for the webserver</param>
/// <param name="WebservicePassword">The password for the webserver, random if none supplied</param>
/// <param name="SettingsEncryptionKey">The encryption key for the database settings</param>
/// <param name="WindowsEventLog">The Windows event log to write to</param>
/// <param name="DisableDbEncryption">Disable database encryption</param>
/// <param name="WebserviceResetJwtConfig">Reset the JWT configuration</param>
@@ -62,12 +64,19 @@ public static class Program
/// <param name="WebserviceDisableSigninTokens">Disable signin tokens for the webserver</param>
/// <param name="PingPongKeepalive">Enable ping-pong keepalive</param>
/// <param name="DisablePreSharedKey">Disable the pre-shared key</param>
/// <param name="KeepWebservicePassword">Keep the webserver password</param>
/// <param name="SecretProvider">The secret provider to use</param>
/// <param name="SecretProviderCache">The secret provider cache level</param>
/// <param name="SecretProviderPattern">The secret provider pattern</param>
private sealed record CommandLineArguments(
string RegistrationUrl,
string AgentRegistrationUrl,
FileInfo AgentSettingsFile,
string? AgentSettingsFilePassphrase,
bool AgentRegisterOnly,
string WebserviceListenInterface,
string WebservicePort,
string WebservicePassword,
string? SettingsEncryptionKey,
string WindowsEventLog,
bool DisableDbEncryption,
bool WebserviceResetJwtConfig,
@@ -75,7 +84,11 @@ public static class Program
bool WebserviceApiOnly,
bool WebserviceDisableSigninTokens,
bool PingPongKeepalive,
bool DisablePreSharedKey
bool DisablePreSharedKey,
bool KeepWebservicePassword,
string? SecretProvider,
SecretProviderHelper.CachingLevel SecretProviderCache,
string SecretProviderPattern
);
[STAThread]
@@ -85,56 +98,191 @@ public static class Program
var runcmd = new Command("run", "Runs the agent")
{
new Option<string>("registration-url", description: "The server URL to connect to", getDefaultValue: () => RegisterForRemote.DefaultRegisterationUrl),
new Option<FileInfo>("agent-settings-file", description: "The file to use for the agent settings", getDefaultValue: () => new FileInfo(Settings.DefaultSettingsFile)),
new Option<string>("webservice-listen-interface", description: "The interface to listen on for the webserver", getDefaultValue: () => "loopback"),
new Option<string>("webservice-port", description: "The port to listen on for the webserver", getDefaultValue: () => "8210"),
new Option<string?>("webservice-password", description: "The password for the webserver, random if none supplied", getDefaultValue: () => null),
new Option<string>("windows-eventlog", description: "The Windows event log to write to", getDefaultValue: () => "Duplicati"),
new Option<bool>("disable-db-encryption", description: "Disable database encryption", getDefaultValue: () => false),
new Option<bool>("webservice-reset-jwt-config", description: "Reset the JWT configuration", getDefaultValue: () => true),
new Option<string>("webservice-allowed-hostnames", description: "The allowed hostnames for the webserver", getDefaultValue: () => "127.0.0.1"),
new Option<bool>("webservice-api-only", description: "Only allow API access to the webserver", getDefaultValue: () => true),
new Option<bool>("webservice-disable-signin-tokens", description: "Disable signin tokens for the webserver", getDefaultValue: () => true),
new Option<bool>("ping-pong-keepalive", description: "Enable ping-pong keepalive", getDefaultValue: () => false)
new Option<string>("--agent-registration-url", description: "The server URL to connect to", getDefaultValue: () => RegisterForRemote.DefaultRegisterationUrl),
new Option<FileInfo>("--agent-settings-file", description: "The file to use for the agent settings", getDefaultValue: () => new FileInfo(Settings.DefaultSettingsFile)),
new Option<string?>("--agent-settings-file-passphrase", description: "The passphrase for the agent settings file", getDefaultValue: () => null),
new Option<bool>("--agent-register-only", description: "Only register the agent, then exit", getDefaultValue: () => false),
new Option<string>("--webservice-listen-interface", description: "The interface to listen on for the webserver", getDefaultValue: () => "loopback"),
new Option<string>("--webservice-port", description: "The port to listen on for the webserver", getDefaultValue: () => "8210"),
new Option<string?>("--webservice-password", description: "The password for the webserver, not set, or set to \"random\", a random value is used.", getDefaultValue: () => null),
new Option<string?>("--settings-encryption-key", description: "The encryption key for the database settings", getDefaultValue: () => null),
new Option<string>("--windows-eventlog", description: "The Windows event log to use as source.", getDefaultValue: () => ""),
new Option<bool>("--disable-db-encryption", description: "Disable database encryption", getDefaultValue: () => false),
new Option<bool>("--webservice-reset-jwt-config", description: "Reset the JWT configuration", getDefaultValue: () => true),
new Option<string>("--webservice-allowed-hostnames", description: "The allowed hostnames for the webserver", getDefaultValue: () => "127.0.0.1"),
new Option<bool>("--webservice-api-only", description: "Only allow API access to the webserver", getDefaultValue: () => true),
new Option<bool>("--webservice-disable-signin-tokens", description: "Disable signin tokens for the webserver", getDefaultValue: () => true),
new Option<bool>("--ping-pong-keepalive", description: "Enable ping-pong keepalive", getDefaultValue: () => false),
new Option<bool>("--disable-pre-shared-key", description: "Disable the pre-shared key that prevents outside access to the webserver", getDefaultValue: () => false),
new Option<bool>("--keep-webservice-password", description: "Disables the random password assigned to the webserver on startup if no password is provided", getDefaultValue: () => false),
new Option<string?>("--secret-provider", description: "The secret provider to use", getDefaultValue: () => null),
new Option<SecretProviderHelper.CachingLevel>("--secret-provider-cache", description: "The secret provider cache level", getDefaultValue: () => SecretProviderHelper.CachingLevel.None),
new Option<string>("--secret-provider-pattern", description: "The secret provider pattern", getDefaultValue: () => SecretProviderHelper.DEFAULT_PATTERN),
new Option<bool>($"--{DataFolderManager.PORTABLE_MODE_OPTION}", description: "Use portable mode for locating the database and storing configuration", getDefaultValue: () => DataFolderManager.PORTABLE_MODE),
new Option<DirectoryInfo?>($"--{DataFolderManager.SERVER_DATAFOLDER_OPTION}", description: "The datafolder to use for locating the database and storing configuration", getDefaultValue: () => new DirectoryInfo(DataFolderManager.DATAFOLDER)),
};
runcmd.Handler = CommandHandler.Create<CommandLineArguments>(x => RunAgent(x, args));
runcmd.Handler = CommandHandler.Create<CommandLineArguments>(RunAgent);
return new RootCommand("Duplicati Agent")
var clearCommand = new Command("clear", "Clears the agent settings")
{
runcmd
}.InvokeAsync(args);
new Option<FileInfo>("--agent-settings-file", description: "The file to use for the agent settings", getDefaultValue: () => new FileInfo(Settings.DefaultSettingsFile)),
};
clearCommand.Handler = CommandHandler.Create<FileInfo>(ClearAgentSettings);
var registerCommand = new Command("register", "Registers the agent and exits")
{
new Argument<string>("agent-registration-url", description: "The server URL to connect to", getDefaultValue: () => RegisterForRemote.DefaultRegisterationUrl) { Arity = ArgumentArity.ZeroOrOne },
new Option<FileInfo>("--agent-settings-file", description: "The file to use for the agent settings", getDefaultValue: () => new FileInfo(Settings.DefaultSettingsFile)),
new Option<string?>("--agent-settings-file-passphrase", description: "The passphrase for the agent settings file", getDefaultValue: () => null),
new Option<string?>("--secret-provider", description: "The secret provider to use", getDefaultValue: () => null),
new Option<string>("--secret-provider-pattern", description: "The secret provider pattern", getDefaultValue: () => SecretProviderHelper.DEFAULT_PATTERN),
};
registerCommand.Handler = CommandHandler.Create<string, FileInfo, string?, string?, string>(RegisterAgent);
var rootcmd = new RootCommand("Duplicati Agent")
{
runcmd,
clearCommand,
registerCommand
};
// rootcmd.Handler = CommandHandler.Create<CommandLineArguments>(RunAgent);
return new CommandLineBuilder(rootcmd)
.UseDefaults()
.UseExceptionHandler((ex, context) =>
{
if (ex is UserInformationException userInformationException)
{
Console.WriteLine("ErrorID: {0}", userInformationException.HelpID);
Console.WriteLine("Message: {0}", userInformationException.Message);
context.ExitCode = 2;
}
else
{
Console.WriteLine("Exception: " + ex);
context.ExitCode = 1;
}
})
.UseAdditionalHelpAliases()
.Build()
.InvokeAsync(args);
}
/// <summary>
/// Clears any local agent settings
/// </summary>
/// <param name="agentSettingsFile">The file to clear</param>
/// <returns>The exit code</returns>
private static Task<int> ClearAgentSettings(FileInfo agentSettingsFile)
{
if (File.Exists(agentSettingsFile.FullName))
File.Delete(agentSettingsFile.FullName);
return Task.FromResult(0);
}
/// <summary>
/// Registers the agent with the remote server
/// </summary>
/// <param name="agentRegistrationUrl">The URL to register the agent with</param>
/// <param name="agentSettingsFile">The file to save the settings to</param>
/// <param name="agentSettingsFilePassphrase">The passphrase for the settings file</param>
/// <param name="secretProvider">The secret provider to use</param>
/// <param name="secretProviderPattern">The secret provider pattern</param>
/// <returns></returns>
private static Task<int> RegisterAgent(string agentRegistrationUrl, FileInfo agentSettingsFile, string? agentSettingsFilePassphrase, string? secretProvider, string secretProviderPattern)
=> RunAgent(new CommandLineArguments(
AgentRegistrationUrl: string.IsNullOrWhiteSpace(agentRegistrationUrl)
? RegisterForRemote.DefaultRegisterationUrl
: agentRegistrationUrl,
AgentSettingsFile: agentSettingsFile,
AgentSettingsFilePassphrase: agentSettingsFilePassphrase,
AgentRegisterOnly: true,
WebserviceListenInterface: "loopback",
WebservicePort: "8210",
WebservicePassword: null!,
SettingsEncryptionKey: null,
WindowsEventLog: "",
DisableDbEncryption: false,
WebserviceResetJwtConfig: false,
WebserviceAllowedHostnames: "",
WebserviceApiOnly: true,
WebserviceDisableSigninTokens: true,
PingPongKeepalive: false,
DisablePreSharedKey: false,
KeepWebservicePassword: false,
SecretProvider: secretProvider,
SecretProviderCache: SecretProviderHelper.CachingLevel.None,
SecretProviderPattern: secretProviderPattern
));
/// <summary>
/// Runs the agent
/// </summary>
/// <param name="agentConfig">The configuration from the commandline</param>
/// <param name="args">All the arguments from the commandline</param>
/// <returns>The exit code</returns>
private static async Task<int> RunAgent(CommandLineArguments agentConfig, string[] args)
private static async Task<int> RunAgent(CommandLineArguments agentConfig)
{
// Prevent access to the webserver interface from anything but the agent
if (string.IsNullOrWhiteSpace(agentConfig.WebservicePassword))
agentConfig = agentConfig with { WebservicePassword = System.Security.Cryptography.RandomNumberGenerator.GetHexString(128) };
// Read the environment variable for the encryption key
if (string.IsNullOrWhiteSpace(agentConfig.SettingsEncryptionKey))
agentConfig = agentConfig with { SettingsEncryptionKey = Environment.GetEnvironmentVariable(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME) };
// Apply the secret provider, if present
if (!string.IsNullOrWhiteSpace(agentConfig.SecretProvider))
{
// We can set any string properties on the agent configuration
var agentProps = agentConfig.GetType().GetProperties()
.Where(x => x.CanRead && x.CanWrite)
.Where(x => !x.Name.StartsWith(nameof(CommandLineArguments.SecretProvider)))
.Where(x => x.PropertyType == typeof(string) || Nullable.GetUnderlyingType(x.PropertyType) == typeof(string))
.ToDictionary(x => x.Name, x => x);
// Grab potential options from the agent configuration
var options = agentProps.Select(x => (x.Key, Value: x.Value.GetValue(agentConfig)?.ToString()))
.Where(x => x.Value != null)
.ToDictionary(x => x.Key, x => x.Value);
// Patch with the secret provider options
options["secret-provider"] = agentConfig.SecretProvider;
options["secret-provider-cache"] = agentConfig.SecretProviderCache.ToString();
options["secret-provider-pattern"] = agentConfig.SecretProviderPattern;
FIXMEGlobal.SecretProvider = await SecretProviderHelper.ApplySecretProviderAsync([], [], options, TempFolder.SystemTempPath, null, CancellationToken.None);
// Apply the secret provider to the agent configuration
foreach (var prop in agentProps)
if (options.TryGetValue(prop.Key, out string? value))
prop.Value.SetValue(agentConfig, value);
}
if (agentConfig.KeepWebservicePassword)
{
// Check for conflicting options, require no password
if (!string.IsNullOrWhiteSpace(agentConfig.WebservicePassword))
throw new UserInformationException("Cannot use --keep-webservice-password with a provided password", "KeepWebservicePasswordWithPassword");
}
else
{
// Prevent access to the webserver interface from anything but the agent
if (string.Equals("random", agentConfig.WebservicePassword, StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(agentConfig.WebservicePassword))
agentConfig = agentConfig with { WebservicePassword = System.Security.Cryptography.RandomNumberGenerator.GetHexString(128) };
}
// Set the pre-shared key for the agent
if (!agentConfig.DisablePreSharedKey)
WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey = PreSharedKey = RandomNumberGenerator.GetHexString(128);
WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey = RandomNumberGenerator.GetHexString(128);
using var cts = new CancellationTokenSource();
var target = new ControllerMultiLogTarget(new ConsoleLogDestination(), LogMessageType.Information, null);
using (Log.StartScope(target))
{
if (OperatingSystem.IsWindows())
if (OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(agentConfig.WindowsEventLog))
{
if (!WindowsEventLogSource.SourceExists(agentConfig.WindowsEventLog))
{
Console.WriteLine("The Windows event log source does not exist, creating it");
Console.WriteLine($"The Windows event log {agentConfig.WindowsEventLog} does not exist, creating it");
try { WindowsEventLogSource.CreateEventSource(agentConfig.WindowsEventLog); }
catch (Exception ex) { Console.WriteLine("Failed to create the Windows event log source: {0}", ex.Message); }
catch (Exception ex) { Console.WriteLine($"Failed to create the Windows event log {agentConfig.WindowsEventLog}: {ex.Message}"); }
}
if (WindowsEventLogSource.SourceExists(agentConfig.WindowsEventLog))
@@ -143,18 +291,26 @@ public static class Program
Log.WriteMessage(LogMessageType.Information, LogTag, "AgentStarting", "Starting agent");
var settings = await Register(agentConfig.RegistrationUrl, agentConfig.AgentSettingsFile.FullName, cts.Token);
var settings = await Register(agentConfig.AgentRegistrationUrl, agentConfig.AgentSettingsFile.FullName, agentConfig.AgentSettingsFilePassphrase, cts.Token);
if (agentConfig.AgentRegisterOnly)
{
Log.WriteMessage(LogMessageType.Information, LogTag, "ClientRegistered", "Client registered, exiting");
return 0;
}
var t = await Task.WhenAny(
StartLocalServer(agentConfig, settings, args, cts.Token),
Task.Run(() => StartLocalServer(agentConfig, settings, cts.Token)),
KeepRemoteConnection.Start(
settings.ServerUrl,
settings.JWT,
settings.CertificateUrl,
settings.ServerCertificates,
cts.Token,
ReKey,
(m) => OnMessage(m, agentConfig)
OnConnect,
m => ReKey(m, agentConfig),
OnControl,
OnMessage
)
);
@@ -164,43 +320,48 @@ public static class Program
}
}
private static async Task OnMessage(KeepRemoteConnection.CommandMessage message, CommandLineArguments agentConfig)
private static Task<Dictionary<string, string?>> OnConnect(Dictionary<string, string?> metadata)
=> FIXMEGlobal.Provider.GetRequiredService<IRemoteControllerHandler>().OnConnect(metadata);
private static Task OnControl(KeepRemoteConnection.ControlMessage message)
=> FIXMEGlobal.Provider.GetRequiredService<IRemoteControllerHandler>().OnControl(message);
private static Task OnMessage(KeepRemoteConnection.CommandMessage message)
=> FIXMEGlobal.Provider.GetRequiredService<IRemoteControllerHandler>().OnMessage(message);
private static Task ReKey(ClaimedClientData keydata, CommandLineArguments agentConfig)
{
Log.WriteMessage(LogMessageType.Information, LogTag, "OnMessage", "Received message: {0}", message);
var provider = FIXMEGlobal.Provider.GetRequiredService<IJWTTokenProvider>();
var token = provider.CreateAccessToken("agent", provider.TemporaryFamilyId, TimeSpan.FromMinutes(2));
using var httpClient = FIXMEGlobal.Provider.GetRequiredService<IHttpClientFactory>().CreateClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
httpClient.BaseAddress = new Uri($"http://127.0.0.1:{agentConfig.WebservicePort}");
if (!string.IsNullOrWhiteSpace(WebserverCore.Middlewares.PreSharedKeyFilter.PreSharedKey))
httpClient.DefaultRequestHeaders.Add(WebserverCore.Middlewares.PreSharedKeyFilter.HeaderName, PreSharedKey);
await message.Handle(httpClient);
}
private static Task ReKey(ClaimedClientData keydata)
{
Log.WriteMessage(LogMessageType.Information, LogTag, "ReKey", "Rekeying the settings");
var settings = Settings.Load();
// ReKey is handled here because we store the config outside of the database
Log.WriteMessage(LogMessageType.Verbose, LogTag, "ReKey", "Rekeying the settings");
var settings = Settings.Load(agentConfig.AgentSettingsFile.FullName, agentConfig.AgentSettingsFilePassphrase);
if (!string.IsNullOrWhiteSpace(keydata.JWT) && settings.JWT != keydata.JWT)
settings = settings with { JWT = keydata.JWT };
if (keydata.ServerCertificates != null && keydata.ServerCertificates.Any())
settings = settings with { ServerCertificates = keydata.ServerCertificates };
if (!string.IsNullOrWhiteSpace(keydata.LocalEncryptionKey) && settings.SettingsEncryptionKey != keydata.LocalEncryptionKey)
// Only allow re-keying if the settings encryption key is not set
if (!FIXMEGlobal.SettingsEncryptionKeyProvidedExternally)
{
// Log.WriteMessage(LogMessageType.Information, LogTag, "ReKey", "Changing the local settings encryption key");
// TODO: Implement changing the database encryption key
// FIXMEGlobal.Provider.GetRequiredService<Connection>().ChangeDbKey(keydata.LocalEncryptionKey);
// settings = settings with { SettingsEncryptionKey = keydata.LocalEncryptionKey };
if (!string.IsNullOrWhiteSpace(keydata.LocalEncryptionKey) && settings.SettingsEncryptionKey != keydata.LocalEncryptionKey)
{
// Log.WriteMessage(LogMessageType.Verbose, LogTag, "ReKey", "Changing the local settings encryption key");
// TODO: Implement changing the database encryption key
// FIXMEGlobal.Provider.GetRequiredService<Connection>().ChangeDbKey(keydata.LocalEncryptionKey);
// settings = settings with { SettingsEncryptionKey = keydata.LocalEncryptionKey };
}
}
settings.Save();
return Task.CompletedTask;
}
/// <summary>
/// Runs the server, restarting on crashes
/// </summary>
/// <param name="args">The commandline arguments passed to the server</param>
/// <param name="cancellationToken">The cancellation token to use for the process</param>
/// <returns>An awaitable task</returns>
private static async Task RunServer(string[] args, CancellationToken cancellationToken)
{
cancellationToken.Register(() =>
@@ -237,11 +398,12 @@ public static class Program
/// </summary>
/// <param name="registrationUrl">The URL to register the machine with</param>
/// <param name="settingsFile">The file to save the settings to</param>
/// <param name="settingsPasshrase">The passphrase for the settings file</param>
/// <param name="cancellationToken">The cancellation token to use for the process</param>
/// <returns>The settings for the machine</returns>
private static async Task<Settings> Register(string registrationUrl, string settingsFile, CancellationToken cancellationToken)
private static async Task<Settings> Register(string registrationUrl, string settingsFile, string? settingsPasshrase, CancellationToken cancellationToken)
{
var settings = Settings.Load(settingsFile);
var settings = Settings.Load(settingsFile, settingsPasshrase);
if (string.IsNullOrWhiteSpace(settings.JWT))
{
Log.WriteMessage(LogMessageType.Information, LogTag, "ClientNoJWT", "No JWT found in settings, starting in registration mode");
@@ -269,7 +431,8 @@ public static class Program
JWT = claimedClientData.JWT,
ServerUrl = claimedClientData.ServerUrl,
SettingsEncryptionKey = claimedClientData.LocalEncryptionKey,
ServerCertificates = claimedClientData.ServerCertificates
ServerCertificates = claimedClientData.ServerCertificates,
CertificateUrl = claimedClientData.CertificateUrl
};
settings.Save();
@@ -284,29 +447,48 @@ public static class Program
/// </summary>
/// <param name="agentConfig">The agent configuration</param>
/// <param name="settings">The settings for the agent</param>
/// <param name="args">The commandline arguments</param>
/// <param name="cancellationToken">The cancellation token that stops the server</param>
/// <returns>An awaitable task</returns>
private static async Task StartLocalServer(CommandLineArguments agentConfig, Settings settings, string[] args, CancellationToken cancellationToken)
private static async Task StartLocalServer(CommandLineArguments agentConfig, Settings settings, CancellationToken cancellationToken)
{
// TODO: Look into pipes for Kestrel to prevent network access
// Lock down the instance, reset tokens and password
args = args.Concat([
$"--webservice-listen-interface={agentConfig.WebserviceListenInterface}",
$"--webservice-password={agentConfig.WebservicePassword}",
OperatingSystem.IsWindows() ? $"--windows-eventlog={agentConfig.WindowsEventLog}" : "",
$"--webservice-port={agentConfig.WebservicePort}",
$"--webservice-reset-jwt-config={agentConfig.WebserviceResetJwtConfig}",
$"--webservice-allowed-hostnames={agentConfig.WebserviceAllowedHostnames}",
$"--webservice-api-only={agentConfig.WebserviceApiOnly}",
$"--webservice-disable-signin-tokens={agentConfig.WebserviceDisableSigninTokens}"
])
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
string? settingsEncryptionKey = null;
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY")))
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", settings.SettingsEncryptionKey);
// Prefer the local key for the settings encryption
if (!string.IsNullOrWhiteSpace(agentConfig.SettingsEncryptionKey))
settingsEncryptionKey = agentConfig.SettingsEncryptionKey;
// Otherwise use server provided key
else if (!string.IsNullOrWhiteSpace(settings.SettingsEncryptionKey))
settingsEncryptionKey = settings.SettingsEncryptionKey;
// Helper method to support empty arguments
static string EncodeOption(string option, string? value)
{
if (string.IsNullOrWhiteSpace(value))
return "";
return $"{option}={value}";
}
// Lock down the instance, reset tokens and password,
// and forward relevant settings from agent to the webserver
var args = new[] {
EncodeOption("--webservice-listen-interface", agentConfig.WebserviceListenInterface),
EncodeOption("--webservice-password", agentConfig.WebservicePassword),
EncodeOption("--webservice-port", agentConfig.WebservicePort),
EncodeOption("--webservice-reset-jwt-config", agentConfig.WebserviceResetJwtConfig.ToString()),
EncodeOption("--webservice-allowed-hostnames", agentConfig.WebserviceAllowedHostnames),
EncodeOption("--webservice-api-only", agentConfig.WebserviceApiOnly.ToString()),
EncodeOption("--webservice-disable-signin-tokens", agentConfig.WebserviceDisableSigninTokens.ToString()),
EncodeOption("--disable-db-encryption", agentConfig.DisableDbEncryption.ToString()),
EncodeOption("--settings-encryption-key", settingsEncryptionKey),
}
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToArray();
// Set the global origin
FIXMEGlobal.Origin = "Agent";
// Start the server
await RunServer(args, cancellationToken);
+54 -25
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -21,7 +21,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Duplicati.Library.Encryption;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using Duplicati.Library.RemoteControl;
@@ -35,13 +37,15 @@ namespace Duplicati.Agent;
/// <param name="ServerUrl">The URL of the server</param>
/// <param name="SettingsEncryptionKey">The encryption key for the local settings</param>
/// <param name="ServerCertificates">The server certificates</param>
/// <param name="Key">The encryption key to use for agent settings</param>
public sealed record Settings(
string Filename,
string JWT,
string ServerUrl,
string CertificateUrl,
string? SettingsEncryptionKey,
IEnumerable<MiniServerCertificate> ServerCertificates
IEnumerable<MiniServerCertificate> ServerCertificates,
EncryptedFieldHelper.KeyInstance? Key
)
{
/// <summary>
@@ -58,23 +62,50 @@ public sealed record Settings(
/// </summary>
private static readonly EncryptedFieldHelper.KeyInstance? DefaultKey = null;
/// <summary>
/// Shared JSON serialization options
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Loads the settings from a file
/// </summary>
/// <param name="path">The path to the file</param>
/// <returns>The loaded settings</returns>
public static Settings Load(string? path = null)
public static Settings Load(string path, string? key)
{
path ??= DefaultSettingsFile;
var tmp = JsonSerializer.Deserialize<Settings>(File.ReadAllText(path))
?? new Settings(path, string.Empty, string.Empty, string.Empty, string.Empty, Array.Empty<MiniServerCertificate>());
var keyInstance = string.IsNullOrWhiteSpace(key)
? DefaultKey
: EncryptedFieldHelper.KeyInstance.CreateKey(key);
return tmp with
var tmp = (File.Exists(path) ? JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), JsonOptions) : null)
?? new Settings(path, string.Empty, string.Empty, string.Empty, string.Empty, Array.Empty<MiniServerCertificate>(), null);
try
{
Filename = path,
JWT = EncryptedFieldHelper.Decrypt(tmp.JWT, DefaultKey),
SettingsEncryptionKey = EncryptedFieldHelper.Decrypt(tmp.SettingsEncryptionKey, DefaultKey)
};
return tmp with
{
Filename = path,
JWT = EncryptedFieldHelper.Decrypt(tmp.JWT, keyInstance),
SettingsEncryptionKey = EncryptedFieldHelper.Decrypt(tmp.SettingsEncryptionKey, keyInstance),
Key = keyInstance
};
}
catch (SettingsEncryptionKeyMismatchException sek)
{
throw new UserInformationException("Invalid settings key provided", "InvalidAgentSettingsKey", sek);
}
catch (SettingsEncryptionKeyMissingException sek)
{
throw new UserInformationException("Settings file is encrypted but key is missing", "AgentSettingsKeyMissing", sek);
}
}
/// <summary>
@@ -82,16 +113,16 @@ public sealed record Settings(
/// </summary>
public void Save()
{
var tmp = DefaultKey != null && !DefaultKey.IsBlacklisted
? this
: this with
var tmp = this with { Key = null, Filename = null! };
tmp = Key == null || Key.IsBlacklisted
? tmp
: tmp with
{
Filename = null!,
JWT = EncryptFieldIfPossible(JWT),
SettingsEncryptionKey = EncryptFieldIfPossible(SettingsEncryptionKey)
JWT = EncryptFieldIfPossible(JWT, Key),
SettingsEncryptionKey = EncryptFieldIfPossible(SettingsEncryptionKey, Key)
};
File.WriteAllText(Filename, JsonSerializer.Serialize(tmp));
File.WriteAllText(Filename, JsonSerializer.Serialize(tmp, JsonOptions));
}
/// <summary>
@@ -100,15 +131,15 @@ public sealed record Settings(
/// <param name="value">The value to encrypt</param>
/// <returns>The encrypted value</returns>
[return: NotNullIfNotNull("value")]
private static string? EncryptFieldIfPossible(string? value)
private static string? EncryptFieldIfPossible(string? value, EncryptedFieldHelper.KeyInstance? key)
{
if (DefaultKey == null || DefaultKey.IsBlacklisted)
if (key == null || key.IsBlacklisted)
return value;
if (value == null)
return null;
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
return EncryptedFieldHelper.Encrypt(value, DefaultKey);
return EncryptedFieldHelper.Encrypt(value, key);
}
/// <summary>
@@ -117,10 +148,8 @@ public sealed record Settings(
/// <returns>The path to the settings file</returns>
private static string GetSettingsPath()
{
var folder = DatabaseLocator.GetDefaultStorageFolderWithDebugSupport(DefaultSettingsFilename);
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
// Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility
var folder = Library.AutoUpdater.DataFolderLocator.GetDefaultStorageFolder(DefaultSettingsFilename, true);
return Path.Combine(folder, DefaultSettingsFilename);
}
}
@@ -4,7 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<Description>A backend debugging tool for Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.BackendTester.Implementation</AssemblyName>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
<ItemGroup>
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
+184 -122
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -21,24 +21,20 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Duplicati.Library.Interface;
using System.Linq;
using System.Globalization;
using System.Threading;
using Duplicati.Library.Utility;
using Duplicati.StreamUtil;
using System.Threading.Tasks;
namespace Duplicati.CommandLine.BackendTester
{
public class Program
{
/// <summary>
/// Used to maintain a reference to initialized system settings.
/// </summary>
#pragma warning disable CS0414 // The private field `Duplicati.CommandLine.BackendTester.Program.SystemSettings' is assigned but its value is never used
private static IDisposable SystemSettings;
#pragma warning restore CS0414 // The private field `Duplicati.CommandLine.BackendTester.Program.SystemSettings' is assigned but its value is never used
class TempFile
{
public readonly string remotefilename;
@@ -88,12 +84,12 @@ namespace Duplicati.CommandLine.BackendTester
List<string> args = new List<string>(_args);
Dictionary<string, string> options = Library.Utility.CommandLineParser.ExtractOptions(args);
if (args.Count != 1 || String.Equals(args[0], "help", StringComparison.OrdinalIgnoreCase) || args[0] == "?")
if (args.Count != 1 || HelpOptionExtensions.IsArgumentAnyHelpString(args))
{
Console.WriteLine("Usage: <protocol>://<username>:<password>@<path>");
Console.WriteLine("Example: ftp://user:pass@server/folder");
Console.WriteLine();
Console.WriteLine("Supported backends: " + string.Join(",", Duplicati.Library.DynamicLoader.BackendLoader.Keys));
Console.WriteLine($"Supported backends: {string.Join(",", Duplicati.Library.DynamicLoader.BackendLoader.Keys)}");
Console.WriteLine();
List<string> lines = new List<string>();
@@ -109,7 +105,7 @@ namespace Duplicati.CommandLine.BackendTester
if (options.ContainsKey("tempdir") && !string.IsNullOrEmpty(options["tempdir"]))
Library.Utility.SystemContextSettings.DefaultTempPath = options["tempdir"];
SystemSettings = Duplicati.Library.Utility.SystemContextSettings.StartSession();
using var SystemSettings = Duplicati.Library.Utility.SystemContextSettings.StartSession();
if (!options.ContainsKey("auth_password") && !string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("AUTH_PASSWORD")))
options["auth_password"] = System.Environment.GetEnvironmentVariable("AUTH_PASSWORD");
@@ -123,16 +119,17 @@ namespace Duplicati.CommandLine.BackendTester
for (int i = 0; i < reruns; i++)
{
Console.WriteLine("Starting run no {0}", i);
Console.WriteLine($"{DateTime.Now.ToShortDateString()} {DateTime.Now.ToLongTimeString()}");
Console.WriteLine($"{LogTimeStamp}Starting run no {i}");
if (!Run(args, options, i == 0))
return 1;
}
Console.WriteLine("Unittest complete!");
Console.WriteLine($"{LogTimeStamp}Unittest complete!");
return 0;
}
catch (Exception ex)
{
Console.WriteLine("Unittest failed: " + ex);
Console.WriteLine($"{LogTimeStamp}Unittest failed: {ex}");
}
return 1;
@@ -140,32 +137,31 @@ namespace Duplicati.CommandLine.BackendTester
static bool Run(List<string> args, Dictionary<string, string> options, bool first)
{
Library.Interface.IBackend backend = Library.DynamicLoader.BackendLoader.GetBackend(args[0], options);
bool failAfterFinished = false;
var backend = Library.DynamicLoader.BackendLoader.GetBackend(args[0], options);
if (backend == null)
{
Console.WriteLine("Unsupported backend");
Console.WriteLine();
Console.WriteLine("Supported backends: " + string.Join(",", Duplicati.Library.DynamicLoader.BackendLoader.Keys));
Console.WriteLine($"Supported backends: {string.Join(",", Duplicati.Library.DynamicLoader.BackendLoader.Keys)}");
return false;
}
string allowedChars = ValidFilenameChars;
var allowedChars = ValidFilenameChars;
if (options.ContainsKey("extended-chars"))
{
allowedChars += String.IsNullOrEmpty(options["extended-chars"]) ? ExtendedChars : options["extended-chars"];
}
bool autoCreateFolders = Library.Utility.Utility.ParseBoolOption(options, "auto-create-folder");
var autoCreateFolders = Library.Utility.Utility.ParseBoolOption(options, "auto-create-folder");
string disabledModulesValue;
string enabledModulesValue;
options.TryGetValue("enable-module", out enabledModulesValue);
options.TryGetValue("disable-module", out disabledModulesValue);
string[] enabledModules = enabledModulesValue == null ? new string[0] : enabledModulesValue.Trim().ToLower(CultureInfo.InvariantCulture).Split(',');
string[] disabledModules = disabledModulesValue == null ? new string[0] : disabledModulesValue.Trim().ToLower(CultureInfo.InvariantCulture).Split(',');
options.TryGetValue("enable-module", out var enabledModulesValue);
options.TryGetValue("disable-module", out var disabledModulesValue);
var enabledModules = enabledModulesValue == null ? new string[0] : enabledModulesValue.Trim().ToLower(CultureInfo.InvariantCulture).Split(',');
var disabledModules = disabledModulesValue == null ? new string[0] : disabledModulesValue.Trim().ToLower(CultureInfo.InvariantCulture).Split(',');
List<Library.Interface.IGenericModule> loadedModules = new List<IGenericModule>();
foreach (Library.Interface.IGenericModule m in Library.DynamicLoader.GenericLoader.Modules)
var loadedModules = new List<IGenericModule>();
foreach (var m in Library.DynamicLoader.GenericLoader.Modules)
if (!disabledModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase) && (m.LoadAsDefault || enabledModules.Contains(m.Key, StringComparer.OrdinalIgnoreCase)))
{
m.Configure(options);
@@ -178,7 +174,7 @@ namespace Duplicati.CommandLine.BackendTester
try
{
backend.TestAsync(CancellationToken.None).Await();
curlist = backend.List();
curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
}
catch (FolderMissingException)
{
@@ -187,11 +183,11 @@ namespace Duplicati.CommandLine.BackendTester
try
{
backend.CreateFolderAsync(CancellationToken.None).Await();
curlist = backend.List();
curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
}
catch (Exception ex)
{
Console.WriteLine("Autocreate folder failed with message: " + ex.Message);
Console.WriteLine($"{LogTimeStamp}Autocreate folder failed with message: {ex.Message}");
}
}
@@ -205,49 +201,74 @@ namespace Duplicati.CommandLine.BackendTester
if (Library.Utility.Utility.ParseBoolOption(options, "auto-clean") && first)
if (Library.Utility.Utility.ParseBoolOption(options, "force"))
{
Console.WriteLine("Auto clean, removing file: {0}", fe.Name);
Console.WriteLine($"{LogTimeStamp}Auto clean, removing file: {fe.Name}");
backend.DeleteAsync(fe.Name, CancellationToken.None).Await();
continue;
}
else
Console.WriteLine("Specify the --force flag to actually delete files");
Console.WriteLine("*** Remote folder is not empty, aborting");
var fileCount = curlist.Where(x => !x.IsFolder).Count();
var filenames = curlist.Where(x => !x.IsFolder).Select(x => x.Name).Take(10).ToList();
Console.WriteLine($"{LogTimeStamp}*** Remote folder contains {fileCount} file(s), aborting");
Console.WriteLine($"{LogTimeStamp}*** First {filenames.Count} file(s): {Environment.NewLine}{string.Join(Environment.NewLine, filenames)}");
if (fileCount > filenames.Count)
Console.WriteLine($"{LogTimeStamp}*** ... and {fileCount - filenames.Count} more file(s)");
return false;
}
int number_of_files = 10;
int min_file_size = 1024;
int max_file_size = 1024 * 1024 * 50;
int min_filename_size = 5;
int max_filename_size = 80;
bool disableStreaming = Library.Utility.Utility.ParseBoolOption(options, "disable-streaming-transfers");
bool skipOverwriteTest = Library.Utility.Utility.ParseBoolOption(options, "skip-overwrite-test");
bool trimFilenameSpaces = Library.Utility.Utility.ParseBoolOption(options, "trim-filename-spaces");
var number_of_files = 10;
var min_file_size = 1024;
var max_file_size = 1024 * 1024 * 50;
var min_filename_size = 5;
var max_filename_size = 80;
var disableStreaming = Library.Utility.Utility.ParseBoolOption(options, "disable-streaming-transfers");
var skipOverwriteTest = Library.Utility.Utility.ParseBoolOption(options, "skip-overwrite-test");
var trimFilenameSpaces = Library.Utility.Utility.ParseBoolOption(options, "trim-filename-spaces");
var waitAfterUpload = TimeSpan.Zero;
var waitAfterDelete = TimeSpan.Zero;
long throttleUpload = 0;
if (options.TryGetValue("throttle-upload", out string throttleUploadString))
var throttleUpload = 0L;
if (options.TryGetValue("throttle-upload", out var throttleUploadString))
{
if (!(backend is IStreamingBackend) || disableStreaming)
{
Console.WriteLine("Warning: Throttling is only supported in this tool on streaming backends");
Console.WriteLine($"{LogTimeStamp}Warning: Throttling is only supported in this tool on streaming backends");
}
throttleUpload = Duplicati.Library.Utility.Sizeparser.ParseSize(throttleUploadString, "kb");
}
long throttleDownload = 0;
if (options.TryGetValue("throttle-download", out string throttleDownloadString))
var throttleDownload = 0L;
if (options.TryGetValue("throttle-download", out var throttleDownloadString))
{
if (!(backend is IStreamingBackend) || disableStreaming)
{
Console.WriteLine("Warning: Throttling is only supported in this tool on streaming backends");
Console.WriteLine($"{LogTimeStamp}Warning: Throttling is only supported in this tool on streaming backends");
}
throttleDownload = Duplicati.Library.Utility.Sizeparser.ParseSize(throttleDownloadString, "kb");
}
int readWriteTimeout = (Environment.GetEnvironmentVariable("READ_WRITE_TIMEOUT_SECONDS") is { } timeout
&& int.TryParse(timeout, out var seconds)
&& seconds == -1)
? Timeout.Infinite
: Environment.GetEnvironmentVariable("READ_WRITE_TIMEOUT_SECONDS") is { } timeoutRetry
&& int.TryParse(timeoutRetry, out var secondsRetry)
? (int)TimeSpan.FromSeconds(secondsRetry).TotalMilliseconds
: (int)TimeSpan.FromSeconds(60).TotalMilliseconds;
Console.WriteLine($"{LogTimeStamp}Read Write Timeout set to {(readWriteTimeout == Timeout.Infinite ? "infinite" : readWriteTimeout + " ms")}");
// Allow overriding the timeout for the backend here, even if timeouts are disabled
if (options.TryGetValue("read-write-timeout", out var readWriteTimeoutString))
readWriteTimeout = (int)Timeparser.ParseTimeSpan(readWriteTimeoutString).TotalMilliseconds;
if (readWriteTimeout <= 0)
readWriteTimeout = Timeout.Infinite;
if (options.ContainsKey("number-of-files"))
number_of_files = int.Parse(options["number-of-files"]);
if (options.ContainsKey("min-file-size"))
@@ -260,46 +281,51 @@ namespace Duplicati.CommandLine.BackendTester
if (options.ContainsKey("max-filename-length"))
max_filename_size = int.Parse(options["max-filename-length"]);
Random rnd = new Random();
System.Security.Cryptography.SHA256 sha = System.Security.Cryptography.SHA256.Create();
if (options.ContainsKey("wait-after-upload"))
waitAfterUpload = Timeparser.ParseTimeSpan(options["wait-after-upload"]);
if (options.ContainsKey("wait-after-delete"))
waitAfterDelete = Timeparser.ParseTimeSpan(options["wait-after-delete"]);
var rnd = new Random();
var sha = System.Security.Cryptography.SHA256.Create();
//Create random files
using (Library.Utility.TempFolder tf = new Duplicati.Library.Utility.TempFolder())
using (var tf = new Duplicati.Library.Utility.TempFolder())
{
List<TempFile> files = new List<TempFile>();
var files = new List<TempFile>();
for (int i = 0; i < number_of_files; i++)
{
string filename = CreateRandomRemoteFileName(min_filename_size, max_filename_size, allowedChars, trimFilenameSpaces, rnd);
var filename = CreateRandomRemoteFileName(min_filename_size, max_filename_size, allowedChars, trimFilenameSpaces, rnd);
string localfilename = CreateRandomFile(tf, i, min_file_size, max_file_size, rnd);
var localfilename = CreateRandomFile(tf, i, min_file_size, max_file_size, rnd);
//Calculate local hash and length
using (System.IO.FileStream fs = new System.IO.FileStream(localfilename, System.IO.FileMode.Open, System.IO.FileAccess.Read))
using (var fs = new System.IO.FileStream(localfilename, System.IO.FileMode.Open, System.IO.FileAccess.Read))
files.Add(new TempFile(filename, localfilename, sha.ComputeHash(fs), fs.Length));
}
byte[] dummyFileHash = null;
if (!skipOverwriteTest)
{
Console.WriteLine("Uploading wrong files ...");
using (Library.Utility.TempFile dummy = Library.Utility.TempFile.WrapExistingFile(CreateRandomFile(tf, files.Count, 1024, 2048, rnd)))
Console.WriteLine($"{LogTimeStamp}Uploading wrong files ...");
using (var dummy = Library.Utility.TempFile.WrapExistingFile(CreateRandomFile(tf, files.Count, 1024, 2048, rnd)))
{
using (System.IO.FileStream fs = new System.IO.FileStream(dummy, System.IO.FileMode.Open, System.IO.FileAccess.Read))
using (var fs = new System.IO.FileStream(dummy, System.IO.FileMode.Open, System.IO.FileAccess.Read))
dummyFileHash = sha.ComputeHash(fs);
//Upload a dummy file for entry 0 and the last one, they will be replaced by the real files afterwards
//We upload entry 0 twice just to try to freak any internal cache list
Uploadfile(dummy, 0, files[0].remotefilename, backend, disableStreaming, throttleUpload);
Uploadfile(dummy, 0, files[0].remotefilename, backend, disableStreaming, throttleUpload);
Uploadfile(dummy, files.Count - 1, files[files.Count - 1].remotefilename, backend, disableStreaming, throttleUpload);
Uploadfile(dummy, 0, files[0].remotefilename, backend, disableStreaming, throttleUpload, readWriteTimeout);
Uploadfile(dummy, 0, files[0].remotefilename, backend, disableStreaming, throttleUpload, readWriteTimeout);
Uploadfile(dummy, files.Count - 1, files[files.Count - 1].remotefilename, backend, disableStreaming, throttleUpload, readWriteTimeout);
}
}
Console.WriteLine("Uploading files ...");
Console.WriteLine($"{LogTimeStamp}Uploading files ...");
for (int i = 0; i < files.Count; i++)
Uploadfile(files[i].localfilename, i, files[i].remotefilename, backend, disableStreaming, throttleUpload);
Uploadfile(files[i].localfilename, i, files[i].remotefilename, backend, disableStreaming, throttleUpload, readWriteTimeout);
TempFile originalRenamedFile = null;
string renamedFileNewName = null;
@@ -311,65 +337,77 @@ namespace Duplicati.CommandLine.BackendTester
renamedFileNewName = CreateRandomRemoteFileName(min_filename_size, max_filename_size, allowedChars, trimFilenameSpaces, rnd);
Console.WriteLine("Renaming file {0} from {1} to {2}", renameIndex, originalRenamedFile.remotefilename, renamedFileNewName);
Console.WriteLine($"{LogTimeStamp}Renaming file {renameIndex} from {originalRenamedFile.remotefilename} to {renamedFileNewName}");
renameEnabledBackend.RenameAsync(originalRenamedFile.remotefilename, renamedFileNewName, CancellationToken.None).Await();
files[renameIndex] = new TempFile(renamedFileNewName, originalRenamedFile.localfilename, originalRenamedFile.hash, originalRenamedFile.length);
}
Console.WriteLine("Verifying file list ...");
if (waitAfterUpload > TimeSpan.Zero)
{
Console.WriteLine($"{LogTimeStamp}Waiting {waitAfterUpload} after upload");
Thread.Sleep(waitAfterUpload);
}
curlist = backend.List();
foreach (Library.Interface.IFileEntry fe in curlist)
Console.WriteLine($"{LogTimeStamp}Verifying file list ...");
curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
foreach (var fe in curlist)
if (!fe.IsFolder)
{
bool found = false;
foreach (TempFile tx in files)
for (var i = 0; i < files.Count; i++)
{
var tx = files[i];
if (tx.remotefilename == fe.Name)
{
if (tx.found)
Console.WriteLine("*** File with name {0} was found more than once", tx.remotefilename);
Console.WriteLine($"{LogTimeStamp}*** File {i}) with name {tx.remotefilename} was found more than once");
found = true;
tx.found = true;
if (fe.Size > 0 && tx.length != fe.Size)
Console.WriteLine("*** File with name {0} has size {1} but the size was reported as {2}", tx.remotefilename, tx.length, fe.Size);
Console.WriteLine($"{LogTimeStamp}*** File {i} with name {tx.remotefilename} has size {tx.length} but the size was reported as {fe.Size}");
break;
}
}
if (!found)
if (originalRenamedFile != null && renamedFileNewName != null && originalRenamedFile.remotefilename == fe.Name)
{
Console.WriteLine("*** File with name {0} was found on server but was supposed to have been renamed to {1}!", fe.Name, renamedFileNewName);
Console.WriteLine($"{LogTimeStamp}*** File with name {fe.Name} was found on server but was supposed to have been renamed to {renamedFileNewName}!");
}
else
{
Console.WriteLine("*** File with name {0} was found on server but not uploaded!", fe.Name);
Console.WriteLine($"{LogTimeStamp}*** File with name {fe.Name} was found on server but not uploaded!");
}
}
foreach (TempFile tx in files)
if (!tx.found)
Console.WriteLine("*** File with name {0} was uploaded but not found afterwards", tx.remotefilename);
for (var i = 0; i < files.Count; i++)
if (!files[i].found)
Console.WriteLine($"{LogTimeStamp}*** File {i} with name {files[i].remotefilename} was uploaded but not found afterwards");
Console.WriteLine("Downloading files");
Console.WriteLine($"{LogTimeStamp}Downloading files");
for (int i = 0; i < files.Count; i++)
{
using (Duplicati.Library.Utility.TempFile cf = new Duplicati.Library.Utility.TempFile())
using (var cf = new Duplicati.Library.Utility.TempFile())
{
Exception e = null;
Console.Write("Downloading file {0} ... ", i);
Console.Write($"{LogTimeStamp}Downloading file {i} ... ");
var sw = Stopwatch.StartNew();
try
{
if (backend is IStreamingBackend streamingBackend && !disableStreaming)
{
using (System.IO.FileStream fs = new System.IO.FileStream(cf, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.None))
using (Library.Utility.ThrottledStream ts = new Library.Utility.ThrottledStream(fs, throttleDownload, throttleDownload))
using (NonSeekableStream nss = new NonSeekableStream(ts))
streamingBackend.GetAsync(files[i].remotefilename, nss, CancellationToken.None).Await();
using (var fs = new System.IO.FileStream(cf, System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.None))
using (var timeoutStream = new TimeoutObservingStream(fs) { WriteTimeout = readWriteTimeout })
using (var ts = new Library.Utility.ThrottledStream(timeoutStream, throttleDownload, throttleDownload))
using (var nss = new NonSeekableStream(ts))
streamingBackend.GetAsync(files[i].remotefilename, nss, timeoutStream.TimeoutToken).Await();
}
else
backend.GetAsync(files[i].remotefilename, cf, CancellationToken.None).Await();
@@ -382,72 +420,89 @@ namespace Duplicati.CommandLine.BackendTester
}
if (e != null)
Console.WriteLine("failed\n*** Error: {0}", e);
{
failAfterFinished = true;
Console.WriteLine($"{LogTimeStamp}failed\n*** Error: {e} after {sw.ElapsedMilliseconds} ms");
}
else
Console.WriteLine("done");
Console.WriteLine($" done in {sw.ElapsedMilliseconds} ms");
Console.Write("Checking hash ... ");
Console.Write($"{LogTimeStamp}Checking hash ... ");
using (System.IO.FileStream fs = new System.IO.FileStream(cf, System.IO.FileMode.Open, System.IO.FileAccess.Read))
using (var fs = new System.IO.FileStream(cf, System.IO.FileMode.Open, System.IO.FileAccess.Read))
if (Convert.ToBase64String(sha.ComputeHash(fs)) != Convert.ToBase64String(files[i].hash))
{
if (dummyFileHash != null && Convert.ToBase64String(sha.ComputeHash(fs)) == Convert.ToBase64String(dummyFileHash))
Console.WriteLine("failed\n*** Downloaded file was the dummy file");
Console.WriteLine($"{LogTimeStamp}failed\n*** Downloaded file was the dummy file"); // Should this be failed?
else
Console.WriteLine("failed\n*** Downloaded file was corrupt");
{
failAfterFinished = true;
Console.WriteLine($"{LogTimeStamp}failed\n*** Downloaded file was corrupt");
}
}
else
Console.WriteLine("done");
}
}
Console.WriteLine("Deleting files...");
Console.WriteLine($"{LogTimeStamp}Deleting files...");
foreach (TempFile tx in files)
try { backend.DeleteAsync(tx.remotefilename, CancellationToken.None).Await(); }
for (int i = 0; i < files.Count; i++)
try
{
Console.WriteLine($"{LogTimeStamp}Deleting file {i}");
backend.DeleteAsync(files[i].remotefilename, CancellationToken.None).Await();
}
catch (Exception ex)
{
Console.WriteLine("*** Failed to delete file {0}, message: {1}", tx.remotefilename, ex);
failAfterFinished = true;
Console.WriteLine($"{LogTimeStamp}*** Failed to delete file {files[i].remotefilename}, message: {ex}");
}
curlist = backend.List();
foreach (Library.Interface.IFileEntry fe in curlist)
if (waitAfterDelete > TimeSpan.Zero)
{
Console.WriteLine($"{LogTimeStamp}Waiting {waitAfterDelete} after delete");
Thread.Sleep(waitAfterDelete);
}
curlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
foreach (var fe in curlist)
if (!fe.IsFolder)
{
Console.WriteLine("*** Remote folder contains {0} after cleanup", fe.Name);
Console.WriteLine($"{LogTimeStamp}*** Remote folder contains {fe.Name} after cleanup");
}
// Test some error cases
Console.WriteLine("Checking retrieval of non-existent file...");
bool caughtExpectedException = false;
Console.WriteLine($"{LogTimeStamp}Checking retrieval of non-existent file...");
var caughtExpectedException = false;
try
{
using (Duplicati.Library.Utility.TempFile tempFile = new Duplicati.Library.Utility.TempFile())
using (var tempFile = new Duplicati.Library.Utility.TempFile())
{
backend.GetAsync(string.Format("NonExistentFile-{0}", Guid.NewGuid()), tempFile.Name, CancellationToken.None).Await();
backend.GetAsync($"NonExistentFile-{Guid.NewGuid()}", tempFile.Name, CancellationToken.None).Await();
}
}
catch (FileMissingException)
{
Console.WriteLine("Caught expected FileMissingException");
Console.WriteLine($"{LogTimeStamp}Caught expected FileMissingException");
caughtExpectedException = true;
}
catch (Exception ex)
{
Console.WriteLine("*** Retrieval of non-existent file failed: {0}", ex);
Console.WriteLine($"{LogTimeStamp}*** Retrieval of non-existent file failed: {ex}");
}
if (!caughtExpectedException)
{
Console.WriteLine("*** Retrieval of non-existent file should have failed with FileMissingException");
failAfterFinished = true;
Console.WriteLine($"{LogTimeStamp}*** Retrieval of non-existent file should have failed with FileMissingException");
}
}
// Test quota retrieval
IQuotaEnabledBackend quotaEnabledBackend = backend as IQuotaEnabledBackend;
if (quotaEnabledBackend != null)
if (backend is IQuotaEnabledBackend quotaEnabledBackend)
{
Console.WriteLine("Checking quota...");
Console.WriteLine($"{LogTimeStamp}Checking quota...");
IQuotaInfo quota = null;
bool noException;
try
@@ -457,7 +512,7 @@ namespace Duplicati.CommandLine.BackendTester
}
catch (Exception ex)
{
Console.WriteLine("*** Checking quota information failed: {0}", ex);
Console.WriteLine($"{LogTimeStamp}*** Checking quota information failed: {ex}");
noException = false;
}
@@ -465,18 +520,18 @@ namespace Duplicati.CommandLine.BackendTester
{
if (quota != null)
{
Console.WriteLine("Free Space: {0}", Library.Utility.Utility.FormatSizeString(quota.FreeQuotaSpace));
Console.WriteLine("Total Space: {0}", Library.Utility.Utility.FormatSizeString(quota.TotalQuotaSpace));
Console.WriteLine($"{LogTimeStamp}Free Space: {Library.Utility.Utility.FormatSizeString(quota.FreeQuotaSpace)}");
Console.WriteLine($"{LogTimeStamp}Total Space: {Library.Utility.Utility.FormatSizeString(quota.TotalQuotaSpace)}");
}
else
{
Console.WriteLine("Unable to retrieve quota information");
Console.WriteLine($"{LogTimeStamp}Unable to retrieve quota information");
}
}
}
// Test DNSName lookup
Console.WriteLine("Checking DNS names used by this backend...");
Console.WriteLine($"{LogTimeStamp}Checking DNS names used by this backend...");
try
{
var dnsNames = backend.GetDNSNamesAsync(CancellationToken.None).Await();
@@ -489,37 +544,40 @@ namespace Duplicati.CommandLine.BackendTester
}
else
{
Console.WriteLine("No DNS names reported");
Console.WriteLine($"{LogTimeStamp}No DNS names reported");
}
}
catch (Exception ex)
{
Console.WriteLine("*** Checking DNSName failed: {0}", ex);
Console.WriteLine($"{LogTimeStamp}*** Checking DNSName failed: {ex}");
}
}
finally
{
foreach (Library.Interface.IGenericModule m in loadedModules)
foreach (var m in loadedModules)
if (m is IDisposable disposable)
disposable.Dispose();
}
return true;
return !failAfterFinished;
}
private static void Uploadfile(string localfilename, int i, string remotefilename, IBackend backend, bool disableStreaming, long throttle)
private static void Uploadfile(string localfilename, int i, string remotefilename, IBackend backend, bool disableStreaming, long throttle, int readWriteTimeout)
{
Console.Write("Uploading file {0}, {1} ... ", i, Duplicati.Library.Utility.Utility.FormatSizeString(new System.IO.FileInfo(localfilename).Length));
Console.Write($"{LogTimeStamp}Uploading file {i}, {Duplicati.Library.Utility.Utility.FormatSizeString(new System.IO.FileInfo(localfilename).Length)} ... ");
Exception e = null;
var sw = Stopwatch.StartNew();
try
{
if (backend is IStreamingBackend streamingBackend && !disableStreaming)
{
using (System.IO.FileStream fs = new System.IO.FileStream(localfilename, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read))
using (Library.Utility.ThrottledStream ts = new Library.Utility.ThrottledStream(fs, throttle, throttle))
using (NonSeekableStream nss = new NonSeekableStream(ts))
streamingBackend.PutAsync(remotefilename, nss, CancellationToken.None).Await();
using (var fs = new System.IO.FileStream(localfilename, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read))
using (var timeoutStream = new TimeoutObservingStream(fs) { ReadTimeout = readWriteTimeout })
using (var ts = new Library.Utility.ThrottledStream(timeoutStream, throttle, throttle))
using (var nss = new NonSeekableStream(ts))
streamingBackend.PutAsync(remotefilename, nss, timeoutStream.TimeoutToken).Await();
}
else
backend.PutAsync(remotefilename, localfilename, CancellationToken.None).Await();
@@ -533,16 +591,16 @@ namespace Duplicati.CommandLine.BackendTester
if (e != null)
{
Console.WriteLine("Failed to upload file {0}, error message: {1}, remote name: {2}", i, e, remotefilename);
Console.WriteLine($"{LogTimeStamp}Failed to upload file {i}, error message: {e}, remote name: {remotefilename} after {sw.ElapsedMilliseconds} ms");
while (e.InnerException != null)
{
e = e.InnerException;
Console.WriteLine(" Inner exception: {0}", e);
Console.WriteLine($"{LogTimeStamp} Inner exception: {e}");
}
}
else
{
Console.WriteLine(" done!");
Console.WriteLine($" done! in {sw.ElapsedMilliseconds} ms");
}
}
@@ -562,7 +620,7 @@ namespace Duplicati.CommandLine.BackendTester
private static string CreateRandomFile(Library.Utility.TempFolder tf, int i, int min_file_size, int max_file_size, Random rnd)
{
Console.Write("Generating file {0}", i);
Console.Write($"{LogTimeStamp}Generating file {i}");
string filename = System.IO.Path.Combine(tf, i.ToString());
using (System.IO.FileStream fs = new System.IO.FileStream(filename, System.IO.FileMode.CreateNew, System.IO.FileAccess.Write))
{
@@ -570,7 +628,7 @@ namespace Duplicati.CommandLine.BackendTester
byte[] buf = new byte[1024];
int size = rnd.Next(min_file_size, max_file_size);
Console.WriteLine(" ({0})", Duplicati.Library.Utility.Utility.FormatSizeString(size));
Console.WriteLine($" ({Duplicati.Library.Utility.Utility.FormatSizeString(size)})");
while (size > 0)
{
@@ -583,6 +641,8 @@ namespace Duplicati.CommandLine.BackendTester
return filename;
}
private static string LogTimeStamp => $"[{DateTime.Now:HH:mm:ss fff}] ";
public static IList<ICommandLineArgument> SupportedCommands
{
get
@@ -601,6 +661,8 @@ namespace Duplicati.CommandLine.BackendTester
new CommandLineArgument("skip-overwrite-test", CommandLineArgument.ArgumentType.Boolean, "Bypass the overwrite test", "A value that indicates if dummy files should be uploaded prior to uploading the real files", "false"),
new CommandLineArgument("auto-clean", CommandLineArgument.ArgumentType.Boolean, "Remove any files found in target folder", "A value that indicates if all files in the target folder should be deleted before starting the first test", "false"),
new CommandLineArgument("force", CommandLineArgument.ArgumentType.Boolean, "Activate file deletion", "A value that indicates if existing files should really be deleted when using auto-clean", "false"),
new CommandLineArgument("wait-after-upload", CommandLineArgument.ArgumentType.Timespan, "Wait after all uploads", "A value that indicates how long to wait after all files are uploaded, to account for the backends eventual consistency", "0s"),
new CommandLineArgument("wait-after-delete", CommandLineArgument.ArgumentType.Timespan, "Wait after all deletes", "A value that indicates how long to wait after each delete operation, to account for the backends eventual consistency", "0s"),
});
}
}
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Duplicati.CommandLine.BackendTool.Implementation</AssemblyName>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
<ItemGroup>
+4 -3
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -26,6 +26,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Duplicati.CommandLine.BackendTool
{
@@ -74,7 +75,7 @@ namespace Duplicati.CommandLine.BackendTool
}
if (args.Count < 2 || String.Equals(args[0], "help", StringComparison.OrdinalIgnoreCase) || args[0] == "?" || command == null)
if (args.Count < 2 || HelpOptionExtensions.IsArgumentAnyHelpString(args) || command == null)
{
if (command == null && args.Count >= 2)
{
@@ -124,7 +125,7 @@ namespace Duplicati.CommandLine.BackendTool
throw new UserInformationException(string.Format("too many arguments: {0}", string.Join(",", args)), "BackendToolTooManyArguments");
Console.WriteLine("{0}\t{1}\t{2}\t{3}", "Name", "Dir/File", "LastChange", "Size");
foreach (var e in backend.List())
foreach (var e in backend.ListAsync(CancellationToken.None).ToBlockingEnumerable())
Console.WriteLine("{0}\t{1}\t{2}\t{3}", e.Name, e.IsFolder ? "Dir" : "File", e.LastModification, e.Size < 0 ? "" : Library.Utility.Utility.FormatSizeString(e.Size));
return 0;
+7 -6
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -163,7 +163,7 @@ namespace Duplicati.CommandLine
options.TryGetValue("dbpath", out dbpath);
if (string.IsNullOrEmpty(dbpath))
{
dbpath = Library.Main.DatabaseLocator.GetDatabasePath(backend, new Duplicati.Library.Main.Options(options), false, true);
dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(backend, new Library.Main.Options(options), false, true);
if (dbpath != null)
options["dbpath"] = dbpath;
}
@@ -366,7 +366,7 @@ namespace Duplicati.CommandLine
options.TryGetValue("dbpath", out dbpath);
if (string.IsNullOrEmpty(dbpath))
{
dbpath = Library.Main.DatabaseLocator.GetDatabasePath(backend, new Duplicati.Library.Main.Options(options), false, true);
dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(backend, new Duplicati.Library.Main.Options(options), false, true);
if (dbpath != null)
options["dbpath"] = dbpath;
}
@@ -622,7 +622,7 @@ namespace Duplicati.CommandLine
switch (phase)
{
case Duplicati.Library.Main.OperationPhase.Backup_ProcessingFiles:
output.MessageEvent("Scanning local files ...");
output.MessageEvent("Scanning source files ...");
periodicOutput.SetReady();
break;
case Duplicati.Library.Main.OperationPhase.Backup_Finalize:
@@ -668,6 +668,7 @@ namespace Duplicati.CommandLine
if (parsedStats.KnownFileCount > 0)
{
output.MessageEvent(string.Format(" Remote files: {0}", parsedStats.KnownFileCount));
output.MessageEvent(string.Format(" Remote filesets: {0}", parsedStats.KnownFilesets));
output.MessageEvent(string.Format(" Remote size: {0}", Library.Utility.Utility.FormatSizeString(parsedStats.KnownFileSize)));
}
@@ -820,7 +821,7 @@ namespace Duplicati.CommandLine
if (string.IsNullOrEmpty(dbpath))
{
if (args.Count > 0)
dbpath = Library.Main.DatabaseLocator.GetDatabasePath(args[0], new Duplicati.Library.Main.Options(options), false, true);
dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(args[0], new Duplicati.Library.Main.Options(options), false, true);
if (dbpath == null)
{
@@ -866,7 +867,7 @@ namespace Duplicati.CommandLine
options.TryGetValue("dbpath", out dbpath);
if (string.IsNullOrEmpty(dbpath))
{
dbpath = Library.Main.DatabaseLocator.GetDatabasePath(args[0], new Duplicati.Library.Main.Options(options), false, true);
dbpath = Library.Main.CLIDatabaseLocator.GetDatabasePathForCLI(args[0], new Duplicati.Library.Main.Options(options), false, true);
if (dbpath != null)
options["dbpath"] = dbpath;
}
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
@@ -5,7 +5,7 @@
<Description>A commandline version of Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.Implementation</AssemblyName>
<ApplicationIcon>TrayWarning.ico</ApplicationIcon>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
<ItemGroup>
@@ -22,6 +22,7 @@
<ProjectReference Include="..\RecoveryTool\Duplicati.CommandLine.RecoveryTool.csproj" />
<ProjectReference Include="..\..\Library\UsageReporter\Duplicati.Library.UsageReporter.csproj" />
<ProjectReference Include="..\..\Tools\Duplicati.Tools.csproj" />
<ProjectReference Include="..\..\Library\Crashlog\Duplicati.Library.Crashlog.csproj" />
</ItemGroup>
<ItemGroup>
+40 -4
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -26,6 +26,7 @@ using System.IO;
using System.Linq;
using System.Text;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.DynamicLoader;
using FilterGroup = Duplicati.Library.Utility.FilterGroup;
namespace Duplicati.CommandLine
@@ -109,14 +110,13 @@ namespace Duplicati.CommandLine
topic = "include";
}
if (_document.ContainsKey(topic))
{
string tp = _document[topic];
Library.Main.Options opts = new Library.Main.Options(new Dictionary<string, string>());
tp = tp.Replace("%CLI_EXE%", PackageHelper.GetExecutableName(PackageHelper.NamedExecutable.CommandLine));
tp = tp.Replace("%VERSION%", License.VersionNumbers.Version);
tp = tp.Replace("%VERSION%", License.VersionNumbers.VERSION_NAME);
tp = tp.Replace("%BACKENDS%", string.Join(", ", Library.DynamicLoader.BackendLoader.Keys));
tp = tp.Replace("%APP_PATH%", Path.Combine(UpdaterManager.INSTALLATIONDIR, PackageHelper.GetExecutableName(PackageHelper.NamedExecutable.CommandLine)));
tp = tp.Replace("%PATH_SEPARATOR%", System.IO.Path.PathSeparator.ToString());
@@ -260,6 +260,18 @@ namespace Duplicati.CommandLine
}
}
if (tp.Contains("%SECRETPROVIDERS%"))
{
var lines = new List<string>();
foreach (var module in SecretProviderLoader.Keys)
{
var metadata = SecretProviderLoader.GetProviderMetadata(module);
lines.Add($"- {module}: {metadata.DisplayName}");
}
tp = tp.Replace("%SECRETPROVIDERS%", string.Join(Environment.NewLine, lines.ToArray()));
}
if (NAMEDOPTION_REGEX.IsMatch(tp))
tp = NAMEDOPTION_REGEX.Replace(tp, new Matcher().MathEvaluator);
@@ -300,6 +312,14 @@ namespace Duplicati.CommandLine
break;
}
if (lines.Count == 0)
foreach (Duplicati.Library.Interface.ISecretProvider mod in SecretProviderLoader.Modules)
if (string.Equals(mod.Key, topic, StringComparison.OrdinalIgnoreCase))
{
PrintSecretProvider(mod, lines);
break;
}
if (lines.Count == 0)
PrintArgumentIfFound(new Matcher().Values, topic, lines);
@@ -395,6 +415,18 @@ namespace Duplicati.CommandLine
lines.Add("");
}
private static void PrintSecretProvider(Duplicati.Library.Interface.ISecretProvider mod, List<string> lines)
{
lines.Add($"{mod.Key}: {mod.Description}");
if (mod.SupportedCommands != null && mod.SupportedCommands.Count > 0)
{
lines.Add(" " + Strings.Program.SupportedOptionsHeader);
foreach (Library.Interface.ICommandLineArgument arg in mod.SupportedCommands)
Library.Interface.CommandLineArgument.PrintArgument(lines, arg, " ");
}
lines.Add("");
}
private static void PrintFormatted(TextWriter outwriter, IEnumerable<string> lines)
{
int windowWidth = 80;
@@ -432,11 +464,15 @@ namespace Duplicati.CommandLine
len -= leadingSpaces.Length;
if (len < c.Length)
{
int ix = c.LastIndexOf(" ", len, StringComparison.Ordinal);
int ix = c.LastIndexOf(' ', len);
if (ix > 0)
len = ix;
}
var lfix = c.IndexOf('\n');
if (lfix >= 0 && lfix < len)
len = lfix + 1;
outwriter.WriteLine(leadingSpaces + c.Substring(0, len).Trim());
c = c.Remove(0, len);
if (extraIndent)
+24 -16
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -26,11 +26,14 @@ using Duplicati.Library.Localization.Short;
using System.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Crashlog;
using Duplicati.Library.Utility;
namespace Duplicati.CommandLine
{
public class Program
{
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType<Program>();
public static bool FROM_COMMANDLINE = false;
@@ -46,14 +49,19 @@ namespace Duplicati.CommandLine
FROM_COMMANDLINE = true;
try
{
CrashlogHelper.OnUnobservedTaskException += LogUnobservedTaskExceptions;
return RunCommandLine(Console.Out, Console.Error, c => { }, args);
}
finally
{
Library.UsageReporter.Reporter.ShutDown();
CrashlogHelper.OnUnobservedTaskException -= LogUnobservedTaskExceptions;
}
}
private static void LogUnobservedTaskExceptions(Exception ex) => Console.Error.WriteLine("UnobservedTaskException: {0}", ex);
private static Dictionary<string, Func<TextWriter, Action<Library.Main.Controller>, List<string>, Dictionary<string, string>, Library.Utility.IFilter, int>> CommandMap
{
get
@@ -157,7 +165,7 @@ namespace Duplicati.CommandLine
}
// Probe for "help" to avoid extra processing
if (cargs.Count == 0 || (string.Equals(cargs[0], "help", StringComparison.OrdinalIgnoreCase)))
if (cargs.Count == 0 || HelpOptionExtensions.IsArgumentAnyHelpString(cargs))
{
return Commands.Help(outwriter, setup, cargs, options, filter);
}
@@ -175,6 +183,10 @@ namespace Duplicati.CommandLine
}
}
// Don't pass these options into the controller
options.Remove(Library.AutoUpdater.DataFolderManager.SERVER_DATAFOLDER_OPTION);
options.Remove(Library.AutoUpdater.DataFolderManager.PORTABLE_MODE_OPTION);
if (!options.ContainsKey("passphrase"))
if (!string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("PASSPHRASE")))
options["passphrase"] = System.Environment.GetEnvironmentVariable("PASSPHRASE");
@@ -277,20 +289,16 @@ namespace Duplicati.CommandLine
}
}
public static IList<Library.Interface.ICommandLineArgument> SupportedOptions
{
get
{
return new List<Library.Interface.ICommandLineArgument>(new Library.Interface.ICommandLineArgument[] {
new Library.Interface.CommandLineArgument("parameters-file", Library.Interface.CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", new string[] {"parameter-file", "parameterfile"}),
new Library.Interface.CommandLineArgument("include", Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.IncludeShort, Strings.Program.IncludeLong),
new Library.Interface.CommandLineArgument("exclude", Library.Interface.CommandLineArgument.ArgumentType.String, Strings.Program.ExcludeShort, Strings.Program.ExcludeLong),
new Library.Interface.CommandLineArgument("control-files", Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.ControlFilesOptionShort, Strings.Program.ControlFilesOptionLong, "false"),
new Library.Interface.CommandLineArgument("quiet-console", Library.Interface.CommandLineArgument.ArgumentType.Boolean, Strings.Program.QuietConsoleOptionShort, Strings.Program.QuietConsoleOptionLong, "false"),
new Library.Interface.CommandLineArgument("auto-update", Library.Interface.CommandLineArgument.ArgumentType.Boolean, LC.L("Toggle automatic updates"), LC.L("Set this option if you prefer to have the commandline version automatically update"), "false"),
});
}
}
public static IList<ICommandLineArgument> SupportedOptions => [
new CommandLineArgument("parameters-file", CommandLineArgument.ArgumentType.Path, Strings.Program.ParametersFileOptionShort, Strings.Program.ParametersFileOptionLong2, "", new string[] {"parameter-file", "parameterfile"}),
new CommandLineArgument("include", CommandLineArgument.ArgumentType.String, Strings.Program.IncludeShort, Strings.Program.IncludeLong),
new CommandLineArgument("exclude", CommandLineArgument.ArgumentType.String, Strings.Program.ExcludeShort, Strings.Program.ExcludeLong),
new CommandLineArgument("control-files", CommandLineArgument.ArgumentType.Boolean, Strings.Program.ControlFilesOptionShort, Strings.Program.ControlFilesOptionLong, "false"),
new CommandLineArgument("quiet-console", CommandLineArgument.ArgumentType.Boolean, Strings.Program.QuietConsoleOptionShort, Strings.Program.QuietConsoleOptionLong, "false"),
new CommandLineArgument("auto-update", CommandLineArgument.ArgumentType.Boolean, Strings.Program.AutoUpdateOptionShort, Strings.Program.AutoUpdateOptionLong, "false"),
new CommandLineArgument(DataFolderManager.PORTABLE_MODE_OPTION, CommandLineArgument.ArgumentType.Boolean, Strings.Program.PortableModeOptionShort, Strings.Program.PortableModeOptionLong, DataFolderManager.PORTABLE_MODE.ToString().ToLowerInvariant()),
new CommandLineArgument(DataFolderManager.SERVER_DATAFOLDER_OPTION, CommandLineArgument.ArgumentType.Path, Strings.Program.DataFolderOptionShort, Strings.Program.DataFolderOptionLong, DataFolderManager.DATAFOLDER),
];
private static bool ReadOptionsFromFile(TextWriter outwriter, string filename, ref Library.Utility.IFilter filter, List<string> cargs, Dictionary<string, string> options)
{
+7 -1
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -54,6 +54,12 @@ namespace Duplicati.CommandLine.Strings
public static string QuietConsoleOptionLong { get { return LC.L(@"If this option is set, progress reports and other messages that would normally go to the console will be redirected to the log."); } }
public static string QuietConsoleOptionShort { get { return LC.L(@"Disable console output"); } }
public static string SkippingSourceArgumentsOnNonBackupOperation { get { return @"The --source argument was specified in the parameter file, but the current operation is not a backup operation, so the argument is ignored."; } }
public static string AutoUpdateOptionShort { get { return LC.L(@"Toggle automatic updates"); } }
public static string AutoUpdateOptionLong { get { return LC.L(@"Set this option if you prefer to have the commandline version automatically update"); } }
public static string PortableModeOptionShort { get { return LC.L(@"Use portable mode"); } }
public static string PortableModeOptionLong { get { return LC.L(@"If this option is set, the configuration files will be stored in a data subfolder of the Duplicati installation folder. This is useful for running from a USB stick or other portable media."); } }
public static string DataFolderOptionShort { get { return LC.L(@"Data folder"); } }
public static string DataFolderOptionLong { get { return LC.L(@"The folder where data is stored. This is the folder where the database and other files are stored."); } }
// ReSharper disable once UnusedMember.Global
// This is a placeholder message that is intended to be used with the code
+113 -64
View File
@@ -1,9 +1,9 @@
# This file is the help for the Duplicati Command Line Client. It shows what
# This file is the help for the Duplicati Command Line Client. It shows what
# commands the CLI understands and what the outputs are. Sometimes we used
# placeholders for options. In that case descriptions are taken directly from
# the modules. Sometimes we described options here. The reason for that is,
# that we found the new description much better but did not want to create a
# need for translation as many options are described in the UI as well and
# the modules. Sometimes we described options here. The reason for that is,
# that we found the new description much better but did not want to create a
# need for translation as many options are described in the UI as well and
# they are translated there.
# =============================================================================
> duplicati.commandline.exe
@@ -18,6 +18,7 @@ See %CLI_EXE% help <topic> for more information.
Modules: %ENCRYPTIONMODULES%, %COMPRESSIONMODULES%, %GENERICMODULES%
Formats: date, time, size, decimal, encryption, compression
Advanced: mail, advanced, returncodes, filter, filter-groups, <option>
Secrets: secret, <provider>
https://duplicati.com/ Version: %VERSION%
@@ -28,8 +29,8 @@ https://duplicati.com/ Version: %VERSION%
Show the last changes to Duplicati
==================================
Duplicati updates automatically when a newer version is available. The following command will show a list of versions and changes.
Duplicati updates automatically when a newer version is available. The following command will show a list of versions and changes.
"%APP_PATH%" changelog
# =============================================================================
@@ -38,8 +39,8 @@ Show the last changes to Duplicati
Show system information as seen by Duplicati
============================================
Issue the following command to see a variety of system information relevant to Duplicati.
Issue the following command to see a variety of system information relevant to Duplicati.
"%APP_PATH%" system-info
# =============================================================================
@@ -48,30 +49,30 @@ Show system information as seen by Duplicati
Make an encrypted backup
========================
The following command will make an encrypted backup and store it on an FTP server. As no passphrase is specified, the user is prompted for a password. The upload speed is throttled to 500kB/s. Backups older than one month are considered old. Old backups are deleted automatically. The maximum file size is limited to 50MB (default).
The following command will make an encrypted backup and store it on an FTP server. As no passphrase is specified, the user is prompted for a password. The upload speed is throttled to 500kB/s. Backups older than one month are considered old. Old backups are deleted automatically. The maximum file size is limited to 50MB (default).
"%APP_PATH%" backup ftp://me:example.com@ftp.example.com/target "%EXAMPLE_SOURCE_PATH%" --throttle=500kB
Search for files in a backup
============================
The backup can be searched for specific files. The following command returns all files "%EXAMPLE_SOURCE_FILE%" of the last backup. File names can also contain the wildcards * and ?.
The backup can be searched for specific files. The following command returns all files "%EXAMPLE_SOURCE_FILE%" of the last backup. File names can also contain the wildcards * and ?.
"%APP_PATH%" find ftp://me:example.com@ftp.example.com/target "%EXAMPLE_SOURCE_FILE%"
Restore a specific file
=======================
The following command will restore "%EXAMPLE_SOURCE_FILE%" from the backup in the latest version to its original destination and overwrite an existing file.
"%APP_PATH%" restore ftp://me:example.com@ftp.example.com/target "%EXAMPLE_SOURCE_FILE%" --overwrite
# =============================================================================
> duplicati.commandline.exe help backup
Usage: %CLI_EXE% backup <storage-URL> "<source-path>" [<options>]
Makes a backup of <source-path> and stores it in <storage-URL>. The format for <storage-URL> is
protocol://username:password@hostname:port/path?backend_option1=value1&backend_option2=value2.
Makes a backup of <source-path> and stores it in <storage-URL>. The format for <storage-URL> is
protocol://username:password@hostname:port/path?backend_option1=value1&backend_option2=value2.
Multiple source paths can be specified if they are separated by a space.
%IF_WINDOWS%
On Windows clients, there are two special ways to specify the drive for files to back up. These two methods may help to back up sources on removable drives, whose drive letters may not be reliable.
@@ -106,9 +107,9 @@ Usage: %CLI_EXE% find <storage-URL> ["<filename>"] [<options>]
--version=<int>
Shows what the files looked like in a specific backup. If no version is specified the latest backup (version=0) will be used. If nothing is found, older backups will be searched automatically.
--include=<string>
Reduces the list of files in a backup to those that match the provided string. This is applied before the search is executed.
Reduces the list of files in a backup to those that match the provided string. This is applied before the search is executed.
--exclude=<string>
Removes matching files from the list of files in a backup. This is applied before the search is executed.
Removes matching files from the list of files in a backup. This is applied before the search is executed.
--all-versions=<boolean>
Searches in all backup sets, instead of just searching the latest
@@ -117,27 +118,33 @@ Usage: %CLI_EXE% find <storage-URL> ["<filename>"] [<options>]
> duplicati.commandline.exe help restore
Usage: %CLI_EXE% restore <storage-URL> ["<filename>"] [<options>]
Restores <filename> to its original destination. If <filename> exists already, <filename> is changed to <filename-timestamp.extension>. To restore all files use "*" or leave empty.
Restores <filename> to its original destination. If <filename> exists already and --overwrite=false is set, then <filename> is changed to <filename-timestamp.extension>. To restore all files use "*" or leave empty.
--overwrite=<boolean>
Overwrites existing files.
--restore-legacy=<bool>
Use this option to use the legacy restore method. The legacy restore method is slower than the new method, but may be more reliable in some cases. The default is false, which means that the new restore method is used.
--restore-path=<string>
Restores files to <restore-path> instead of their original destination. Top folders are removed if possible.
--restore-permissions=<bool>
By default permissions are not restored as they might prevent you from accessing your files. Use this option to restore the permissions as well.
--skip-metadata=<bool>
Use this option to skip the metadata restore. The default is false, which means that metadata is restored.
--time=<time>
Restore files that are older than the specified time.
--version=<int>
Restore files from a specific backup.
# =============================================================================
> duplicati.commandline.exe help delete
Usage: %CLI_EXE% delete <storage-URL> [<options>]
Marks old data deleted and removes outdated dlist files. A backup is deleted when it is older than <keep-time> or when there are more newer versions than <keep-versions>. Data is considered old, when it is not required from any existing backup anymore.
--dry-run
Performs the operation, but does not write changes to the local database or the remote storage
--keep-time=<time>
@@ -148,26 +155,26 @@ Usage: %CLI_EXE% delete <storage-URL> [<options>]
Deletes all files that belong to the specified version(s).
--allow-full-removal
Disables the protection against removing the final fileset
# =============================================================================
> duplicati.commandline.exe help compact
Usage: %CLI_EXE% compact <storage-URL> [<options>]
Old data is not deleted immediately as in most cases only small parts of a dblock file are old data. When the amount of old data in a dblock file grows it might be worth to replace it. This is especially the case when the number of dblock files and thus the required storage space can be reduced. When backups are frequently made and only few files have changed, the uploaded dblock files are small. At some point it might make sense to replace a large number of small files with one large file. This is what compacting does.
--small-file-max-count=<int>
The maximum allowed number of small files.
--small-file-size=<int>
Files smaller than this size are considered to be small and will be compacted with other small files as soon as there are <small-file-max-count> of them. --small-file-size=20 means 20% of <dblock-size>.
--threshold=<percent_value>
Files smaller than this size are considered to be small and will be compacted with other small files as soon as there are <small-file-max-count> of them. --small-file-size=20 means 20% of <dblock-size>.
--threshold=<percent_value>
The amount of old data that a dblock file can contain before it is considered to be replaced.
# =============================================================================
> duplicati.commandline.exe help compare
@@ -198,7 +205,7 @@ Usage: %CLI_EXE% create-report <storage-URL> <output-file> [<options>]
> duplicati.commandline.exe help repair
Usage: %CLI_EXE% repair <storage-URL> [<options>]
Tries to repair the backup. If no local db is found or the db is empty, the db is re-created with data from the storage. If the db is in place but the remote storage is corrupt, the remote storage gets repaired with local data (if available).
@@ -207,7 +214,7 @@ Usage: %CLI_EXE% repair <storage-URL> [<options>]
> duplicati.commandline.exe help purge
Usage: %CLI_EXE% purge <storage-URL> <filenames> [<options>]
Purges (removes) files from remote backup data. This command can either take a list of filenames or use the filters to choose which files to purge. The purge process creates new filesets on the remote destination with the purged files removed, and will start the compacting process after a purge. By default, the matching files are purged in all versions, but this can be limited by choosing one or more versions. To test what will happen, use the --dry-run flag.
--dry-run
@@ -229,7 +236,7 @@ Usage: %CLI_EXE% purge <storage-URL> <filenames> [<options>]
> duplicati.commandline.exe help purge-broken-files
Usage: %CLI_EXE% list-broken-files <storage-URL> [<options>]
Checks the database for missing data that cause files not not be restoreable. Files can become unrestoreable if remote data files are defect or missing. Use the list-broken-files command to see what the purge-broken-files command will remove.
Usage: %CLI_EXE% purge-broken-files <storage-URL> [<options>]
@@ -238,39 +245,39 @@ Usage: %CLI_EXE% purge-broken-files <storage-URL> [<options>]
--dry-run
Performs the operation, but does not write changes to the local database or the remote storage
# =============================================================================
> duplicati.commandline.exe help test
> duplicati.commandline.exe help verify
Usage: %CLI_EXE% test <storage-URL> <samples> [<options>]
Verifies integrity of a backup. A random sample of dlist, dindex, dblock files is downloaded, decrypted and the content is checked against recorded size values and data hashes. <samples> specifies the number of samples to be tested. If "all” is specified, all files in the backup will be tested. This is a rolling check, i.e. when executed another time different samples are verified than in the first run. A sample consists of 1 dlist, 1 dindex, 1 dblock.
--time=<time>
Checks samples from a specific time.
--version=<int>
Checks samples from specific versions. Delimiters are , -
--full-remote-verification
Checks the internal structure of each file instead of just verifying the file hash
# =============================================================================
> duplicati.commandline.exe help test-filters
> duplicati.commandline.exe help test-filter
Usage: %CLI_EXE% test-filters <source-path> [<options>]
Scans the source files and tests against the filters specified, the console output shows which files and folders are examined and the result.
# =============================================================================
> duplicati.commandline.exe help affected
Usage: %CLI_EXE% affected <storage-URL> <remote-filename> [<remote-filenames>] [<options>]
Returns a report explaining what backup sets and files are affected by a remote file. You can use this option to see what source files are affected if one or more remote files are damaged or deleted. Notes that this command requires a local database to be present, if the database is not found automatically, you can set --dbpath to point to the database to use.
# =============================================================================
@@ -278,8 +285,8 @@ Usage: %CLI_EXE% affected <storage-URL> <remote-filename> [<remote-filenames>] [
Usage: %CLI_EXE% vacuum <storage-URL> [<options>]
Rebuilds the local database, repacking it into a minimal amount of disk space.
Rebuilds the local database, repacking it into a minimal amount of disk space.
# =============================================================================
> duplicati.commandline.exe help debug
> duplicati.commandline.exe help logging
@@ -292,12 +299,12 @@ Duplicati provides information for debugging and logging purposes. By default, a
If something needs to be retried (e.g. upload failed) this will cause an entry in the log file.
--log-file = <path>
The path to the log file e.g. "D:\duplicati\log.txt".
--log-file-log-level = Profiling | Verbose | Information | Warning | Error
--log-file-log-level = Profiling | Verbose | Information | Warning | Error
Specifies the log level to use for the log file.
--console-log-level = Profiling | Verbose | Information | Warning | Error
--console-log-level = Profiling | Verbose | Information | Warning | Error
Specifies the log level to use for the console log.
# =============================================================================
> duplicati.commandline.exe help encryption
> duplicati.commandline.exe help compression
@@ -354,7 +361,7 @@ Options:
Duplicati can use FTP servers to store backups. The following target URL formats can be used:
ftp://hostname/folder
Options:
%MODULEOPTIONS%
@@ -364,7 +371,7 @@ Options:
Duplicati can use SSH servers to store backups. The following target URL formats can be used:
ssh://hostname/folder
Options:
%MODULEOPTIONS%
@@ -374,7 +381,7 @@ Options:
Duplicati can use WebDAV servers to store backups. The following target URL formats can be used:
webdav://hostname/folder"
Options:
%MODULEOPTIONS%
@@ -415,11 +422,11 @@ Options:
Duplicati supports absolute and relative dates and times:
now --> The current time
1234567890 --> A timestamp, seconds since 1970.
"2009-03-26T08:30:00+01:00" --> An absolute date and time. You can also use the local date and time format of your system like e.g. "01-14-2000" or "01 jan. 2004".
Y, M, D, W, h, m, s --> Relative date and time: year, month, day, week, hour, minute, second. Example: 2M10D5h is now + 2 months + 10 days + 5 hours.
# =============================================================================
@@ -431,7 +438,7 @@ Whenever a size is required, you can use any of the following suffixes:
kB - Kilobytes
MB - Megabytes
GB - Gigabytes
For speed limits, use the same format. If you enter 1MB this will be 1MB/sec.
# =============================================================================
@@ -447,7 +454,7 @@ For entering decimal values you must use the English notation, with a period as
> duplicati.commandline.exe help filter
> duplicati.commandline.exe help filters
Duplicati can apply globbing and regex filter rules to backup and restore specific files only. Globbing filters can be used in file names. To specify a regex filter put the filter in [brackets].
Duplicati can apply globbing and regex filter rules to backup and restore specific files only. Globbing filters can be used in file names. To specify a regex filter put the filter in [brackets].
Globbing: *.txt
Regex: [.*test\.txt]
@@ -477,12 +484,12 @@ Duplicati has several built-in groups of filters, which include commonly exclude
> duplicati.commandline.exe help send-mail
Duplicati can send email notifications after each operation. Use the send-mail command to test this:
"%APP_PATH%" send-mail --send-mail-to=<email-address>
--send-mail-to=<email-address>
Send an email to <email-address> after a backup. Valid formats are "Name <test@example.com>, Other <test2@example.com>, test3@example.com". Multiple addresses must be separated with a comma.
Send an email to <email-address> after a backup. Valid formats are "Name <test@example.com>, Other <test2@example.com>, test3@example.com". Multiple addresses must be separated with a comma.
--send-mail-from=<email-address>
This is the sender address of the email that is sent.
--send-mail-subject=<subject>
@@ -499,12 +506,12 @@ Duplicati can send email notifications after each operation. Use the send-mail c
When email messages are sent: "Success", "Warning", "Error", "Fatal", "All" are possible.
--send-mail-any-operation=true
Also send emails after other operations like restore etc.
Allowed placeholders are:
%PARSEDRESULT%
The parsed result op the operation: Success, Warning, Error
%RESULT%
When used in the body, this is the result/log of the backup,
When used in the body, this is the result/log of the backup,
When used in the subject line, this is the same as %PARSEDRESULT%
%OPERATIONNAME%
The name of the operation, usually "backup", but could also be "restore" etc.
@@ -512,7 +519,17 @@ Duplicati can send email notifications after each operation. Use the send-mail c
The backend url
%LOCALPATH%
The path to the local folders involved (i.e. the folders being backed up)
%machine-id%
The assigned unique random identifier for the current machine, can be overridden with --machine-id
%backup-id%
The assigned id for the backup, can be overridden with --backup-id
%backup-name%
The name of the backup, can be overridden with --backup-name
%machine-name%
The name of the machine, can be overridden with --machine-name
# =============================================================================
> duplicati.commandline.exe help advanced
> duplicati.commandline.exe help option
@@ -536,3 +553,35 @@ Duplicati reports the following return/exit codes:
50 - Backup uploaded some files, but did not finish
100 - An error occurred
200 - Invalid commandline arguments found
# =============================================================================
> duplicati.commandline.exe help secret
> duplicati.commandline.exe help secrets
> duplicati.commandline.exe help secret-provider
Duplicati supports reading secrets from an external secret provider.
The setup is done by adding an option:
--secret-provider=<config>
This setup will make Duplicati replace all values starting with a $-symbol, using the secret provider.
As an example, if the setting is:
--passphrase=$backup-passhrase
Duplicati will use the secret provider to fetch the secret for the key "backup-passphrase", and replace the value before starting the operation. If the value is not found the operation will fail.
Replacing can also be done inside the backend urls, such that a backend url could be:
s3://?aws-access-key-id=$awsid&aws-secret-access-key=$awskey
Here the values "awsid" and "awskey" would be fetched from the secret provider and replaced verbatim in the url (make sure to url encode the secrets in the provider).
To support more robust parsing, it is also possible to change the pattern from the default "$":
--secret-provider-pattern=!secret{}
This would extract keys inside that pattern, such as extracting "key name" from "!secret{key name}".
For providers that return a set of secrets, the key lookup is default case insensitive.
For providers that require a request per secret, the lookup is case-sensitive.
The following secret providers are supported:
%SECRETPROVIDERS%
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -25,6 +25,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Duplicati.Library.Utility;
using System.Threading.Tasks;
namespace Duplicati.CommandLine.RecoveryTool
{
@@ -56,7 +57,7 @@ namespace Duplicati.CommandLine.RecoveryTool
Console.WriteLine("Listing files on backend: {0} ...", backend.ProtocolKey);
var lst = backend.List().ToList();
var lst = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
Console.WriteLine("Found {0} files", lst.Count);
@@ -4,7 +4,7 @@
<TargetFramework>net8.0</TargetFramework>
<Description>RecoveryTool for Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.RecoveryTool.Implementation</AssemblyName>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
<ItemGroup>
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
@@ -1,4 +1,4 @@
// Copyright (C) 2024, The Duplicati Team
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
@@ -29,7 +29,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Duplicati.CommandLine.RecoveryTool
{
@@ -71,7 +71,7 @@ namespace Duplicati.CommandLine.RecoveryTool
Console.WriteLine("Listing files on backend: {0} ...", backend.ProtocolKey);
var rawlist = backend.List().ToList();
var rawlist = backend.ListAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
Console.WriteLine("Found {0} files at remote storage", rawlist.Count);
@@ -218,7 +218,7 @@ namespace Duplicati.CommandLine.RecoveryTool
{
// Correct inner filename extension to target compression type
cmfileNew = cmfileNew.Replace("." + cmFileVolume.CompressionModule, "." + target_compr_module);
if (!reencrypt)
if (!reencrypt && !string.IsNullOrWhiteSpace(cmFileVolume.EncryptionModule))
cmfileNew = cmfileNew.Replace("." + cmFileVolume.EncryptionModule, "");
//Because compression changes blocks file sizes - needs to be updated
+19 -19
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2024, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.SecretTool.Implementation</AssemblyName>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.CommandLine.NamingConventionBinder" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Library\DynamicLoader\Duplicati.Library.DynamicLoader.csproj" />
<ProjectReference Include="..\..\Library\Utility\Duplicati.Library.Utility.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.DotNet.Analyzers.Compatibility" Version="0.2.12-alpha">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+138
View File
@@ -0,0 +1,138 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.NamingConventionBinder;
using System.CommandLine.Parsing;
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
using Uri = System.Uri;
namespace Duplicati.CommandLine.SecretTool;
/// <summary>
/// The main entry point for the SecretTool application.
/// </summary>
public static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
public static Task<int> Main(string[] args)
{
Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.SecretTool);
var testcmd = new Command("test", "Tests the secret provider")
{
new Argument<string>("secret-url", description: "The connection string to the secret provider"),
new Argument<string[]>("secrets", description: "The secrets to fetch") { Arity = ArgumentArity.OneOrMore }
};
testcmd.Handler = CommandHandler.Create((string secretUrl, string[] secrets) => RunTest(secretUrl, secrets));
var infocmd = new Command("info", "Displays information about the secret provider")
{
new Argument<string>("secret-url", description: "The connection string to the secret provider, or just the leading part of the URL")
};
infocmd.Handler = CommandHandler.Create((string secretUrl) => ShowInfo(secretUrl));
var cmd = new RootCommand("Duplicati Secret Tool")
{
testcmd,
infocmd
};
return new CommandLineBuilder(cmd)
.UseDefaults()
.UseExceptionHandler((ex, context) =>
{
if (ex is UserInformationException userInformationException)
{
Console.WriteLine("ErrorID: {0}", userInformationException.HelpID);
Console.WriteLine("Message: {0}", userInformationException.Message);
context.ExitCode = 2;
}
else
{
Console.WriteLine("Exception: " + ex);
context.ExitCode = 1;
}
})
.UseAdditionalHelpAliases()
.Build()
.InvokeAsync(args);
}
private static async Task<int> RunTest(string secretUrl, string[] secrets)
{
var secretProvider = SecretProviderLoader.CreateInstance(secretUrl);
await secretProvider.InitializeAsync(new Uri(secretUrl), CancellationToken.None);
var result = await secretProvider.ResolveSecretsAsync(secrets, CancellationToken.None);
Console.WriteLine("NOTE: Secret values are not displayed for security reasons");
Console.WriteLine("Secrets:");
foreach (var secret in secrets)
Console.WriteLine($"- {secret}: {(result.ContainsKey(secret) ? "Found!" : "Not found")}");
return 0;
}
private static Task<int> ShowInfo(string secretUrl)
{
var p = secretUrl.IndexOf(':');
var key = p >= 0 ? secretUrl.Substring(0, p) : secretUrl;
try
{
var metadata = SecretProviderLoader.GetProviderMetadata(key);
Console.WriteLine($"Secret provider '{key}'");
var lines = metadata.Description.Trim().Split(new char[] { '\r', '\n' }, StringSplitOptions.None);
foreach (var line in lines)
Console.WriteLine($" {line.Trim()}");
Console.WriteLine();
if (metadata.SupportedCommands.Count > 0)
{
Console.WriteLine("Supported options:");
foreach (var cmd in metadata.SupportedCommands)
{
Console.WriteLine($" {cmd.Name}: {cmd.ShortDescription}");
Console.WriteLine($" Type: {cmd.Type}");
if (cmd.DefaultValue != null)
Console.WriteLine($" Default: {cmd.DefaultValue}");
if (cmd.ValidValues != null)
Console.WriteLine($" Valid values: {string.Join(", ", cmd.ValidValues)}");
Console.WriteLine($" {cmd.LongDescription}");
Console.WriteLine();
}
}
}
catch (Exception ex)
{
Console.WriteLine($"No information found for secret provider '{key}': {ex.Message}");
return Task.FromResult(1);
}
return Task.FromResult(0);
}
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Invocation;
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -10,19 +31,32 @@ public static class ChangePassword
{
new Argument<string>("new-password", "The new password to use") {
Arity = ArgumentArity.ZeroOrOne
},
}
}
.WithHandler(CommandHandler.Create<Settings, string>(async (settings, newPassword) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string>(async (settings, output, newPassword) =>
{
// Ask for previous password first, if needed
var connection = await settings.GetConnection();
var connection = await settings.GetConnection(output);
if (string.IsNullOrWhiteSpace(newPassword))
{
if (output.JsonOutputMode)
throw new UserReportedException("No password provided with json mode.");
newPassword = HelperMethods.ReadPasswordFromConsole("Please provide the new password: ");
}
if (string.IsNullOrWhiteSpace(newPassword))
throw new UserReportedException("No password provided");
if (settings.SecretProvider != null)
{
var opts = new Dictionary<string, string?> { { "password", newPassword } };
await settings.ReplaceSecrets(opts).ConfigureAwait(false);
newPassword = opts["password"]!;
}
await connection.ChangePassword(newPassword);
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -16,17 +37,17 @@ public static class Export
new Option<bool?>(name: "--export-passwords", description: "Flag toggling the inclusion of sensitive values, such as passwords, defaults to true if a passphrase is supplied", getDefaultValue: () => null),
new Option<bool>(name: "--overwrite", description: "Flag toggling the overwriting of existing files", getDefaultValue: () => false),
new Option<bool>(name: "--unencrypted", description: "Flag toggling unencrypted export of configurations", getDefaultValue: () => false),
new Option<DirectoryInfo>(name: "--destination", description: "The folder where the backup configuration should be exported to", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory())),
new Option<DirectoryInfo>(name: "--destination", description: "The folder where the backup configuration should be exported to", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory()))
}
.WithHandler(CommandHandler.Create<Settings, string[], string?, bool?, bool, bool, DirectoryInfo>(async (settings, backups, encryptionPassphrase, exportPasswords, overwrite, unencrypted, destination) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string[], string?, bool?, bool, bool, DirectoryInfo>(async (settings, output, backups, encryptionPassphrase, exportPasswords, overwrite, unencrypted, destination) =>
{
if (!destination.Exists)
{
Console.WriteLine($"Creating destination folder {destination.FullName}");
output.AppendConsoleMessage($"Creating destination folder {destination.FullName}");
destination.Create();
}
var connection = await settings.GetConnection();
var connection = await settings.GetConnection(output);
var serverbackups = await connection.ListBackups();
var includeAllBackups = backups.Any(x => string.Equals(x, "all", StringComparison.OrdinalIgnoreCase));
var targetbackups = serverbackups.Where(b => includeAllBackups || backups.Any(x => b.Name.Contains(x, StringComparison.OrdinalIgnoreCase)) || backups.Contains(b.ID.ToString())).ToArray();
@@ -40,29 +61,39 @@ public static class Export
if (!exportPasswords.HasValue)
{
Console.WriteLine("The --export-passwords flag is not set, sensitive keys will not be included in the exported file");
output.AppendConsoleMessage("The --export-passwords flag is not set, sensitive keys will not be included in the exported file");
exportPasswords = false;
}
else if (exportPasswords.Value)
{
Console.WriteLine("Warning: Exporting unencrypted configurations with sensitive keys included");
output.AppendConsoleMessage("Warning: Exporting unencrypted configurations with sensitive keys included");
}
}
else
{
if (string.IsNullOrWhiteSpace(encryptionPassphrase))
{
if (output.JsonOutputMode)
throw new UserReportedException("No passphrase provided in json mode, cannot proceed");
encryptionPassphrase = HelperMethods.ReadPasswordFromConsole("Please provide a passphrase to encrypt the backup configuration: ");
if (string.IsNullOrWhiteSpace(encryptionPassphrase))
throw new UserReportedException("No passphrase provided, use --unencrypted to export unencrypted configurations");
}
if (!exportPasswords.HasValue)
exportPasswords = true;
if (settings.SecretProvider != null)
{
var opts = new Dictionary<string, string?> { { "password", encryptionPassphrase } };
await settings.ReplaceSecrets(opts).ConfigureAwait(false);
encryptionPassphrase = opts["password"]!;
}
exportPasswords ??= true;
}
Console.WriteLine($"Exporting {targetbackups.Length} backup{(targetbackups.Length == 1 ? "" : "s")} to {destination.FullName}");
output.AppendConsoleMessage($"Exporting {targetbackups.Length} backup{(targetbackups.Length == 1 ? "" : "s")} to {destination.FullName}");
List<dynamic> exportedBackups = [];
foreach (var backup in targetbackups)
{
var name = backup.Name;
@@ -72,15 +103,18 @@ public static class Export
var file = new FileInfo(Path.Combine(destination.FullName, $"{backup.ID}-{backup.Name}.json{(unencrypted ? "" : ".aes")}"));
if (file.Exists && !overwrite)
{
Console.WriteLine($"Skipping existing file {file.FullName}, use --overwrite to force");
output.AppendConsoleMessage($"Skipping existing file {file.FullName}, use --overwrite to force");
continue;
}
using (var s = await connection.ExportBackup(backup.ID, encryptionPassphrase, exportPasswords.Value))
using (var fs = file.Create())
await using (var s = await connection.ExportBackup(backup.ID, encryptionPassphrase, exportPasswords.Value))
await using (var fs = file.Create())
await s.CopyToAsync(fs);
Console.WriteLine($"- Exported to {file.Name}");
exportedBackups.Add(new { Id = backup.ID, Name = backup.Name, File = file.FullName });
output.AppendConsoleMessage($"- Exported to {file.Name}");
}
output.AppendCustomObject("ExportedBackups", exportedBackups);
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -7,30 +28,31 @@ public static class Health
{
public static Command Create() =>
new Command("health", "Checks the server health endpoint")
.WithHandler(CommandHandler.Create<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
using var client = new HttpClient(new HttpClientHandler()
using var client = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = settings.Insecure
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null
})
{
BaseAddress = settings.HostUrl,
Timeout = TimeSpan.FromSeconds(10)
};
});
client.BaseAddress = settings.HostUrl;
client.Timeout = TimeSpan.FromSeconds(10);
try
{
var response = await client.GetAsync("health");
response.EnsureSuccessStatusCode();
Console.WriteLine("Server is healthy");
output.SetResult(true);
output.AppendCustomObject("healthy", true);
output.AppendConsoleMessage("Server is healthy");
return 0;
}
catch (HttpRequestException)
{
Console.WriteLine("Server is unhealthy");
output.AppendConsoleMessage("Server is unhealthy");
output.AppendCustomObject("healthy", false);
output.SetResult(false);
return 1;
}
})
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -16,25 +37,37 @@ public static class Import
},
new Option<bool>(name: "--import-metadata", description: "Import metadata from the backup", getDefaultValue: () => false)
}
.WithHandler(CommandHandler.Create<Settings, FileInfo, string, bool>(async (settings, file, passphrase, importMetadata) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, FileInfo, string, bool>(async (settings, output, file, passphrase, importMetadata) =>
{
if (!file.Exists)
throw new UserReportedException($"File {file.FullName} does not exist");
Console.WriteLine($"Importing backup configuration from {file.FullName}");
output.AppendConsoleMessage($"Importing backup configuration from {file.FullName}");
if (IsEncrypted(file))
{
if (output.JsonOutputMode)
throw new UserReportedException("No password provided with json mode.");
if (string.IsNullOrWhiteSpace(passphrase))
passphrase = HelperMethods.ReadPasswordFromConsole("The file is encrypted. Please provide the encryption password: ");
if (string.IsNullOrWhiteSpace(passphrase))
throw new UserReportedException("No password provided");
if (settings.SecretProvider != null)
{
var opts = new Dictionary<string, string?> { { "password", passphrase } };
await settings.ReplaceSecrets(opts).ConfigureAwait(false);
passphrase = opts["password"]!;
}
}
var connection = await settings.GetConnection();
var connection = await settings.GetConnection(output);
var result = await connection.ImportBackup(file.FullName, passphrase, importMetadata);
Console.WriteLine($"Imported \"{result.Name}\" with ID {result.ID}");
output.AppendConsoleMessage($"Imported \"{result.Name}\" with ID {result.ID}");
output.AppendCustomObject( "Imported",new {Id = result.ID, Name = result.Name});
output.SetResult(true);
}));
private static bool IsEncrypted(FileInfo file)
@@ -0,0 +1,44 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
namespace Duplicati.CommandLine.ServerUtil.Commands;
public static class IssueForeverToken
{
public static Command Create() =>
new Command("issue-forever-token", "Issues a long-lived access token").WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
var token = await (await settings.GetConnection(output)).CreateForeverToken();
output.AppendConsoleMessage("Token issued with a lifetime of 10 years.");
output.AppendConsoleMessage("Make sure you disable the forever token API on the server, to avoid generating new tokens.");
output.AppendConsoleMessage(string.Empty);
output.AppendConsoleMessage($"If you need to revoke the token, you can reset the JWT signing keys by restarting the server with the command '--{"reset-jwt-config"}=true', or the environment variable '{"DUPLICATI__RESET_JWT_CONFIG=true"}'.");
output.AppendConsoleMessage(string.Empty);
output.AppendConsoleMessage("The issued token is:");
output.AppendConsoleMessage($"Authorization: Bearer {token}");
output.AppendConsoleMessage(string.Empty);
output.AppendCustomObject( "Token" ,new { Token = token });;
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -7,22 +28,25 @@ public static class ListBackups
{
public static Command Create() =>
new Command("list-backups", "List all backups")
.WithHandler(CommandHandler.Create<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
var bks = await (await settings.GetConnection()).ListBackups();
var bks = await (await settings.GetConnection(output)).ListBackups();
if (!bks.Any())
var backupEntries = bks as Connection.BackupEntry[] ?? bks.ToArray();
if (backupEntries.Any())
{
Console.WriteLine("No backups found");
return;
foreach (var bk in backupEntries)
{
output.AppendConsoleMessage($"{bk.ID}: {bk.Name}");
if (!string.IsNullOrEmpty(bk.Description))
output.AppendConsoleMessage($" {bk.Description}");
output.AppendConsoleMessage(string.Empty);
}
output.AppendCustomObject("Backups", backupEntries.Select(id => new { Id = id.ID, Name = id.Name }).ToArray());
}
else
output.AppendConsoleMessage("No backups found");
foreach (var bk in bks)
{
Console.WriteLine($"{bk.ID}: {bk.Name}");
if (!string.IsNullOrEmpty(bk.Description))
Console.WriteLine($" {bk.Description}");
Console.WriteLine();
}
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -7,12 +28,12 @@ public static class Login
{
public static Command Create() =>
new Command("login", "Logs in to the server")
.WithHandler(CommandHandler.Create<Settings>(async (settings) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
Console.WriteLine("Logging in to the server");
await Connection.Connect(settings, true);
Console.WriteLine("Logged in, persistent token saved");
output.AppendConsoleMessage("Logging in to the server");
await Connection.Connect(settings, true, output);
output.AppendConsoleMessage("Logged in, persistent token saved");
output.SetResult(true);
})
);
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -7,7 +28,12 @@ public static class Logout
{
public static Command Create()
=> new Command("logout", "Logs out of the server")
.WithHandler(CommandHandler.Create<Settings>(async (settings) =>
await (await settings.GetConnection()).Logout(settings))
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
output.AppendConsoleMessage("Logging out of the server...");
await (await settings.GetConnection(output)).Logout(settings, output);
// If no exception we presume success
output.SetResult(true);
})
);
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -10,15 +31,17 @@ public static class Pause
{
new Argument<string?>("duration", description: "The duration to pause the server for", getDefaultValue: () => null) {
Arity = ArgumentArity.ZeroOrOne
},
}
}
.WithHandler(CommandHandler.Create<Settings, string?>(async (settings, duration) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string?>(async (settings, output, duration) =>
{
if (string.IsNullOrWhiteSpace(duration))
Console.WriteLine("Pausing the server indefinitely");
else
Console.WriteLine($"Pausing the server for {duration}");
output.AppendConsoleMessage(string.IsNullOrWhiteSpace(duration)
? "Pausing the server indefinitely"
: $"Pausing the server for {duration}");
await (await settings.GetConnection()).Pause(duration);
await (await settings.GetConnection(output)).Pause(duration);
// If no exception we presume success
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -7,7 +28,10 @@ public static class Resume
{
public static Command Create()
=> new Command("resume", "Resumes the server")
.WithHandler(CommandHandler.Create<Settings>(async (settings) =>
await (await settings.GetConnection()).Resume())
);
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
output.AppendConsoleMessage("Resuming the server...");
await (await settings.GetConnection(output)).Resume();
output.SetResult(true);
}));
}
@@ -1,3 +1,24 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
@@ -11,10 +32,22 @@ public static class RunBackup
new Argument<string>("backup", "The backup to run, either ID or exact name (case-insensitive)") {
Arity = ArgumentArity.ExactlyOne
},
new Option<bool>("--wait", "Wait for the backup to finish before returning") {
IsRequired = false
},
new Option<int>("--poll-interval", description: "The interval in seconds to poll for backup status", getDefaultValue: () => 5) {
IsRequired = false
},
new Option<bool>("--quiet", "Do not print progress messages") {
IsRequired = false
}
}
.WithHandler(CommandHandler.Create<Settings, string>(async (settings, backup) =>
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor, string, bool, int, bool>(async (settings, output, backup, wait, pollinterval, quiet) =>
{
var connection = await settings.GetConnection();
if (pollinterval < 1)
throw new UserReportedException("Poll interval must be at least 1 second");
var connection = await settings.GetConnection(output);
var matchingBackup = (await connection.ListBackups())
.FirstOrDefault(b => string.Equals(b.Name, backup, StringComparison.OrdinalIgnoreCase) || string.Equals(b.ID, backup));
@@ -22,7 +55,24 @@ public static class RunBackup
if (matchingBackup == null)
throw new UserReportedException("No backup found with supplied ID or name");
Console.WriteLine($"Running backup {matchingBackup.Name} (ID: {matchingBackup.ID})");
if (!quiet)
output.AppendConsoleMessage($"Running backup {matchingBackup.Name} (ID: {matchingBackup.ID})");
await connection.RunBackup(matchingBackup.ID);
if (wait)
{
if (!quiet)
output.AppendConsoleMessage("Waiting for backup to finish...");
await connection.WaitForBackup(matchingBackup.ID, TimeSpan.FromSeconds(pollinterval), msg =>
{
if (!quiet)
output.AppendConsoleMessage($"[{DateTime.Now}]: {msg}");
});
if (!quiet)
output.AppendConsoleMessage("Backup finished");
}
output.SetResult(true);
}));
}
@@ -0,0 +1,65 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
namespace Duplicati.CommandLine.ServerUtil.Commands;
public static class ServerStatus
{
public static Command Create() =>
new Command("status", "Gets the server status")
.WithHandler(CommandHandler.Create<Settings, OutputInterceptor>(async (settings, output) =>
{
var state = await (await settings.GetConnection(output)).GetServerState();
output.AppendConsoleMessage($"Server state: {state.ProgramState}");
output.AppendCustomObject("ServerState", state.ProgramState);
if (state.ActiveTask != null)
{
output.AppendConsoleMessage(
$"Active task: [Task {state.ActiveTask.Item1}]: BackupId = {state.ActiveTask.Item2}");
output.AppendCustomObject("ActiveTask",
new { Task = state.ActiveTask.Item1, BackupId = state.ActiveTask.Item2 });
}
else
{
output.AppendConsoleMessage("Active task: None");
output.AppendCustomObject("ActiveTask", null);
}
if (state.SchedulerQueueIds.Any())
{
output.AppendConsoleMessage("Scheduled tasks:");
foreach (var (taskId, backupId) in state.SchedulerQueueIds) output.AppendConsoleMessage($" [Task {taskId}]: BackupId = {backupId}");
output.AppendCustomObject("SchedulerTasks", state.SchedulerQueueIds.Select(id => new { Task = id.Item1, BackupId = id.Item2 }).ToArray());
}
else
{
output.AppendConsoleMessage("Scheduler tasks: Empty");
output.AppendCustomObject("SchedulerTasks", null);
}
output.SetResult(true);
}));
}
+142 -30
View File
@@ -1,6 +1,29 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Net.Http.Json;
using System.Net.Security;
using System.Text.Json;
using Duplicati.Library.AutoUpdater;
using Duplicati.WebserverCore.Middlewares;
namespace Duplicati.CommandLine.ServerUtil;
@@ -65,6 +88,18 @@ public class Connection
string Operation
);
/// <summary>
/// The server state
/// </summary>
/// <param name="ActiveTask">The active task, if any</param>
/// <param name="ProgramState">The state of the server</param>
/// <param name="SchedulerQueueIds">The IDs of the tasks in the scheduler queue</param>
public sealed record ServerState(
Tuple<long, string>? ActiveTask,
string ProgramState,
IList<Tuple<long, string>> SchedulerQueueIds
);
/// <summary>
/// The stop level
/// </summary>
@@ -102,17 +137,22 @@ public class Connection
/// </summary>
/// <param name="settings">The settings to use for the connection</param>
/// <param name="obtainRefreshToken">Whether to obtain a refresh token</param>
/// <param name="console">Console messages interceptor</param>
/// <returns>The connection</returns>
public static async Task<Connection> Connect(Settings settings, bool obtainRefreshToken = false)
public static async Task<Connection> Connect(Settings settings, bool obtainRefreshToken = false, OutputInterceptor? console = null)
{
Console.WriteLine($"Connecting to {settings.HostUrl}...");
if (console != null)
console.AppendConsoleMessage($"Connecting to {settings.HostUrl}...");
else
Console.WriteLine($"Connecting to {settings.HostUrl}...");
var trustedCertificateHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(settings.AcceptedHostCertificate))
trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new char[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
trustedCertificateHashes.UnionWith(settings.AcceptedHostCertificate.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
// Configure the client for requests
var client = new HttpClient(new HttpClientHandler()
var client = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = settings.Insecure || trustedCertificateHashes.Contains("*")
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
@@ -123,7 +163,7 @@ public class Connection
if (cert == null)
return false;
return trustedCertificateHashes.Contains(cert.GetCertHashString());
}),
})
})
{
BaseAddress = new Uri(settings.HostUrl + "api/v1/")
@@ -140,36 +180,42 @@ public class Connection
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Failed to get refresh token");
(settings with { RefreshToken = refreshToken }).Save();
(settings with { RefreshToken = refreshToken }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to use refresh token: {ex.Message}");
if (console != null)
console.AppendExceptionMessage($"Failed to use refresh token: {ex.Message}");
else
Console.WriteLine($"Failed to use refresh token: {ex.Message}");
}
// If we can read the server database, try to create a signin token
try
{
var opts = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder))
opts.Add("server-datafolder", settings.ServerDatafolder);
if (File.Exists(Path.Combine(Server.Program.GetDataFolderPath(opts), Server.Program.SERVER_DATABASE_FILENAME)))
if (settings.Key != null)
opts["settings-encryption-key"] = settings.Key.Key;
if (File.Exists(Path.Combine(DataFolderManager.DATAFOLDER, DataFolderManager.SERVER_DATABASE_FILENAME)))
{
string? cfg = null;
using (var connection = Duplicati.Server.Program.GetDatabaseConnection(opts, true))
using (var connection = Server.Program.GetDatabaseConnection(opts, true))
{
cfg = connection.ApplicationSettings.JWTConfig;
if (settings.HostUrl.Scheme == "https" && connection.ApplicationSettings.ServerSSLCertificate != null && trustedCertificateHashes.Count == 0)
trustedCertificateHashes.Add(connection.ApplicationSettings.ServerSSLCertificate.GetCertHashString());
{
var selfSignedCertHash = connection.ApplicationSettings.ServerSSLCertificate?.FirstOrDefault(x => x.HasPrivateKey)?.GetCertHashString();
if (!string.IsNullOrWhiteSpace(selfSignedCertHash))
trustedCertificateHashes.Add(selfSignedCertHash);
}
}
if (!string.IsNullOrWhiteSpace(cfg))
{
var signinjwt = new WebserverCore.Middlewares.JWTTokenProvider(
JsonSerializer.Deserialize<WebserverCore.Middlewares.JWTConfig>(cfg)
var signinjwt = new JWTTokenProvider(
JsonSerializer.Deserialize<JWTConfig>(cfg)
?? throw new InvalidOperationException("Failed to deserialize JWTConfig")
).CreateSigninToken("server-cli");
@@ -179,19 +225,25 @@ public class Connection
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
(settings with { RefreshToken = refreshToken }).Save();
(settings with { RefreshToken = refreshToken }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
}
else if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder))
else if (!string.IsNullOrWhiteSpace(DataFolderManager.DATAFOLDER))
{
Console.WriteLine($"No database found in {settings.ServerDatafolder}");
if (console != null)
console.AppendConsoleMessage($"No database found in {DataFolderManager.DATAFOLDER}");
else
Console.WriteLine($"No database found in {DataFolderManager.DATAFOLDER}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to obtain a signin token: {ex.Message}");
if (console != null)
console.AppendConsoleMessage($"Failed to obtain a signin token: {ex.Message}");
else
Console.WriteLine($"Failed to obtain a signin token: {ex.Message}");
}
// Otherwise, we need a password to log in
@@ -209,7 +261,7 @@ public class Connection
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
(settings with { RefreshToken = refreshToken }).Save();
(settings with { RefreshToken = refreshToken }).Save(console);
return CreateConnectionWithClient(client, accessToken);
}
@@ -239,9 +291,9 @@ public class Connection
/// <param name="password">The password to use</param>
/// <param name="obtainRefreshToken">Whether to obtain a refresh token</param>
/// <returns>The access and refresh tokens</returns>
private static Task<(string? AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken)
private static Task<(string AccessToken, string? RefreshToken)> LoginWithPassword(HttpClient client, string password, bool obtainRefreshToken)
=> ParseAuthResponse(
client.PostAsync($"auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken }))
client.PostAsync("auth/login", JsonContent.Create(new { Password = password, RememberMe = obtainRefreshToken }))
);
/// <summary>
@@ -250,10 +302,10 @@ public class Connection
/// <param name="client">The HTTP client</param>
/// <param name="refreshToken">The refresh token to use</param>
/// <returns>The access and refresh tokens</returns>
private static Task<(string? AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken)
private static Task<(string AccessToken, string? RefreshToken)> LoginWithRefreshToken(HttpClient client, string refreshToken)
=> ParseAuthResponse(client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh")
{
Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } },
Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={refreshToken}" } }
}));
@@ -262,7 +314,7 @@ public class Connection
/// </summary>
/// <param name="response">The response to parse</param>
/// <returns>The access and refresh tokens</returns>
private static async Task<(string? AccessToken, string? RefreshToken)> ParseAuthResponse(Task<HttpResponseMessage> responseTask)
private static async Task<(string AccessToken, string? RefreshToken)> ParseAuthResponse(Task<HttpResponseMessage> responseTask)
{
var response = await responseTask;
await EnsureSuccessStatusCodeWithParsing(response);
@@ -273,7 +325,7 @@ public class Connection
throw new InvalidOperationException("Failed to get access token");
response.Headers.TryGetValues("Set-Cookie", out var cookies);
var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith($"RefreshToken_"))?.Split('=', 2)[1];
var refreshToken = cookies?.SelectMany(c => c.Split(';')).FirstOrDefault(c => c.StartsWith("RefreshToken_"))?.Split('=', 2)[1];
return (accessToken, refreshToken);
}
@@ -296,7 +348,7 @@ public class Connection
/// <returns>The task</returns>
public async Task Resume()
{
var response = await client.PostAsync($"serverstate/resume", null);
var response = await client.PostAsync("serverstate/resume", null);
await EnsureSuccessStatusCodeWithParsing(response);
}
@@ -341,6 +393,54 @@ public class Connection
await EnsureSuccessStatusCodeWithParsing(response);
}
/// <summary>
/// Gets the server state
/// </summary>
/// <returns>The server state</returns>
public async Task<ServerState> GetServerState()
{
var response = await client.GetAsync("serverstate");
await EnsureSuccessStatusCodeWithParsing(response);
return await response.Content.ReadFromJsonAsync<ServerState>()
?? throw new InvalidDataException("Failed to parse server response");
}
/// <summary>
/// Runs a backup
/// </summary>
/// <param name="backupId">The ID of the backup</param>
/// <returns>The task</returns>
public async Task WaitForBackup(string backupId, TimeSpan delay, Action<string> statusMessage)
{
var state = await GetServerState();
if (!state.SchedulerQueueIds.Any(x => x.Item2 == backupId) && state.ActiveTask?.Item2 != backupId)
throw new UserReportedException("Backup is not queued or running");
var hasStarted = state.ActiveTask?.Item2 == backupId;
while (true)
{
await Task.Delay(delay);
state = await GetServerState();
if (state.ActiveTask?.Item2 == backupId)
{
statusMessage("Backup is running ...");
hasStarted = true;
continue;
}
if (!hasStarted && state.SchedulerQueueIds.Any(x => x.Item2 == backupId))
{
statusMessage("Backup is queued ...");
continue;
}
break;
}
}
/// <summary>
/// Lists the active tasks
/// </summary>
@@ -367,7 +467,7 @@ public class Connection
StopLevel.AfterCurrentFile => "stopaftercurrentfile",
StopLevel.StopNow => "stopnow",
StopLevel.Abort => "abort",
_ => throw new ArgumentOutOfRangeException(nameof(level)),
_ => throw new ArgumentOutOfRangeException(nameof(level))
};
var response = await client.PostAsync($"task/{Uri.EscapeDataString(taskId)}/{levelString}", null);
await EnsureSuccessStatusCodeWithParsing(response);
@@ -377,15 +477,16 @@ public class Connection
/// Logs out of the server
/// </summary>
/// <param name="settings">The settings to use</param>
/// <param name="output"></param>
/// <returns>The task</returns>
public async Task Logout(Settings settings)
public async Task Logout(Settings settings, OutputInterceptor output)
{
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, "auth/refresh/logout")
{
Headers = { { "Cookie", $"RefreshToken_{client.BaseAddress!.Port}={settings.RefreshToken}" } }
});
await EnsureSuccessStatusCodeWithParsing(response);
(settings with { RefreshToken = null }).Save();
(settings with { RefreshToken = null }).Save(output);
}
/// <summary>
@@ -449,6 +550,17 @@ public class Connection
return await response.Content.ReadAsStreamAsync();
}
/// <summary>
/// Creates a forever token
/// </summary>
/// <returns>The token</returns>
public async Task<string> CreateForeverToken()
{
var (accessToken, _) = await ParseAuthResponse(client.PostAsync("auth/issue-forever-token", null));
return accessToken;
}
/// <summary>
/// The server error structure for JSON deserialization
/// </summary>
@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.ServerUtil.Implementation</AssemblyName>
<Copyright>Copyright © 2024 Team Duplicati, MIT license</Copyright>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.CommandLine.ServerUtil</RootNamespace>
</PropertyGroup>
@@ -1,3 +1,23 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
@@ -0,0 +1,118 @@
using System.CommandLine.Binding;
using System.Dynamic;
using Duplicati.Library.Backend;
using Newtonsoft.Json;
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
/// Manages console output, optionally serializing it to JSON.
/// </summary>
/// <remarks>
/// This class captures command execution details, messages, and exceptions, providing flexibility to either output them to the console
/// or serialize them into a JSON format based on the <paramref name="jsonOutput"/> parameter.
/// </remarks>
public sealed class OutputInterceptor(bool jsonOutput, BindingContext bindingContext)
{
private readonly DateTimeOffset _timestamp = DateTimeOffset.Now;
private readonly List<string> _commandMessages = [];
private readonly List<string> _exceptionMessages = [];
private string? _command;
private bool _success;
private readonly Dictionary<string, object?> _extendedProperties = [];
public bool JsonOutputMode { get; } = jsonOutput;
public int ExitCode { get; set; }
/// <summary>
/// Gets the binding context associated with this interceptor.
/// </summary>
public BindingContext BindingContext { get; } = bindingContext ?? throw new ArgumentNullException(nameof(bindingContext));
/// <summary>
/// Sets the command string to be intercepted and tracked.
/// </summary>
/// <param name="command">The command string to set. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="command"/> is null.</exception>
public void SetCommand(string command)
{
_command = command ?? throw new ArgumentNullException(nameof(command));
}
/// <summary>
/// Sets the result of the command execution.
/// </summary>
/// <param name="success">A value indicating whether the ** business rule ** was successful. On exception by definition it will be false.</param>
public void SetResult(bool success)
{
_success = success;
}
/// <summary>
/// Appends an exception message to the interceptor.
/// </summary>
/// <param name="message">The exception message to append. Ignored if null or empty.</param>
/// <remarks>
/// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console.
/// </remarks>
public void AppendExceptionMessage(string? message)
{
if (string.IsNullOrEmpty(message)) return;
if (JsonOutputMode)
{
_exceptionMessages.Add(message);
}
else
{
Console.WriteLine(message);
}
}
public void AppendCustomObject(string keyName, object? customObject)
{
_extendedProperties.Add(keyName, customObject);
}
/// <summary>
/// Appends a console message to the interceptor.
/// </summary>
/// <param name="message">The console message to append. Ignored if null or empty.</param>
/// <remarks>
/// If JSON output is enabled, the message is stored in a list; otherwise, it is written to the console.
/// </remarks>
public void AppendConsoleMessage(string? message)
{
if (message == null) return;
if (JsonOutputMode && !string.IsNullOrEmpty(message))
_commandMessages.Add(message);
else
Console.WriteLine(message);
}
/// <summary>
/// Serializes the intercepted data into a JSON string if JSON output is enabled.
/// </summary>
/// <returns>
/// A JSON string containing the intercepted data, or <c>null</c> if JSON output is disabled.
/// </returns>
/// <remarks>
/// The serialized result includes the timestamp, command, success status, messages, and exceptions in a structured format.
/// </remarks>
public string? GetSerializedResult()
{
if (!JsonOutputMode) return null;
dynamic result = new ExpandoObject();
result.Timestamp = _timestamp.ToString("O");
result.UnixTimestamp = _timestamp.ToUnixTimeSeconds();
result.Command = _command;
result.Success = _success;
result.ExitCode = ExitCode;
result.Messages = _commandMessages.AsReadOnly();
result.Exceptions = _exceptionMessages.AsReadOnly();
foreach (var kvp in _extendedProperties) ((IDictionary<string, object>)result)[kvp.Key] = kvp.Value ?? string.Empty;
return JsonConvert.SerializeObject(result, Formatting.Indented);
}
}
@@ -0,0 +1,62 @@
using System.CommandLine.Binding;
using System.CommandLine.Parsing;
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
/// An abstract binder class for managing a singleton instance of <see cref="OutputInterceptor"/>.
/// </summary>
/// <remarks>
/// This class ensures that only one instance of <see cref="OutputInterceptor"/> is associated with a given <see cref="BindingContext"/>.
/// </remarks>
public abstract class OutputInterceptorBinder : BinderBase<OutputInterceptor>
{
private static OutputInterceptor? _instance;
/// <summary>
/// Gets the current instance of <see cref="OutputInterceptor"/>.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the instance has not been initialized.</exception>
public static OutputInterceptor? Instance => _instance;
/// <summary>
/// Retrieves or creates a <see cref="OutputInterceptor"/> instance for the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to associate with the interceptor. Must not be null.</param>
/// <returns>The existing or newly created <see cref="OutputInterceptor"/> instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="bindingContext"/> is null.</exception>
public static OutputInterceptor GetConsoleInterceptor(BindingContext bindingContext)
{
ArgumentNullException.ThrowIfNull(bindingContext, nameof(bindingContext));
if (_instance is not null && ReferenceEquals(_instance.BindingContext, bindingContext))
{
return _instance;
}
_instance = CreateInterceptor(bindingContext);
return _instance;
}
/// <summary>
/// Gets the bound <see cref="OutputInterceptor"/> value for the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to retrieve the interceptor for.</param>
/// <returns>The associated <see cref="OutputInterceptor"/> instance.</returns>
protected override OutputInterceptor GetBoundValue(BindingContext bindingContext)
{
return GetConsoleInterceptor(bindingContext);
}
/// <summary>
/// Creates a new <see cref="OutputInterceptor"/> instance with the specified binding context.
/// </summary>
/// <param name="bindingContext">The binding context to initialize the interceptor with.</param>
/// <returns>A new <see cref="OutputInterceptor"/> instance.</returns>
private static OutputInterceptor CreateInterceptor(BindingContext bindingContext)
{
var interceptor = new OutputInterceptor(bindingContext.ParseResult.Tokens.Any(x => x is { Type: TokenType.Option, Value: "--json" }), bindingContext);
interceptor.SetCommand(bindingContext.ParseResult.CommandResult.Command.Name);
return interceptor;
}
}
+46 -10
View File
@@ -1,7 +1,30 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using Duplicati.CommandLine.ServerUtil.Commands;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Utility;
namespace Duplicati.CommandLine.ServerUtil;
@@ -15,9 +38,9 @@ public static class Program
/// </summary>
/// <param name="args"></param>
/// <returns>The return code</returns>
public static Task<int> Main(string[] args)
public static async Task<int> Main(string[] args)
{
Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.ServerUtil);
PreloadSettingsLoader.ConfigurePreloadSettings(ref args, PackageHelper.NamedExecutable.ServerUtil);
var rootCmd = new RootCommand("Server CLI tool for Duplicati")
{
@@ -31,34 +54,47 @@ public static class Program
Import.Create(),
Export.Create(),
Health.Create(),
IssueForeverToken.Create(),
ServerStatus.Create()
};
rootCmd = SettingsBinder.AddGlobalOptions(rootCmd);
return new CommandLineBuilder(rootCmd)
return await new CommandLineBuilder(rootCmd)
.UseDefaults()
.UseExceptionHandler((ex, context) =>
{
OutputInterceptorBinder.Instance?.SetResult(false);
if (ex is UserReportedException ure)
{
Console.WriteLine(ure.Message);
OutputInterceptorBinder.Instance?.AppendExceptionMessage(ure.Message);
context.ExitCode = 2;
}
else
{
Console.WriteLine(ex.ToString());
OutputInterceptorBinder.Instance?.AppendExceptionMessage(ex.ToString());
context.ExitCode = 1;
}
if (OutputInterceptorBinder.Instance != null)
{
OutputInterceptorBinder.Instance.ExitCode = context.ExitCode;
Console.WriteLine(OutputInterceptorBinder.Instance.GetSerializedResult());
}
})
.AddMiddleware(async (context, next) =>
{
// Inject settings with custom binder
if (context.ParseResult.CommandResult?.Command is Command cmd)
if (context.ParseResult.CommandResult.Command is { } cmd)
context.BindingContext.AddService(_ => SettingsBinder.GetSettings(context.BindingContext));
context.BindingContext.AddService(_ => OutputInterceptorBinder.GetConsoleInterceptor(context.BindingContext));
await next(context);
Console.WriteLine(OutputInterceptorBinder.Instance?.GetSerializedResult());
})
.Build()
.InvokeAsync(args);
.UseAdditionalHelpAliases()
.Build().InvokeAsync(args);
}
}
}
+124 -27
View File
@@ -1,6 +1,33 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Text.Json;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Encryption;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using Duplicati.Library.Utility;
using Uri = System.Uri;
using Utility = Duplicati.Library.Utility.Utility;
namespace Duplicati.CommandLine.ServerUtil;
@@ -10,17 +37,21 @@ namespace Duplicati.CommandLine.ServerUtil;
/// <param name="Password">The commandline password</param>
/// <param name="RefreshToken">The saved refresh token</param>
/// <param name="HostUrl">The host url to connect to</param>
/// <param name="ServerDatafolder">The server datafolder for password-free connections</param>
/// <param name="SettingsFile">The settings file where data is loaded/saved</param>
/// <param name="Insecure">Whether to disable TLS/SSL certificate trust check</param>
/// <param name="Key">The encryption key to use for the settings file</param>
/// <param name="SecretProvider">The secret provider to use for reading secrets</param>
/// <param name="SecretProviderPattern">The pattern to use for the secret provider</param>
/// <param name="AcceptedHostCertificate">The SHA1 hash of the host certificate to accept</param>
public sealed record Settings(
string? Password,
string? RefreshToken,
Uri HostUrl,
string? ServerDatafolder,
string SettingsFile,
bool Insecure,
EncryptedFieldHelper.KeyInstance? Key,
ISecretProvider? SecretProvider,
string SecretProviderPattern,
string? AcceptedHostCertificate
)
{
@@ -35,69 +66,125 @@ public sealed record Settings(
Uri HostUrl,
string? ServerDatafolder
);
private static string GetDefaultStorageFolder(string filename)
{
var folder = DatabaseLocator.GetDefaultStorageFolderWithDebugSupport(filename);
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
return folder;
}
/// <summary>
/// Gets the default data folder for the settings file
/// </summary>
/// <param name="filename">The filename to use</param>
/// <returns>The default storage folder</returns>
private static string GetDefaultStorageFolder(string filename)
// Ideally, this should use DataFolderManager.DATAFOLDER, but we cannot due to backwards compatibility
=> DataFolderLocator.GetDefaultStorageFolder(filename, true);
/// <summary>
/// Loads the settings from the settings file
/// </summary>
/// <param name="password">The password to use</param>
/// <param name="hostUrl">The host URL to use</param>
/// <param name="serverDataFolder">The server data folder to use</param>
/// <param name="settingsFile">The settings file to use</param>
/// <param name="insecure">Whether to disable TLS/SSL certificate trust check</param>
/// <param name="settingsPassphrase">The encryption key to use</param>
/// <param name="secretProvider">The secret provider to use</param>
/// <param name="secretProviderCache">The secret provider cache level to use</param>
/// <param name="secretProviderPattern">The secret provider pattern to use</param>
/// <param name="acceptedHostCertificate">The SHA1 hash of the host certificate to accept</param>
/// <returns>The loaded settings</returns>
public static Settings Load(string? password, Uri? hostUrl, string? serverDataFolder, string settingsFile, bool insecure, string? acceptedHostCertificate)
public static Settings Load(string? password, Uri? hostUrl, string settingsFile, bool insecure, string? settingsPassphrase, string? secretProvider, SecretProviderHelper.CachingLevel secretProviderCache, string secretProviderPattern, string? acceptedHostCertificate)
{
hostUrl ??= new Uri("http://localhost:8200");
if (string.IsNullOrWhiteSpace(serverDataFolder))
serverDataFolder = GetDefaultStorageFolder("Duplicati-server.sqlite");
hostUrl ??= new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200");
ISecretProvider? secretInstance = null;
if (!string.IsNullOrWhiteSpace(secretProvider))
{
var secretProviderInstance = SecretProviderLoader.CreateInstance(secretProvider);
// Map into expected structure
var opts = new Dictionary<string, string?>
{
{ "secret-provider", secretProvider },
{ "secret-provider-pattern", secretProviderPattern },
{ "secret-provider-cache", secretProviderCache.ToString() },
{ "password", password },
{ "settings-encryption-key", settingsPassphrase }
};
var args = new[] { hostUrl };
secretInstance = SecretProviderHelper.ApplySecretProviderAsync(args, [], opts, TempFolder.SystemTempPath, null, CancellationToken.None).Await();
// Read back transformed values
hostUrl = args[0];
password = opts["password"];
settingsPassphrase = opts["settings-encryption-key"];
}
if (!string.IsNullOrWhiteSpace(settingsFile) && !Path.IsPathRooted(settingsFile))
settingsFile = Path.Combine(GetDefaultStorageFolder(settingsFile), settingsFile);
var persistedSettings = LoadSettings(settingsFile)
var key = EncryptedFieldHelper.KeyInstance.CreateKeyIfValid(settingsPassphrase);
var persistedSettings = LoadSettings(settingsFile, key)
.FirstOrDefault(x => x.HostUrl == hostUrl);
return new Settings(
password,
persistedSettings?.RefreshToken,
hostUrl,
serverDataFolder,
settingsFile,
insecure,
key,
secretInstance,
secretProviderPattern,
acceptedHostCertificate
);
}
/// <summary>
/// Replaces secrets inside arguments and options
/// </summary>
/// <param name="args">The arguments to replace</param>
/// <param name="options">The options to replace</param>
/// <returns>The task to await</returns>
public Task ReplaceSecrets(Dictionary<string, string?> options)
{
if (SecretProvider == null)
return Task.CompletedTask;
return SecretProviderHelper.ReplaceSecretsAsync(SecretProvider, [], [], options, SecretProviderPattern, CancellationToken.None);
}
/// <summary>
/// Saves the settings to the settings file
/// </summary>
public void Save()
public void Save(OutputInterceptor? output = null)
{
var thisKey = Key;
if (!string.IsNullOrWhiteSpace(RefreshToken))
{
if (!EncryptedFieldHelper.HasValidDefaultKey)
Console.WriteLine("Warning: The encryption key is missing, saving login token without encryption");
else if (EncryptedFieldHelper.IsDefaultKeyBlacklisted)
Console.WriteLine("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption");
if (Key == null)
{
if (output != null)
output.AppendConsoleMessage("Warning: The encryption key is missing, saving login token without encryption");
else
Console.WriteLine("Warning: The encryption key is missing, saving login token without encryption");
}
else if (Key?.IsBlacklisted ?? false)
{
if (output != null)
output.AppendConsoleMessage("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption");
else
Console.WriteLine("Warning: The current encryption key is blacklisted and cannot be used, saving login token without encryption");
thisKey = null;
}
}
File.WriteAllText(SettingsFile, JsonSerializer.Serialize(LoadSettings(SettingsFile)
File.WriteAllText(SettingsFile, JsonSerializer.Serialize(LoadSettings(SettingsFile, thisKey)
.Where(x => x.HostUrl != HostUrl)
.Append(new PersistedSettings(RefreshToken, HostUrl, ServerDatafolder))
.Append(new PersistedSettings(RefreshToken, HostUrl, DataFolderManager.DATAFOLDER))
.Select(x => x with
{
RefreshToken = string.IsNullOrWhiteSpace(x.RefreshToken) || EncryptedFieldHelper.IsDefaultKeyBlacklisted || !EncryptedFieldHelper.HasValidDefaultKey
RefreshToken = string.IsNullOrWhiteSpace(x.RefreshToken) || thisKey == null
? x.RefreshToken
: EncryptedFieldHelper.Encrypt(x.RefreshToken)
: EncryptedFieldHelper.Encrypt(x.RefreshToken, thisKey)
})
));
}
@@ -110,17 +197,27 @@ public sealed record Settings(
{
return Connection.Connect(this);
}
/// <summary>
/// Gets a connection to the server
/// </summary>
/// <returns>The connection</returns>
public Task<Connection> GetConnection(OutputInterceptor output)
{
return Connection.Connect(this, false, output);
}
/// <summary>
/// Loads the settings from the settings file
/// </summary>
/// <param name="filename">The filename to load</param>
/// <param name="key">The encryption key to use</param>
/// <returns>The loaded settings</returns>
private static List<PersistedSettings> LoadSettings(string filename)
private static List<PersistedSettings> LoadSettings(string filename, EncryptedFieldHelper.KeyInstance? key)
{
if (File.Exists(filename))
return (JsonSerializer.Deserialize<List<PersistedSettings>>(File.ReadAllText(filename)) ?? [])
.Select(x => x with { RefreshToken = EncryptedFieldHelper.Decrypt(x.RefreshToken) })
.Select(x => x with { RefreshToken = EncryptedFieldHelper.Decrypt(x.RefreshToken, key) })
.ToList();
return [];
@@ -1,5 +1,31 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Binding;
using Duplicati.Library.AutoUpdater;
using Duplicati.Library.Encryption;
using Duplicati.Library.Main;
using Uri = System.Uri;
using Utility = Duplicati.Library.Utility.Utility;
namespace Duplicati.CommandLine.ServerUtil;
@@ -15,11 +41,15 @@ public class SettingsBinder : BinderBase<Settings>
/// <summary>
/// The host URL option.
/// </summary>
public static readonly Option<Uri> hostUrlOption = new Option<Uri>("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri("http://localhost:8200"));
public static readonly Option<Uri> hostUrlOption = new Option<Uri>("--hosturl", description: "The host URL to use", getDefaultValue: () => new Uri($"http://{Utility.IpVersionCompatibleLoopback}:8200"));
/// <summary>
/// The server datafolder option.
/// </summary>
public static readonly Option<DirectoryInfo?> serverDatafolderOption = new Option<DirectoryInfo?>("--server-datafolder", description: "The server datafolder to use for locating the database", getDefaultValue: () => null);
public static readonly Option<DirectoryInfo?> serverDatafolderOption = new Option<DirectoryInfo?>($"--{DataFolderManager.SERVER_DATAFOLDER_OPTION}", description: "The server datafolder to use for locating the database and storing configuration", getDefaultValue: () => new DirectoryInfo(DataFolderManager.DATAFOLDER));
/// <summary>
/// The server portable mode option.
/// </summary>
public static readonly Option<bool> portableModeOption = new Option<bool>($"--{DataFolderManager.PORTABLE_MODE_OPTION}", description: "Use portable mode for locating the database and storing configuration", getDefaultValue: () => DataFolderManager.PORTABLE_MODE);
/// <summary>
/// The settings file option.
/// </summary>
@@ -31,10 +61,34 @@ public class SettingsBinder : BinderBase<Settings>
public static readonly Option<bool> insecureOption = new Option<bool>("--insecure", description: "Accepts any TLS/SSL certificate (dangerous)", getDefaultValue: () => false);
/// <summary>
/// The settings encryption key option.
/// </summary>
public static readonly Option<string?> settingsEncryptionKeyOption = new Option<string?>("--settings-encryption-key", description: $"The encryption key to use for the settings file. Can also be supplied with environment variable {EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME}", getDefaultValue: () => null);
/// <summary>
/// The secret provider option.
/// </summary>
public static readonly Option<string?> secretProviderOption = new Option<string?>("--secret-provider", description: "The secret provider to use for reading secrets", getDefaultValue: () => null);
/// <summary>
/// The secret provider cache option.
/// </summary>
public static readonly Option<SecretProviderHelper.CachingLevel> secretProviderCacheOption = new Option<SecretProviderHelper.CachingLevel>("--secret-provider-cache", description: "The secret provider cache to use for reading secrets", getDefaultValue: () => SecretProviderHelper.CachingLevel.None);
/// <summary>
/// The secret provider pattern option.
/// </summary>
public static readonly Option<string?> secretProviderPatternOption = new Option<string?>("--secret-provider-pattern", description: "The pattern to use for the secret provider", getDefaultValue: () => SecretProviderHelper.DEFAULT_PATTERN);
/// The accepted host certificate option.
/// </summary>
public static readonly Option<string> acceptedHostCertificateOption = new Option<string>("--host-cert", description: "The SHA1 hash of the host certificate to accept. Use * for any certificate, same as --insecure (dangerous)", getDefaultValue: () => string.Empty);
/// <summary>
/// Option to wrap stdout as a json.
/// </summary>
public static readonly Option<bool> jsonOutputOption =
new Option<bool>("--json", description: "Wraps stdout as a json", getDefaultValue: () => false);
/// <summary>
/// Adds global options to the root command.
/// </summary>
@@ -45,9 +99,15 @@ public class SettingsBinder : BinderBase<Settings>
rootCommand.AddGlobalOption(passwordOption);
rootCommand.AddGlobalOption(hostUrlOption);
rootCommand.AddGlobalOption(serverDatafolderOption);
rootCommand.AddGlobalOption(portableModeOption);
rootCommand.AddGlobalOption(settingsFileOption);
rootCommand.AddGlobalOption(insecureOption);
rootCommand.AddGlobalOption(settingsEncryptionKeyOption);
rootCommand.AddGlobalOption(secretProviderOption);
rootCommand.AddGlobalOption(secretProviderCacheOption);
rootCommand.AddGlobalOption(secretProviderPatternOption);
rootCommand.AddGlobalOption(acceptedHostCertificateOption);
rootCommand.AddGlobalOption(jsonOutputOption);
return rootCommand;
}
@@ -60,9 +120,12 @@ public class SettingsBinder : BinderBase<Settings>
Settings.Load(
bindingContext.ParseResult.GetValueForOption(passwordOption),
bindingContext.ParseResult.GetValueForOption(hostUrlOption),
bindingContext.ParseResult.GetValueForOption(serverDatafolderOption)?.FullName,
bindingContext.ParseResult.GetValueForOption(settingsFileOption)?.FullName ?? "settings.json",
bindingContext.ParseResult.GetValueForOption(insecureOption),
bindingContext.ParseResult.GetValueForOption(settingsEncryptionKeyOption) ?? Environment.GetEnvironmentVariable(EncryptedFieldHelper.ENVIROMENT_VARIABLE_NAME),
bindingContext.ParseResult.GetValueForOption(secretProviderOption),
bindingContext.ParseResult.GetValueForOption(secretProviderCacheOption),
bindingContext.ParseResult.GetValueForOption(secretProviderPatternOption) ?? SecretProviderHelper.DEFAULT_PATTERN,
bindingContext.ParseResult.GetValueForOption(acceptedHostCertificateOption)
);
@@ -1,3 +1,23 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
namespace Duplicati.CommandLine.ServerUtil;
/// <summary>
@@ -0,0 +1,42 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Invocation;
namespace Duplicati.CommandLine.SourceTool;
/// <summary>
/// Extensions for <see cref="Command"/>.
/// </summary>
public static class CommandExtensions
{
/// <summary>
/// Adds the missing WithHandler method to <see cref="Command"/>.
/// </summary>
/// <param name="command">The command to add the handler to.</param>
/// <param name="handler">The handler to add.</param>
/// <returns>The command with the handler added.</returns>
public static Command WithHandler(this Command command, ICommandHandler handler)
{
command.Handler = handler;
return command;
}
}
@@ -0,0 +1,110 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
namespace Duplicati.CommandLine.SourceTool.Commands;
/// <summary>
/// The download command
/// </summary>
public static class Download
{
/// <summary>
/// Creates the download command
/// </summary>
/// <returns>The download command</returns>
public static Command Create() =>
new Command("download", "Downloads all files from the remote")
{
new Argument<string>("url", "The source URL") {
Arity = ArgumentArity.ExactlyOne
},
new Option<DirectoryInfo>("--destination", description: "The destination folder", getDefaultValue: () => new DirectoryInfo(Directory.GetCurrentDirectory())),
new Option<int>("--max-depth", description: "The maximum depth to visit", getDefaultValue: () => 0),
new Option<long>("--max-size", description: "The maximum filesize to download", getDefaultValue: () => 0),
new Option<bool>("--overwrite", description: "Overwrite existing files", getDefaultValue: () => false),
}
.WithHandler(CommandHandler.Create<string, DirectoryInfo, int, long, bool>(async (url, destination, maxdepth, maxsize, overwrite) =>
{
var token = new CancellationTokenSource().Token;
using var source = await Common.GetProvider(url);
string localPath(string path)
{
var relpath = path.Substring(source.MountedPath.Length).TrimStart(Path.DirectorySeparatorChar);
return Path.Combine(destination.FullName, relpath);
}
var started = DateTime.UtcNow;
var fileCount = 0L;
var folderCount = 0L;
var totalSize = 0L;
var downloadCount = 0L;
var downloadSize = 0L;
await Common.Visit(source, maxdepth, async (entry, level) =>
{
if (entry.IsMetaEntry)
return true;
var path = localPath(entry.Path);
if (entry.IsFolder)
{
folderCount++;
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
}
else
{
fileCount++;
totalSize += entry.Size;
if (entry.Size <= maxsize || maxsize <= 0)
{
var folder = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(folder) && !Directory.Exists(folder))
Directory.CreateDirectory(folder);
if (!overwrite && File.Exists(path))
{
Console.WriteLine($"Skipping {entry.Path} as it already exists");
}
else
{
downloadCount++;
downloadSize += entry.Size;
Console.WriteLine($"Downloading {entry.Path} to {path} ({Library.Utility.Utility.FormatSizeString(entry.Size)})");
await using var stream = await entry.OpenRead(token);
await using var dest = File.OpenWrite(path);
await stream.CopyToAsync(dest, token);
}
}
}
return true;
}, token);
Console.WriteLine($"Found {fileCount} files and {folderCount} folders with a total size of {Library.Utility.Utility.FormatSizeString(totalSize)}");
Console.WriteLine($"Downloaded {downloadCount} files with a total size of {Library.Utility.Utility.FormatSizeString(downloadSize)} in {DateTime.UtcNow - started}");
}));
}
@@ -0,0 +1,67 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
namespace Duplicati.CommandLine.SourceTool.Commands;
/// <summary>
/// The list command
/// </summary>
public static class List
{
/// <summary>
/// Creates the list command
/// </summary>
/// <returns>The command</returns>
public static Command Create() =>
new Command("list", "Lists all paths on the remote")
{
new Argument<string>("url", "The source URL") {
Arity = ArgumentArity.ExactlyOne
},
new Option<int>("--max-depth", description: "The maximum depth to list", getDefaultValue: () => 0),
}
.WithHandler(CommandHandler.Create<string, int>(async (url, maxdepth) =>
{
var token = new CancellationTokenSource().Token;
var folders = 0L;
var files = 0L;
using var source = await Common.GetProvider(url);
await Common.Visit(source, maxdepth, (entry, level) =>
{
if (entry.IsFolder)
folders++;
else
files++;
if (entry.IsFolder && level > 0)
level--;
if (!entry.IsRootEntry)
Console.WriteLine($"{new string(' ', (level + 1) * 2)}{entry.Path}");
return Task.FromResult(true);
}, token);
Console.WriteLine($"Found {folders} folders and {files} files");
})
);
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.SourceTool.Implementation</AssemblyName>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.CommandLine.Sourcetool</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.CommandLine.NamingConventionBinder" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj" />
<ProjectReference Include="../../Library/SourceProvider/Builtin/Duplicati.Library.SourceProvider.Builtin.csproj" />
<ProjectReference Include="../../Library/SourceProviders/Duplicati.Library.SourceProviders.csproj" />
<ProjectReference Include="../../Library/Utility/Duplicati.Library.Utility.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Interface;
namespace Duplicati.CommandLine.SourceTool;
/// <summary>
/// Common methods for the source tool
/// </summary>
public static partial class Common
{
/// <summary>
/// Gets the source provider for the given URL
/// </summary>
/// <param name="url">The URL to get the provider for</param>
/// <returns>The source provider</returns>
public static async Task<ISourceProvider> GetProvider(string url) =>
(await SourceProviderLoader.GetSourceProvider(url, "", [], CancellationToken.None))
?? throw new UserInformationException(string.Format("The source \"{0}\" is not supported", url), "SourceNotSupported");
}
@@ -0,0 +1,60 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Library.Interface;
namespace Duplicati.CommandLine.SourceTool;
/// <summary>
/// Common methods for the source tool
/// </summary>
public static partial class Common
{
/// <summary>
/// Visits all entries in the source provider
/// </summary>
/// <param name="source">The source provider to visit</param>
/// <param name="maxdepth">The maximum depth to visit</param>
/// <param name="visitor">The visitor function</param>
/// <param name="token">The cancellation token</param>
/// <returns>An awaitable task</returns>
public static async Task Visit(ISourceProvider source, int maxdepth, Func<ISourceProviderEntry, int, Task<bool>> visitor, CancellationToken token)
{
var visit = new Stack<(ISourceProviderEntry Entry, int Level)>();
await foreach (var item in source.Enumerate(token))
visit.Push((item, 0));
while (visit.Count() != 0)
{
var item = visit.Pop();
var process = await visitor(item.Entry, item.Level);
if (item.Entry.IsFolder && process && (item.Level < maxdepth || maxdepth <= 0))
{
await foreach (var subitem in item.Entry.Enumerate(token))
{
if (subitem.IsFolder)
visit.Push((subitem, item.Level + 1));
else
await visitor(subitem, item.Level);
}
}
}
}
}
@@ -0,0 +1,68 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
namespace Duplicati.CommandLine.SourceTool;
/// <summary>
/// The entry point of the application
/// </summary>
public static class Program
{
/// <summary>
/// Executes the program
/// </summary>
/// <param name="args">The arguments</param>
/// <returns>The return code</returns>
public static Task<int> Main(string[] args)
{
Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.ServerUtil);
var rootCmd = new RootCommand("Source CLI tool for Duplicati")
{
Commands.List.Create(),
Commands.Download.Create(),
};
return new CommandLineBuilder(rootCmd)
.UseDefaults()
.UseExceptionHandler((ex, context) =>
{
if (ex is UserInformationException uie)
{
Console.WriteLine(uie.Message);
context.ExitCode = 2;
}
else
{
Console.WriteLine(ex.ToString());
context.ExitCode = 1;
}
})
.UseAdditionalHelpAliases()
.Build()
.InvokeAsync(args);
}
}
@@ -1,4 +1,24 @@
using System;
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Threading;
using OpenQA.Selenium;
using OpenQA.Selenium.Edge;
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -1,4 +1,4 @@
// ------------------------------------------------------------------------------
// ------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by SpecFlow (https://www.specflow.org/).
// SpecFlow Version:3.7.0.0
@@ -15,8 +15,8 @@ namespace Duplicati.Browser.Test.Features
using TechTalk.SpecFlow;
using System;
using System.Linq;
[System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.7.0.0")]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[NUnit.Framework.TestFixtureAttribute()]
@@ -24,15 +24,15 @@ namespace Duplicati.Browser.Test.Features
[NUnit.Framework.CategoryAttribute("Duplicati")]
public partial class DuplicatiLocalBackupFeature
{
private TechTalk.SpecFlow.ITestRunner testRunner;
private string[] _featureTags = new string[] {
"Duplicati"};
#line 1 "LocalBackup.feature"
#line hidden
[NUnit.Framework.OneTimeSetUpAttribute()]
public virtual void FeatureSetup()
{
@@ -42,41 +42,41 @@ namespace Duplicati.Browser.Test.Features
"Duplicati"});
testRunner.OnFeatureStart(featureInfo);
}
[NUnit.Framework.OneTimeTearDownAttribute()]
public virtual void FeatureTearDown()
{
testRunner.OnFeatureEnd();
testRunner = null;
}
[NUnit.Framework.SetUpAttribute()]
public virtual void TestInitialize()
{
}
[NUnit.Framework.TearDownAttribute()]
public virtual void TestTearDown()
{
testRunner.OnScenarioEnd();
}
public virtual void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo)
{
testRunner.OnScenarioInitialize(scenarioInfo);
testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs<NUnit.Framework.TestContext>(NUnit.Framework.TestContext.CurrentContext);
}
public virtual void ScenarioStart()
{
testRunner.OnScenarioStart();
}
public virtual void ScenarioCleanup()
{
testRunner.CollectScenarioErrors();
}
[NUnit.Framework.TestAttribute()]
[NUnit.Framework.DescriptionAttribute("Configure Backup")]
public virtual void ConfigureBackup()
@@ -85,7 +85,7 @@ namespace Duplicati.Browser.Test.Features
System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary();
TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Configure Backup", null, tagsOfScenario, argumentsOfScenario, this._featureTags);
#line 6
this.ScenarioInitialize(scenarioInfo);
this.ScenarioInitialize(scenarioInfo);
#line hidden
bool isScenarioIgnored = default(bool);
bool isFeatureIgnored = default(bool);
@@ -105,18 +105,18 @@ this.ScenarioInitialize(scenarioInfo);
{
this.ScenarioStart();
#line 7
testRunner.Given("there is local backup defined", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given ");
testRunner.Given("there is local backup defined", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given ");
#line hidden
#line 8
testRunner.When("you run the backup", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When ");
testRunner.When("you run the backup", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When ");
#line hidden
#line 9
testRunner.Then("the result should be 120", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then ");
testRunner.Then("the result should be 120", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then ");
#line hidden
}
this.ScenarioCleanup();
}
[NUnit.Framework.TestAttribute()]
[NUnit.Framework.DescriptionAttribute("Add two numbers permutations")]
[NUnit.Framework.TestCaseAttribute("0", "0", "0", null)]
@@ -131,7 +131,7 @@ this.ScenarioInitialize(scenarioInfo);
argumentsOfScenario.Add("Expected result", expectedResult);
TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("Add two numbers permutations", null, tagsOfScenario, argumentsOfScenario, this._featureTags);
#line 12
this.ScenarioInitialize(scenarioInfo);
this.ScenarioInitialize(scenarioInfo);
#line hidden
bool isScenarioIgnored = default(bool);
bool isFeatureIgnored = default(bool);
@@ -151,16 +151,16 @@ this.ScenarioInitialize(scenarioInfo);
{
this.ScenarioStart();
#line 13
testRunner.Given(string.Format("the first number is {0}", firstNumber), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given ");
testRunner.Given(string.Format("the first number is {0}", firstNumber), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given ");
#line hidden
#line 14
testRunner.And(string.Format("the second number is {0}", secondNumber), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And ");
testRunner.And(string.Format("the second number is {0}", secondNumber), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And ");
#line hidden
#line 15
testRunner.When("the two numbers are added", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When ");
testRunner.When("the two numbers are added", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When ");
#line hidden
#line 16
testRunner.Then(string.Format("the result should be {0}", expectedResult), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then ");
testRunner.Then(string.Format("the result should be {0}", expectedResult), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then ");
#line hidden
}
this.ScenarioCleanup();
@@ -1,4 +1,24 @@
using Duplicati.Browser.Test.Drivers;
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Duplicati.Browser.Test.Drivers;
using Duplicati.Browser.Test.PageObjects;
using TechTalk.SpecFlow;
@@ -1,4 +1,24 @@
using BoDi;
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using BoDi;
using Duplicati.Browser.Test.Drivers;
using TechTalk.SpecFlow;
@@ -1,3 +1,23 @@
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Threading;
using Duplicati.Browser.Test.Drivers;
using OpenQA.Selenium;
@@ -1,4 +1,24 @@
using System;
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using Duplicati.Browser.Test.Drivers;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
@@ -1,4 +1,24 @@
using System.IO;
// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.IO;
using Duplicati.Browser.Test.Drivers;
using Duplicati.Browser.Test.PageObjects;
using FluentAssertions;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More