refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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; }
|
||||
|
||||
// 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.");
|
||||
_ = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user