feat: harden control-plane deployment
Build web service image / build (push) Successful in 48s

This commit is contained in:
2026-09-12 11:09:42 +08:00
parent 13c31fc902
commit 0d29b828bb
22 changed files with 1213 additions and 71 deletions
+56 -5
View File
@@ -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);
+47 -3
View File
@@ -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();
}
}
+2
View File
@@ -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>
+6 -2
View File
@@ -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;