This commit is contained in:
@@ -64,6 +64,18 @@ public sealed record RemoteAgentOptions
|
||||
[JsonPropertyName("token")]
|
||||
public string? Token { get; init; }
|
||||
|
||||
[JsonPropertyName("tokenFile")]
|
||||
public string? TokenFile { get; init; }
|
||||
|
||||
[JsonPropertyName("serverCaFile")]
|
||||
public string? ServerCaFile { get; init; }
|
||||
|
||||
[JsonPropertyName("clientCertificateFile")]
|
||||
public string? ClientCertificateFile { get; init; }
|
||||
|
||||
[JsonPropertyName("clientCertificateKeyFile")]
|
||||
public string? ClientCertificateKeyFile { get; init; }
|
||||
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string? NodeId { get; init; }
|
||||
|
||||
@@ -74,15 +86,38 @@ public sealed record RemoteAgentOptions
|
||||
public bool AllowInsecureHttp { get; init; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(AuthAddress)
|
||||
&& !string.IsNullOrWhiteSpace(Token)
|
||||
&& (!string.IsNullOrWhiteSpace(Token) || !string.IsNullOrWhiteSpace(TokenFile))
|
||||
&& !string.IsNullOrWhiteSpace(NodeId);
|
||||
|
||||
public string TokenState => string.IsNullOrWhiteSpace(Token) ? "not-configured" : "configured";
|
||||
public string TokenState => !string.IsNullOrWhiteSpace(TokenFile)
|
||||
? "file-configured"
|
||||
: string.IsNullOrWhiteSpace(Token) ? "not-configured" : "configured";
|
||||
|
||||
public string GetToken()
|
||||
{
|
||||
string? token;
|
||||
if (string.IsNullOrWhiteSpace(TokenFile))
|
||||
{
|
||||
token = Token;
|
||||
}
|
||||
else
|
||||
{
|
||||
try { token = File.ReadAllText(TokenFile).Trim(); }
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile could not be read.", exception);
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token or tokenFile must contain a non-empty token.");
|
||||
return token;
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AuthAddress) || string.IsNullOrWhiteSpace(Token) || string.IsNullOrWhiteSpace(NodeId))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress, token and nodeId are required before remote access is enabled.");
|
||||
if (string.IsNullOrWhiteSpace(AuthAddress) || (!string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(TokenFile))
|
||||
|| (!IsConfigured) || string.IsNullOrWhiteSpace(NodeId))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress, token or tokenFile and nodeId are required before remote access is enabled.");
|
||||
if (!Uri.TryCreate(AuthAddress, UriKind.Absolute, out var uri) || uri is null
|
||||
|| uri.AbsolutePath == "/" && uri.Query.Length != 0
|
||||
|| uri.UserInfo.Length != 0
|
||||
@@ -92,12 +127,28 @@ public sealed record RemoteAgentOptions
|
||||
&& (!AllowInsecureHttp || !IsPrivateNetwork(uri.Host)))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Non-loopback HTTP requires allowInsecureHttp=true and a private-network IP address.");
|
||||
ValidateIdentifier(NodeId, "nodeId", 200);
|
||||
if (Token.Any(char.IsWhiteSpace))
|
||||
var token = GetToken();
|
||||
if (token.Any(char.IsWhiteSpace))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token must not contain whitespace.");
|
||||
if (!string.IsNullOrWhiteSpace(TokenFile) && !File.Exists(TokenFile))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile does not exist.");
|
||||
ValidateOptionalFile(ServerCaFile, "serverCaFile");
|
||||
ValidateOptionalFile(ClientCertificateFile, "clientCertificateFile");
|
||||
ValidateOptionalFile(ClientCertificateKeyFile, "clientCertificateKeyFile");
|
||||
if (!string.IsNullOrWhiteSpace(ClientCertificateKeyFile) && string.IsNullOrWhiteSpace(ClientCertificateFile))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateFile is required with clientCertificateKeyFile.");
|
||||
if (!string.IsNullOrWhiteSpace(ClientCertificateFile) && string.IsNullOrWhiteSpace(ClientCertificateKeyFile))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateKeyFile is required for PEM client certificates.");
|
||||
}
|
||||
|
||||
public RemoteAgentOptions Redacted() => this with { Token = string.IsNullOrWhiteSpace(Token) ? null : "<redacted>" };
|
||||
|
||||
private static void ValidateOptionalFile(string? path, string name)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && !File.Exists(path))
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} does not exist.");
|
||||
}
|
||||
|
||||
private static bool IsLoopback(string host) => host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| System.Net.IPAddress.TryParse(host.Trim('[', ']'), out var address) && System.Net.IPAddress.IsLoopback(address);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -18,13 +20,24 @@ public sealed class RemoteControlClient : IDisposable
|
||||
private readonly bool _ownsHttp;
|
||||
private readonly RemoteAgentOptions _options;
|
||||
private readonly Uri? _baseAddress;
|
||||
private readonly X509Certificate2? _clientCertificate;
|
||||
private readonly X509Certificate2? _serverCaCertificate;
|
||||
private bool _authenticated;
|
||||
|
||||
public RemoteControlClient(RemoteAgentOptions options, HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_ownsHttp = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient();
|
||||
if (httpClient is null)
|
||||
{
|
||||
_clientCertificate = LoadClientCertificate(options);
|
||||
_serverCaCertificate = LoadServerCaCertificate(options);
|
||||
_http = CreateHttpClient(_clientCertificate, _serverCaCertificate);
|
||||
}
|
||||
else
|
||||
{
|
||||
_http = httpClient;
|
||||
}
|
||||
if (Uri.TryCreate(options.AuthAddress, UriKind.Absolute, out var address))
|
||||
_baseAddress = new Uri($"{address.Scheme}://{address.Authority}/", UriKind.Absolute);
|
||||
AuthState = options.IsConfigured ? RemoteAuthState.NotConfigured : RemoteAuthState.NotConfigured;
|
||||
@@ -174,7 +187,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
using var request = new HttpRequestMessage(method, new Uri(_baseAddress, path.TrimStart('/')));
|
||||
request.Headers.TryAddWithoutValidation("X-Correlation-Id", NewCorrelationId());
|
||||
if (authenticated || method == HttpMethod.Post && path == "/v1/nodes/register")
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.Token);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.GetToken());
|
||||
if (body is not null)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(body, RemoteJson.Options);
|
||||
@@ -220,7 +233,7 @@ public sealed class RemoteControlClient : IDisposable
|
||||
if (!_options.IsConfigured)
|
||||
{
|
||||
AuthState = RemoteAuthState.NotConfigured;
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote authAddress, token and nodeId must all be configured.");
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Remote authAddress, token or tokenFile and nodeId must all be configured.");
|
||||
}
|
||||
_options.Validate();
|
||||
}
|
||||
@@ -249,11 +262,42 @@ public sealed class RemoteControlClient : IDisposable
|
||||
return new RemoteApiError("RemoteRequestFailed", "Remote request failed.", correlationId);
|
||||
}
|
||||
|
||||
private static X509Certificate2? LoadClientCertificate(RemoteAgentOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ClientCertificateFile)) return null;
|
||||
return X509Certificate2.CreateFromPemFile(options.ClientCertificateFile, options.ClientCertificateKeyFile!);
|
||||
}
|
||||
|
||||
private static X509Certificate2? LoadServerCaCertificate(RemoteAgentOptions options) =>
|
||||
string.IsNullOrWhiteSpace(options.ServerCaFile) ? null : new X509Certificate2(options.ServerCaFile);
|
||||
|
||||
private static HttpClient CreateHttpClient(X509Certificate2? clientCertificate, X509Certificate2? serverCaCertificate)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
if (clientCertificate is not null) handler.ClientCertificates.Add(clientCertificate);
|
||||
if (serverCaCertificate is not null)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = (_, certificate, _, errors) =>
|
||||
{
|
||||
if (certificate is null || (errors & (SslPolicyErrors.RemoteCertificateNameMismatch | SslPolicyErrors.RemoteCertificateNotAvailable)) != 0)
|
||||
return false;
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(serverCaCertificate);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
return chain.Build(new X509Certificate2(certificate));
|
||||
};
|
||||
}
|
||||
return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) };
|
||||
}
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value);
|
||||
private static string NewCorrelationId() => Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
_clientCertificate?.Dispose();
|
||||
_serverCaCertificate?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,6 +1033,8 @@ static void PrintHelp() => Console.WriteLine("""
|
||||
WxAgent.Host commands:
|
||||
serve --config <service.json>
|
||||
remote auth show|set|clear --config <remote.json>
|
||||
auth set options: --address <url> --token <token> | --token-file <path> --node <id>
|
||||
[--server-ca-file <pem>] [--client-certificate-file <pem> --client-certificate-key-file <pem>]
|
||||
remote status show --config <remote.json> [--data-dir <dir>]
|
||||
remote probe run --config <remote.json> [--timeout 60]
|
||||
remote reporting show|enable|disable|account-add|account-enable|account-disable|allow|deny --config <remote.json>
|
||||
|
||||
@@ -49,7 +49,11 @@ internal static class RemoteCliCommands
|
||||
var remote = new RemoteAgentOptions
|
||||
{
|
||||
AuthAddress = Required(args, "--address"),
|
||||
Token = Required(args, "--token"),
|
||||
Token = Option(args, "--token"),
|
||||
TokenFile = Option(args, "--token-file"),
|
||||
ServerCaFile = Option(args, "--server-ca-file"),
|
||||
ClientCertificateFile = Option(args, "--client-certificate-file"),
|
||||
ClientCertificateKeyFile = Option(args, "--client-certificate-key-file"),
|
||||
NodeId = Required(args, "--node"),
|
||||
ActiveAccountId = Option(args, "--active-account"),
|
||||
AllowInsecureHttp = Has(args, "--allow-insecure-http")
|
||||
@@ -71,7 +75,7 @@ internal static class RemoteCliCommands
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
configuration.Remote.Validate();
|
||||
using var client = new RemoteControlClient(configuration.Remote, new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
|
||||
using var client = new RemoteControlClient(configuration.Remote);
|
||||
var accounts = configuration.Reporting.Accounts.Select(account => new RemoteAccountSummary(
|
||||
account.AccountId,
|
||||
string.Equals(account.AccountId, configuration.Remote.ActiveAccountId, StringComparison.Ordinal),
|
||||
|
||||
@@ -38,8 +38,7 @@ public sealed class RemoteAgentHostedService(
|
||||
}
|
||||
var remote = configuredRemote;
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
|
||||
using var client = new RemoteControlClient(remote, http);
|
||||
using var client = new RemoteControlClient(remote);
|
||||
var ledger = new RemoteTaskLedger(Path.Combine(options.DataDirectory, "remote-task-ledger.json"));
|
||||
var eventQueue = remoteQueue ?? new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json"));
|
||||
var retry = RetryDelay;
|
||||
|
||||
Reference in New Issue
Block a user