using System.Net.Http.Json;
using System.Text.Json;
namespace Duplicati.CommandLine.ServerUtil;
///
/// Implementation of actions performed on the server.
///
public class Connection
{
///
/// The reported backup data
///
/// The ID of the backup
/// The name of the backup
/// The description of the backup
/// The metadata of the backup
public sealed record BackupEntry(
string ID,
string Name,
string Description,
Dictionary? Metadata
);
///
/// The response backup data returned from the server
///
/// The backup details
private sealed record ResponseBackupEntry(ResponseBackupEntry.ResponseBackupDetailsEntry Backup)
{
///
/// The response backup details entry
///
/// The ID of the backup
/// The name of the backup
/// The description of the backup
/// The path to the local database
/// The metadata of the backup
public sealed record ResponseBackupDetailsEntry(
string ID,
string Name,
string? Description,
string? DBPath,
Dictionary? Metadata
);
///
/// Converts the response backup entry to a backup entry
///
/// The backup entry
public BackupEntry ToBackupEntry()
=> new BackupEntry(Backup.ID, Backup.Name, Backup.Description ?? "", Backup.Metadata);
}
///
/// The task entry
///
/// The ID of the task
/// The ID of the backup
/// The operation of the task
public sealed record TaskEntry(
long TaskID,
string BackupID,
string Operation
);
///
/// The stop level
///
public enum StopLevel
{
///
/// Stop after the current file
///
AfterCurrentFile,
///
/// Stop now
///
StopNow,
///
/// Stop immediately
///
Abort
}
///
/// The HTTP client used to connect to the server
///
private readonly HttpClient client;
///
/// Initializes a new instance of the class
///
private Connection(HttpClient client)
{
this.client = client;
}
///
/// Connects to the server
///
/// The settings to use for the connection
/// Whether to obtain a refresh token
/// The connection
public static async Task Connect(Settings settings, bool obtainRefreshToken = false)
{
Console.WriteLine($"Connecting to {settings.HostUrl}...");
// Configure the client for requests
var client = new HttpClient(new HttpClientHandler()
{
ServerCertificateCustomValidationCallback = settings.Insecure
? HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
: null
})
{
BaseAddress = new Uri(settings.HostUrl + "api/v1/")
};
// If we already have a refresh token, try that first
try
{
if (!string.IsNullOrWhiteSpace(settings.RefreshToken))
{
var (accessToken, refreshToken) = await LoginWithRefreshToken(client, settings.RefreshToken);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Failed to get refresh token");
(settings with { RefreshToken = refreshToken }).Save();
return CreateConnectionWithClient(client, accessToken);
}
}
catch (Exception ex)
{
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();
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)))
{
string? cfg = null;
using (var connection = Duplicati.Server.Program.GetDatabaseConnection(opts, true))
cfg = connection.ApplicationSettings.JWTConfig;
if (!string.IsNullOrWhiteSpace(cfg))
{
var signinjwt = new WebserverCore.Middlewares.JWTTokenProvider(
JsonSerializer.Deserialize(cfg)
?? throw new InvalidOperationException("Failed to deserialize JWTConfig")
).CreateSigninToken("server-cli");
var responseTask = client.PostAsync("auth/signin", JsonContent.Create(new { SigninToken = signinjwt, RememberMe = obtainRefreshToken }));
var (accessToken, refreshToken) = await ParseAuthResponse(responseTask);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
(settings with { RefreshToken = refreshToken }).Save();
return CreateConnectionWithClient(client, accessToken);
}
}
else if (!string.IsNullOrWhiteSpace(settings.ServerDatafolder))
{
Console.WriteLine($"No database found in {settings.ServerDatafolder}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to obtain a signin token: {ex.Message}");
}
// Otherwise, we need a password to log in
try
{
// Try obtaining the password from the user
if (string.IsNullOrWhiteSpace(settings.Password))
settings = settings with { Password = HelperMethods.ReadPasswordFromConsole("Enter server password: ") };
if (string.IsNullOrWhiteSpace(settings.Password))
throw new UserReportedException("Password is required");
var (accessToken, refreshToken) = await LoginWithPassword(client, settings.Password, obtainRefreshToken);
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to get access token");
if (!string.IsNullOrWhiteSpace(refreshToken))
(settings with { RefreshToken = refreshToken }).Save();
return CreateConnectionWithClient(client, accessToken);
}
catch (Exception ex)
{
client.Dispose();
throw new UserReportedException($"Failed to connect to server: {ex.Message}", ex);
}
}
///
/// Creates the connection and adds the authorization header
///
/// The HTTP client
/// The access token
/// The connection
private static Connection CreateConnectionWithClient(HttpClient client, string accessToken)
{
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}");
return new Connection(client);
}
///
/// Logs in with a password
///
/// The HTTP client
/// The password to use
/// Whether to obtain a refresh token
/// The access and refresh tokens
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 }))
);
///
/// Logs in with a refresh token
///
/// The HTTP client
/// The refresh token to use
/// The access and refresh tokens
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}" } },
}));
///
/// Parses the authentication response
///
/// The response to parse
/// The access and refresh tokens
private static async Task<(string? AccessToken, string? RefreshToken)> ParseAuthResponse(Task responseTask)
{
var response = await responseTask;
await EnsureSuccessStatusCodeWithParsing(response);
var json = JsonSerializer.Deserialize>(response.Content.ReadAsStringAsync().Result)
?? throw new InvalidOperationException("Failed to parse response");
if (!json.TryGetValue("AccessToken", out var accessToken))
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];
return (accessToken, refreshToken);
}
///
/// Pauses the server
///
/// The duration to pause for
/// The task
public async Task Pause(string? duration)
{
var query = string.IsNullOrWhiteSpace(duration) ? "" : $"?duration={Uri.EscapeDataString(duration)}";
var response = await client.PostAsync($"serverstate/pause{query}", null);
await EnsureSuccessStatusCodeWithParsing(response);
}
///
/// Resumes the server
///
/// The task
public async Task Resume()
{
var response = await client.PostAsync($"serverstate/resume", null);
await EnsureSuccessStatusCodeWithParsing(response);
}
///
/// Lists the backups configured on the server
///
/// The backups
public async Task> ListBackups()
{
var response = await client.GetAsync("backups");
await EnsureSuccessStatusCodeWithParsing(response);
return (JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync())
?? throw new UserReportedException("Failed to parse response"))
.Select(x => x.ToBackupEntry())
.ToArray();
}
///
/// Gets a backup by ID
///
/// The ID of the backup
/// The backup
public async Task GetBackup(string backupId)
{
var response = await client.GetAsync($"backup/{Uri.EscapeDataString(backupId)}");
await EnsureSuccessStatusCodeWithParsing(response);
return (JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync())
?? throw new UserReportedException("Failed to parse response"))
.ToBackupEntry();
}
///
/// Runs a backup
///
/// The ID of the backup
/// The task
public async Task RunBackup(string backupId)
{
var response = await client.PostAsync($"backup/{Uri.EscapeDataString(backupId)}/run", null);
await EnsureSuccessStatusCodeWithParsing(response);
}
///
/// Lists the active tasks
///
/// The tasks
public async Task> ListTasks()
{
var response = await client.GetAsync("tasks");
await EnsureSuccessStatusCodeWithParsing(response);
return JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync())
?? throw new InvalidOperationException("Failed to parse response");
}
///
/// Stops a task
///
/// The ID of the task
/// The level to stop at
/// The task
public async Task StopTask(string taskId, StopLevel level = StopLevel.AfterCurrentFile)
{
var levelString = level switch
{
StopLevel.AfterCurrentFile => "stopaftercurrentfile",
StopLevel.StopNow => "stopnow",
StopLevel.Abort => "abort",
_ => throw new ArgumentOutOfRangeException(nameof(level)),
};
var response = await client.PostAsync($"task/{Uri.EscapeDataString(taskId)}/{levelString}", null);
await EnsureSuccessStatusCodeWithParsing(response);
}
///
/// Logs out of the server
///
/// The settings to use
/// The task
public async Task Logout(Settings settings)
{
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();
}
///
/// Changes the server password
///
/// The new password to use
/// The task
public async Task ChangePassword(string newPassword)
{
var response = await client.PutAsync("serversetting/server-passphrase", JsonContent.Create(newPassword));
await EnsureSuccessStatusCodeWithParsing(response);
}
///
/// Imports a backup
///
/// The file to import
/// The password to use
/// Whether to import metadata
/// The backup
public async Task ImportBackup(string file, string? password, bool importMetadata)
{
var payload = JsonContent.Create(new
{
config = Convert.ToBase64String(await File.ReadAllBytesAsync(file)),
import_metadata = importMetadata,
passphrase = password,
direct = true
});
var response = await client.PostAsync("backups/import", payload);
await EnsureSuccessStatusCodeWithParsing(response);
var json = JsonSerializer.Deserialize>(await response.Content.ReadAsStringAsync())
?? throw new UserReportedException("Failed to parse response");
if (!json.TryGetValue("Id", out var id))
throw new UserReportedException("Added backup but failed to get from response");
return await GetBackup(id);
}
///
/// Exports a backup
///
/// The ID of the backup
/// The passphrase to use, if encrypting
/// Whether to include sensitive keys
/// The stream with the exported backup
public async Task ExportBackup(string backupId, string? passphrase, bool includeKeys)
{
var exportTokenResponse = await client.PostAsync("auth/issuetoken/export", null);
await EnsureSuccessStatusCodeWithParsing(exportTokenResponse);
var values = JsonSerializer.Deserialize>(await exportTokenResponse.Content.ReadAsStringAsync())
?? throw new UserReportedException("Failed to parse response");
if (!values.TryGetValue("Token", out var token))
throw new UserReportedException("Failed to get export token");
var response = await client.GetAsync($"backup/{Uri.EscapeDataString(backupId)}/export?passphrase={Uri.EscapeDataString(passphrase ?? "")}&export-passwords={includeKeys}&token={token}");
await EnsureSuccessStatusCodeWithParsing(response);
return await response.Content.ReadAsStreamAsync();
}
///
/// The server error structure for JSON deserialization
///
/// The error message
/// The error code
private sealed record ServerError(string Error, int Code);
///
/// Ensures the response is successful or extracts an error message
///
/// The message to check
/// The task
private static async Task EnsureSuccessStatusCodeWithParsing(HttpResponseMessage? message)
{
if (message is null)
throw new UserReportedException("No response received");
if (message.IsSuccessStatusCode)
return;
var content = await message.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(content))
message.EnsureSuccessStatusCode();
ServerError? errMsg = null;
try
{
errMsg = JsonSerializer.Deserialize(content);
}
catch
{
}
if (errMsg is not null)
throw new UserReportedException($"Server error ({errMsg.Code}): {errMsg.Error}");
throw new UserReportedException($"Failed to parse response ({message.StatusCode}): {content}");
}
}