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(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; }