93 lines
5.0 KiB
C#
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;
|
|
}
|