Files

109 lines
6.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; }
// Connection authorization covers data synchronization; validation writes remain separately gated.
public bool EnableValidationOperations { get; init; }
public bool EnableListenerEvents { get; init; }
public bool PreventAutoLock { get; init; }
// Kept as a compatibility switch for older service.json files; a configured Agent connection authorizes sync.
public bool EnableDataSync { get; init; } = true;
public int DataSyncIntervalSeconds { get; init; } = 5;
public int DataSyncBatchLimit { get; init; } = 100;
public int DataSyncOverlapRows { get; init; } = 1;
public int DataSyncQueueMaxItems { get; init; } = 1000;
public long DataSyncQueueMaxBytes { get; init; } = 16 * 1024 * 1024;
// 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; } = "文件传输助手";
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.");
if (DataSyncIntervalSeconds is < 1 or > 300)
throw new ArgumentException("DataSyncIntervalSeconds must be between 1 and 300.");
if (DataSyncBatchLimit is < 1 or > 500)
throw new ArgumentException("DataSyncBatchLimit must be between 1 and 500.");
if (DataSyncOverlapRows is < 0 or > 100)
throw new ArgumentException("DataSyncOverlapRows must be between 0 and 100.");
if (DataSyncQueueMaxItems is < 1 or > 100_000 || DataSyncQueueMaxBytes is < 1 or > 512L * 1024 * 1024)
throw new ArgumentException("Data sync queue limits are out of range.");
try
{
Remote?.Validate();
(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;
}