Files
wx-win-agent/node-agent/WxAgent.Service/ServiceOptions.cs
T
rogee 7bf994de88
Build web service image / build (push) Successful in 40s
feat: make Windows Agent configuration GUI-only
2026-09-20 17:25:34 +08:00

93 lines
5.0 KiB
C#

using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using WxAgent.Core;
namespace WxAgent.Service;
public sealed record ServiceCredential(string PrincipalId, string TokenSha256, string[] Permissions, string[] AccountIds);
public sealed class ServiceOptions
{
private static readonly System.Text.Json.JsonSerializerOptions CredentialJson = new(System.Text.Json.JsonSerializerDefaults.Web);
public string ListenUrl { get; init; } = "http://127.0.0.1:5088";
public bool AllowExternal { get; init; }
public string[] AllowedHosts { get; init; } = ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"];
public string[] AllowedOrigins { get; init; } = ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"];
public string? AccessToken { get; init; }
public required string CredentialFile { get; init; }
public required string DataDirectory { get; init; }
public RemoteAgentOptions? Remote { get; init; }
public ReportingConfig Reporting { get; init; } = new();
public string? RemoteConfigurationFile { get; init; }
// Explicitly opt-in for a single, user-authorized Windows validation session.
// Production deployments remain read-only unless this local gate is enabled.
public bool EnableValidationOperations { get; init; }
public bool PreventAutoLock { get; init; }
// Kept only so older service.json files can be loaded and rewritten by the tray.
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public int QueueCapacity { get; init; } = 100;
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public string ListenerSession { get; init; } = "文件传输助手";
[JsonIgnore]
[Obsolete("Internal compatibility field; the value is ignored.")]
public bool EnableListenerEvents { get; init; }
public void Validate()
{
if (!Uri.TryCreate(ListenUrl, UriKind.Absolute, out var uri) || uri.Scheme != "http" ||
uri.AbsolutePath != "/" || uri.Query.Length != 0 || uri.Fragment.Length != 0 || uri.UserInfo.Length != 0 ||
!IPAddress.TryParse(uri.Host.Trim('[', ']'), out var address))
throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port.");
if (!IPAddress.IsLoopback(address) && !AllowExternal)
throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network.");
if (AllowExternal && (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)))
throw new ArgumentException("External HTTP requires a concrete listen IP; do not use 0.0.0.0 or ::.");
if (AccessToken is not null && !IsValidAccessToken(AccessToken))
throw new ArgumentException("AccessToken must be non-empty and contain no whitespace.");
try { (Reporting ?? new ReportingConfig()).NormalizeAndValidate(); }
catch (WxAgentException exception) { throw new ArgumentException(exception.Message, exception); }
_ = ReadCredentials();
}
// Reread on every authorization boundary: rotation has no overlap or stale cache.
public ServiceCredential[] ReadCredentials()
{
var credentials = System.Text.Json.JsonSerializer.Deserialize<ServiceCredential[]>(File.ReadAllText(CredentialFile), CredentialJson)
?? throw new InvalidDataException("No credentials configured.");
if (credentials.Length != 1)
throw new InvalidDataException("Exactly one credential must be configured.");
if (credentials.Any(c =>
c is null || string.IsNullOrWhiteSpace(c.PrincipalId) || c.TokenSha256 is null || c.TokenSha256.Length != 64 ||
!c.TokenSha256.All(Uri.IsHexDigit) || c.Permissions is null || c.AccountIds is null ||
c.Permissions.Any(p => p is not ("read" or "content" or "write" or "manage" or "local-admin")) ||
c.AccountIds.Any(string.IsNullOrWhiteSpace)))
throw new InvalidDataException("Invalid credential configuration.");
return credentials;
}
public static bool IsValidAccessToken(string? token) =>
!string.IsNullOrEmpty(token) && token.All(c => !char.IsWhiteSpace(c));
public static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds, bool LocalOnly = false)
{
public bool Allows(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
public bool AllowsAccount(string accountId) => LocalOnly || AccountIds.Contains(accountId, StringComparer.Ordinal);
}
public sealed class ServiceException(string code, int statusCode, string message) : Exception(message)
{
public string Code { get; } = code;
public int StatusCode { get; } = statusCode;
}