Files

404 lines
17 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace WxAgent.Core;
public static class RemoteProtocol
{
public const string Version = "v1";
public const int MaxTaskPayloadBytes = 64 * 1024;
public const int MaxTaskResultBytes = 512 * 1024;
public const int MaxEventContentLength = 16 * 1024;
}
public enum RemoteAuthState
{
NotConfigured,
Authenticating,
Authenticated,
AuthenticationFailed
}
public enum RemoteNodeStatus
{
Registered,
Online,
Degraded,
Offline,
SessionLocked,
WechatNotRunning,
WechatNotLoggedIn
}
public enum RemoteTaskStatus
{
Pending,
WaitingForClient,
Accepted,
Running,
Succeeded,
Failed,
Cancelled,
Expired,
ResultUnconfirmed
}
public enum ReportingChatType
{
Group,
Private
}
public enum ReportingDataType
{
Message,
TaskResult,
Error,
Diagnostic
}
public sealed record RemoteAgentOptions
{
[JsonPropertyName("authAddress")]
public string? AuthAddress { get; init; }
[JsonPropertyName("token")]
public string? Token { get; init; }
[JsonPropertyName("tokenFile")]
public string? TokenFile { get; init; }
[JsonPropertyName("serverCaFile")]
public string? ServerCaFile { get; init; }
[JsonPropertyName("clientCertificateFile")]
public string? ClientCertificateFile { get; init; }
[JsonPropertyName("clientCertificateKeyFile")]
public string? ClientCertificateKeyFile { get; init; }
[JsonPropertyName("nodeId")]
public string? NodeId { get; init; }
[JsonPropertyName("activeAccountId")]
public string? ActiveAccountId { get; init; }
[JsonPropertyName("allowInsecureHttp")]
public bool AllowInsecureHttp { get; init; }
public bool IsConfigured => !string.IsNullOrWhiteSpace(AuthAddress)
&& (!string.IsNullOrWhiteSpace(Token) || !string.IsNullOrWhiteSpace(TokenFile))
&& !string.IsNullOrWhiteSpace(NodeId);
public string TokenState => !string.IsNullOrWhiteSpace(TokenFile)
? "file-configured"
: string.IsNullOrWhiteSpace(Token) ? "not-configured" : "configured";
public string GetToken()
{
string? token;
if (string.IsNullOrWhiteSpace(TokenFile))
{
token = Token;
}
else
{
try { token = File.ReadAllText(TokenFile).Trim(); }
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile could not be read.", exception);
}
}
if (string.IsNullOrWhiteSpace(token))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token or tokenFile must contain a non-empty token.");
return token;
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(AuthAddress) || (!string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(TokenFile))
|| (!IsConfigured) || string.IsNullOrWhiteSpace(NodeId))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress, token or tokenFile and nodeId are required before remote access is enabled.");
if (!Uri.TryCreate(AuthAddress, UriKind.Absolute, out var uri) || uri is null
|| uri.AbsolutePath == "/" && uri.Query.Length != 0
|| uri.UserInfo.Length != 0
|| uri.Scheme is not ("https" or "http"))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "authAddress must be an HTTPS URL or an HTTP URL for an explicitly trusted endpoint.");
if (uri.Scheme == "http" && !IsLoopback(uri.Host)
&& (!AllowInsecureHttp || !IsPrivateNetwork(uri.Host)))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Non-loopback HTTP requires allowInsecureHttp=true and a private-network IP address.");
ValidateIdentifier(NodeId, "nodeId", 200);
var token = GetToken();
if (token.Any(char.IsWhiteSpace))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "token must not contain whitespace.");
if (!string.IsNullOrWhiteSpace(TokenFile) && !File.Exists(TokenFile))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "tokenFile does not exist.");
ValidateOptionalFile(ServerCaFile, "serverCaFile");
ValidateOptionalFile(ClientCertificateFile, "clientCertificateFile");
ValidateOptionalFile(ClientCertificateKeyFile, "clientCertificateKeyFile");
if (!string.IsNullOrWhiteSpace(ClientCertificateKeyFile) && string.IsNullOrWhiteSpace(ClientCertificateFile))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateFile is required with clientCertificateKeyFile.");
if (!string.IsNullOrWhiteSpace(ClientCertificateFile) && string.IsNullOrWhiteSpace(ClientCertificateKeyFile))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "clientCertificateKeyFile is required for PEM client certificates.");
}
public RemoteAgentOptions Redacted() => this with { Token = string.IsNullOrWhiteSpace(Token) ? null : "<redacted>" };
private static void ValidateOptionalFile(string? path, string name)
{
if (!string.IsNullOrWhiteSpace(path) && !File.Exists(path))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} does not exist.");
}
private static bool IsLoopback(string host) => host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| System.Net.IPAddress.TryParse(host.Trim('[', ']'), out var address) && System.Net.IPAddress.IsLoopback(address);
private static bool IsPrivateNetwork(string host)
{
if (!System.Net.IPAddress.TryParse(host.Trim('[', ']'), out var address)) return false;
if (System.Net.IPAddress.IsLoopback(address)) return true;
var bytes = address.GetAddressBytes();
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
return bytes[0] == 10
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|| bytes[0] == 192 && bytes[1] == 168;
return address.IsIPv6LinkLocal || bytes[0] is >= 0xfc and <= 0xfd;
}
internal static void ValidateIdentifier(string? value, string name, int maxLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} is required and bounded.");
}
}
public sealed record ReportingConfig
{
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("configVersion")]
public long ConfigVersion { get; init; }
[JsonPropertyName("accounts")]
public IReadOnlyList<AccountReportingConfig> Accounts { get; init; } = [];
public ReportingConfig NormalizeAndValidate()
{
if (ConfigVersion < 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "configVersion must not be negative.");
var accounts = Accounts ?? [];
var duplicateAccounts = accounts.GroupBy(account => account.AccountId, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateAccounts is not null)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Each account may occur only once in reporting configuration.");
foreach (var account in accounts)
account.ValidateAndNormalize();
return this with { Accounts = accounts.ToArray() };
}
public AccountReportingConfig? FindAccount(string accountId) =>
Accounts.FirstOrDefault(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal));
}
public sealed record AccountReportingConfig
{
[JsonPropertyName("accountId")]
public string AccountId { get; init; } = "";
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("allowedChats")]
public IReadOnlyList<AllowedChat> AllowedChats { get; init; } = [];
public void ValidateAndNormalize()
{
RemoteAgentOptions.ValidateIdentifier(AccountId, "accountId", 200);
var duplicate = (AllowedChats ?? []).GroupBy(chat => $"{chat.Type}:{chat.ChatId}", StringComparer.Ordinal)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Each account chat identity and type may occur only once.");
foreach (var chat in AllowedChats ?? [])
chat.Validate();
}
}
public sealed record AllowedChat
{
[JsonPropertyName("type")]
public ReportingChatType Type { get; init; }
[JsonPropertyName("chatId")]
public string ChatId { get; init; } = "";
[JsonPropertyName("enabled")]
public bool Enabled { get; init; }
[JsonPropertyName("identityVerified")]
public bool IdentityVerified { get; init; }
public void Validate() => RemoteAgentOptions.ValidateIdentifier(ChatId, "chatId", 512);
}
public sealed record RemoteNodeRegistration(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("agent_version")] string AgentVersion,
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
[property: JsonPropertyName("capabilities")] IReadOnlyList<string> Capabilities,
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
[property: JsonPropertyName("accounts")] IReadOnlyList<RemoteAccountSummary> Accounts,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteAccountSummary(
[property: JsonPropertyName("account_id")] string AccountId,
[property: JsonPropertyName("active")] bool Active,
[property: JsonPropertyName("verified")] bool Verified,
[property: JsonPropertyName("allowed_group_count")] int AllowedGroupCount,
[property: JsonPropertyName("allowed_private_count")] int AllowedPrivateCount)
{
[JsonPropertyName("allowed_chats")]
public IReadOnlyList<RemoteAllowedChatSummary> AllowedChats { get; init; } = [];
}
public sealed record RemoteAllowedChatSummary(
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("chat_type")] ReportingChatType ChatType);
public sealed record RemoteNodeRegistrationResponse(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
[property: JsonPropertyName("authenticated")] bool Authenticated,
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteDataSyncStatus(
[property: JsonPropertyName("state")] string State,
[property: JsonPropertyName("source_generation")] string SourceGeneration,
[property: JsonPropertyName("confirmed_sequence")] long ConfirmedSequence,
[property: JsonPropertyName("confirmed_cursor")] string ConfirmedCursor,
[property: JsonPropertyName("last_success_at")] DateTimeOffset? LastSuccessAt = null);
public sealed record RemoteHeartbeatResponse(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("status")] RemoteNodeStatus Status,
[property: JsonPropertyName("last_heartbeat_at")] DateTimeOffset LastHeartbeatAt,
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteTaskBatch(
[property: JsonPropertyName("tasks")] IReadOnlyList<RemoteTaskEnvelope> Tasks);
public sealed record RemoteHeartbeat(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("agent_version")] string AgentVersion,
[property: JsonPropertyName("protocol_version")] string ProtocolVersion,
[property: JsonPropertyName("node_status")] RemoteNodeStatus NodeStatus,
[property: JsonPropertyName("wechat_running")] bool WechatRunning,
[property: JsonPropertyName("wechat_logged_in")] bool WechatLoggedIn,
[property: JsonPropertyName("session_locked")] bool SessionLocked,
[property: JsonPropertyName("active_account_id")] string? ActiveAccountId,
[property: JsonPropertyName("queue_length")] int QueueLength,
[property: JsonPropertyName("reporting_config_version")] long ReportingConfigVersion,
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("last_error_code")] string? LastErrorCode = null,
[property: JsonPropertyName("connection_id")] string? ConnectionId = null);
public sealed record RemoteTaskEnvelope(
[property: JsonPropertyName("task_id")] string TaskId,
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("account_id")] string AccountId,
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("idempotency_key")] string IdempotencyKey,
[property: JsonPropertyName("payload")] JsonElement Payload,
[property: JsonPropertyName("lease_generation")] long LeaseGeneration,
[property: JsonPropertyName("lease_expires_at")] DateTimeOffset? LeaseExpiresAt,
[property: JsonPropertyName("cancel_requested_at")] DateTimeOffset? CancelRequestedAt,
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
[property: JsonPropertyName("state_version")] long StateVersion)
{
[JsonPropertyName("lease_owner")]
public string? LeaseOwner { get; init; }
[JsonPropertyName("created_at")]
public DateTimeOffset? CreatedAt { get; init; }
[JsonPropertyName("updated_at")]
public DateTimeOffset? UpdatedAt { get; init; }
[JsonPropertyName("last_correlation_id")]
public string? LastCorrelationId { get; init; }
[JsonPropertyName("result")]
public RemoteTaskResult? Result { get; init; }
}
public sealed record RemoteTaskResult(
[property: JsonPropertyName("task_id")] string TaskId,
[property: JsonPropertyName("account_id")] string AccountId,
[property: JsonPropertyName("lease_generation")] long LeaseGeneration,
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
[property: JsonPropertyName("error_code")] string? ErrorCode,
[property: JsonPropertyName("message")] string? Message,
[property: JsonPropertyName("has_side_effect")] bool HasSideEffect,
[property: JsonPropertyName("content")] JsonElement? Content,
[property: JsonPropertyName("correlation_id")] string CorrelationId);
public sealed record RemoteReportingScope(
[property: JsonPropertyName("chatId")] string ChatId,
[property: JsonPropertyName("chatType")] ReportingChatType ChatType);
public sealed record RemoteMessageEvent(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("account_id")] string AccountId,
[property: JsonPropertyName("chat_id")] string ChatId,
[property: JsonPropertyName("chat_type")] ReportingChatType ChatType,
[property: JsonPropertyName("event_seq")] long EventSeq,
[property: JsonPropertyName("event_type")] string EventType,
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt,
[property: JsonPropertyName("content")] string? Content,
[property: JsonPropertyName("config_version")] long ConfigVersion,
[property: JsonPropertyName("authorization_version")] long AuthorizationVersion,
[property: JsonPropertyName("correlation_id")] string CorrelationId,
[property: JsonPropertyName("authorized")] bool Authorized = true);
public sealed record RemoteEventReceipt(
[property: JsonPropertyName("accepted")] bool Accepted,
[property: JsonPropertyName("duplicate")] bool Duplicate,
[property: JsonPropertyName("event_id")] string? EventId,
[property: JsonPropertyName("reason")] string? Reason);
public sealed record ReportingDecision(bool Allowed, string Reason, long AuthorizationVersion);
public sealed record RemoteTaskSubmission(
[property: JsonPropertyName("node_id")] string NodeId,
[property: JsonPropertyName("account_id")] string AccountId,
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("idempotency_key")] string IdempotencyKey,
[property: JsonPropertyName("payload")] JsonElement Payload,
[property: JsonPropertyName("not_after")] DateTimeOffset? NotAfter = null);
public sealed record RemoteTaskSubmissionResponse(
[property: JsonPropertyName("task_id")] string TaskId,
[property: JsonPropertyName("status")] RemoteTaskStatus Status,
[property: JsonPropertyName("duplicate")] bool Duplicate,
[property: JsonPropertyName("state_version")] long StateVersion);
public sealed record RemoteApiError(string Code, string Message, string CorrelationId);
public static class RemoteJson
{
public static readonly JsonSerializerOptions Options = Create();
private static JsonSerializerOptions Create()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
};
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
}