Files
duplicati/Duplicati/Agent/Program.cs
T

277 lines
14 KiB
C#

// 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
// DEALINGS IN THE SOFTWARE.
using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using System.Reactive.Linq;
using Duplicati.Library.Logging;
using Duplicati.Library.Main;
using Duplicati.Library.RemoteControl;
using Duplicati.Library.RestAPI;
using Duplicati.Server;
using Duplicati.WebserverCore.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Duplicati.Agent;
public static class Program
{
/// <summary>
/// The log tag for messages from this class
/// </summary>
private static readonly string LogTag = Log.LogTagFromType(typeof(Program));
/// <summary>
/// Commandline arguments for the agent
/// </summary>
/// <param name="Url">The server URL to connect to</param>
/// <param name="Jwt">The JWT value to use for authentication</param>
private sealed record CommandLineArguments(
string RegistrationUrl,
FileInfo AgentSettingsFile,
string WebserviceListenInterface,
string WebservicePort,
string WebservicePassword,
string WindowsEventLog,
bool DisableDbEncryption,
bool WebserviceResetJwtConfig,
string WebserviceAllowedHostnames,
bool WebserviceApiOnly,
bool WebserviceDisableSigninTokens,
bool PingPongKeepalive
);
[STAThread]
public static Task<int> Main(string[] args)
{
Library.AutoUpdater.PreloadSettingsLoader.ConfigurePreloadSettings(ref args, Library.AutoUpdater.PackageHelper.NamedExecutable.Agent);
var runcmd = new Command("run", "Runs the agent")
{
new Option<string>("registration-url", description: "The server URL to connect to", getDefaultValue: () => "https://agent.duplicati.com"),
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)
};
runcmd.Handler = CommandHandler.Create<CommandLineArguments>(x => RunAgent(x, args));
return new RootCommand("Duplicati Agent")
{
runcmd
}.InvokeAsync(args);
}
/// <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)
{
// 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) };
using var cts = new CancellationTokenSource();
var target = new ControllerMultiLogTarget(new ConsoleLogDestination(), LogMessageType.Information, null);
using (Log.StartScope(target))
{
if (OperatingSystem.IsWindows())
{
if (!WindowsEventLogSource.SourceExists(agentConfig.WindowsEventLog))
{
Console.WriteLine("The Windows event log source 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); }
}
if (WindowsEventLogSource.SourceExists(agentConfig.WindowsEventLog))
target.AddTarget(new WindowsEventLogSource(agentConfig.WindowsEventLog), LogMessageType.Information, null);
}
Log.WriteMessage(LogMessageType.Information, LogTag, "AgentStarting", "Starting agent");
var settings = await Register(agentConfig.RegistrationUrl, agentConfig.AgentSettingsFile.FullName, cts.Token);
var t = await Task.WhenAny(
StartLocalServer(agentConfig, settings, args, cts.Token),
KeepRemoteConnection.Start(
settings.ServerUrl,
settings.JWT,
settings.CertificateUrl,
settings.ServerCertificates,
cts.Token,
ReKey,
(m) => OnMessage(m, agentConfig)
)
);
await t;
return 0;
}
}
private static async Task OnMessage(KeepRemoteConnection.CommandMessage message, CommandLineArguments agentConfig)
{
Log.WriteMessage(LogMessageType.Information, LogTag, "OnMessage", "Received message: {0}", message);
var token = FIXMEGlobal.Provider.GetRequiredService<IJWTTokenProvider>().CreateAccessToken("agent", "agent", 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}");
await message.Handle(httpClient);
}
private static Task ReKey(ClaimedClientData keydata)
{
Log.WriteMessage(LogMessageType.Information, LogTag, "ReKey", "Rekeying the settings");
var settings = Settings.Load();
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)
{
// 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 };
}
settings.Save();
return Task.CompletedTask;
}
private static async Task RunServer(string[] args, CancellationToken cancellationToken)
{
cancellationToken.Register(() =>
{
Server.Program.ApplicationExitEvent.Set();
});
var lastRestart = DateTime.Now;
while (!cancellationToken.IsCancellationRequested)
{
try
{
lastRestart = DateTime.Now;
Server.Program.Main(args);
}
catch (Exception ex)
{
if (!cancellationToken.IsCancellationRequested)
{
var nextRestart = lastRestart.AddSeconds(30);
var waitTime = nextRestart - DateTime.Now;
if (waitTime.TotalSeconds < 0)
waitTime = TimeSpan.FromSeconds(0);
Log.WriteMessage(LogMessageType.Error, LogTag, "ServerCrash", ex, "Server crashed, restarting in {0} seconds", waitTime.TotalSeconds);
if (waitTime.TotalSeconds > 0)
await Task.Delay(waitTime);
}
}
}
}
/// <summary>
/// Registers the machine with the remote server
/// </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="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)
{
var settings = Settings.Load(settingsFile);
if (string.IsNullOrWhiteSpace(settings.JWT))
{
Log.WriteMessage(LogMessageType.Information, LogTag, "ClientNoJWT", "No JWT found in settings, starting in registration mode");
using (var registration = new RegisterForRemote(registrationUrl, null, cancellationToken))
{
var registerClientData = await registration.Register();
Log.WriteMessage(LogMessageType.Information, LogTag, "ClientRegistered", $"Machine registered, claim it by visiting: {registerClientData.ClaimLink}");
var claimedClientData = await registration.Claim();
Log.WriteMessage(LogMessageType.Information, LogTag, "ClientClaimed", "Machine claimed, saving JWT");
settings = settings with
{
JWT = claimedClientData.JWT,
ServerUrl = claimedClientData.ServerUrl,
SettingsEncryptionKey = claimedClientData.LocalEncryptionKey,
ServerCertificates = claimedClientData.ServerCertificates
};
settings.Save();
}
}
return settings;
}
/// <summary>
/// Starts the local webserver in locked down mode, restarting on crashes
/// </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)
{
// 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();
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY")))
Environment.SetEnvironmentVariable("SETTINGS_ENCRYPTION_KEY", settings.SettingsEncryptionKey);
// Start the server
await RunServer(args, cancellationToken);
}
}