404 lines
17 KiB
C#
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;
|
|
}
|
|
}
|