1199 lines
42 KiB
C#
1199 lines
42 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Net.WebSockets;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Options;
|
|
using Nexus.Api.Observability;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
/// <summary>
|
|
/// Maintains Nexus' single trusted backend connection to the OpenClaw Gateway.
|
|
/// The connector implements the protocol-v4 challenge/connect handshake, keeps
|
|
/// request/response correlation inside the backend, and never exposes Gateway
|
|
/// credentials to the browser.
|
|
/// </summary>
|
|
public sealed class GatewayConnector : BackgroundService, IGatewayConnector
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<GatewayConnector> _logger;
|
|
private readonly GatewayConnectorOptions _options;
|
|
private readonly IOpenClawDeviceIdentityStore _deviceIdentityStore;
|
|
private readonly object _stateLock = new();
|
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
|
private readonly SemaphoreSlim _reconnectSignal = new(0, 1);
|
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonNode?>> _pending = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentQueue<GatewayEventEnvelope> _recentEvents = new();
|
|
|
|
private ClientWebSocket? _socket;
|
|
private GatewayConnectionState _state = GatewayConnectionState.Initializing;
|
|
private string? _gatewayVersion;
|
|
private string? _requiredVersion;
|
|
private DateTimeOffset? _lastConnectedAt;
|
|
private DateTimeOffset? _lastEventAt;
|
|
private int _reconnectAttempts;
|
|
private int? _protocolVersion;
|
|
private string? _statusMessage;
|
|
private string? _deviceId;
|
|
private bool _deviceTokenConfigured;
|
|
private bool _pairingRequired;
|
|
private string? _pairingRequestId;
|
|
private HashSet<string> _advertisedMethods = new(StringComparer.Ordinal);
|
|
private HashSet<string> _advertisedEvents = new(StringComparer.Ordinal);
|
|
private HashSet<string> _grantedScopes = new(StringComparer.Ordinal);
|
|
private HashSet<string> _requestedScopes = new(StringComparer.Ordinal);
|
|
private Uri? _endpointOverride;
|
|
private string? _tlsFingerprintOverride;
|
|
private string? _transientBootstrapToken;
|
|
|
|
public GatewayConnector(
|
|
IConfiguration configuration,
|
|
IOptions<GatewayConnectorOptions> options,
|
|
ILogger<GatewayConnector> logger)
|
|
: this(
|
|
configuration,
|
|
options,
|
|
logger,
|
|
new OpenClawDeviceIdentityStore(
|
|
options,
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpenClawDeviceIdentityStore>.Instance))
|
|
{
|
|
}
|
|
|
|
public GatewayConnector(
|
|
IConfiguration configuration,
|
|
IOptions<GatewayConnectorOptions> options,
|
|
ILogger<GatewayConnector> logger,
|
|
IOpenClawDeviceIdentityStore deviceIdentityStore)
|
|
{
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
_options = options.Value;
|
|
_deviceIdentityStore = deviceIdentityStore;
|
|
_requestedScopes = _options.Scopes
|
|
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
|
.Select(scope => scope.Trim())
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
var pinnedVersion = _configuration["Integrations:OpenClaw:RequiredVersion"];
|
|
_requiredVersion = string.IsNullOrWhiteSpace(pinnedVersion)
|
|
? OpenClawGatewayProtocol.DefaultRequiredGatewayVersion
|
|
: pinnedVersion.Trim();
|
|
}
|
|
|
|
public GatewayConnectionState ConnectionState
|
|
{
|
|
get { lock (_stateLock) return _state; }
|
|
}
|
|
|
|
public string? GatewayVersion
|
|
{
|
|
get { lock (_stateLock) return _gatewayVersion; }
|
|
}
|
|
|
|
public string? RequiredVersion
|
|
{
|
|
get { lock (_stateLock) return _requiredVersion; }
|
|
}
|
|
|
|
public DateTimeOffset? LastConnectedAt
|
|
{
|
|
get { lock (_stateLock) return _lastConnectedAt; }
|
|
}
|
|
|
|
public int ReconnectAttempts
|
|
{
|
|
get { lock (_stateLock) return _reconnectAttempts; }
|
|
}
|
|
|
|
public string? StatusMessage
|
|
{
|
|
get { lock (_stateLock) return _statusMessage; }
|
|
}
|
|
|
|
public string? DeviceId
|
|
{
|
|
get { lock (_stateLock) return _deviceId; }
|
|
}
|
|
|
|
public bool DeviceTokenConfigured
|
|
{
|
|
get { lock (_stateLock) return _deviceTokenConfigured; }
|
|
}
|
|
|
|
public bool PairingRequired
|
|
{
|
|
get { lock (_stateLock) return _pairingRequired; }
|
|
}
|
|
|
|
public string? PairingRequestId
|
|
{
|
|
get { lock (_stateLock) return _pairingRequestId; }
|
|
}
|
|
|
|
public int? ProtocolVersion
|
|
{
|
|
get { lock (_stateLock) return _protocolVersion; }
|
|
}
|
|
|
|
public IReadOnlySet<string> AdvertisedMethods
|
|
{
|
|
get { lock (_stateLock) return new HashSet<string>(_advertisedMethods, StringComparer.Ordinal); }
|
|
}
|
|
|
|
public IReadOnlySet<string> AdvertisedEvents
|
|
{
|
|
get { lock (_stateLock) return new HashSet<string>(_advertisedEvents, StringComparer.Ordinal); }
|
|
}
|
|
|
|
public IReadOnlySet<string> GrantedScopes
|
|
{
|
|
get { lock (_stateLock) return new HashSet<string>(_grantedScopes, StringComparer.Ordinal); }
|
|
}
|
|
|
|
public string? ActiveEndpoint
|
|
{
|
|
get
|
|
{
|
|
lock (_stateLock)
|
|
return (_endpointOverride ?? BuildConfiguredWebSocketUri()).AbsoluteUri;
|
|
}
|
|
}
|
|
|
|
public string? ActiveTlsFingerprint
|
|
{
|
|
get
|
|
{
|
|
lock (_stateLock)
|
|
return _tlsFingerprintOverride ?? NormalizeFingerprint(_options.TlsFingerprint);
|
|
}
|
|
}
|
|
|
|
public DateTimeOffset? LastEventAt
|
|
{
|
|
get { lock (_stateLock) return _lastEventAt; }
|
|
}
|
|
|
|
public bool Supports(string method)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(method))
|
|
return false;
|
|
|
|
lock (_stateLock)
|
|
return _advertisedMethods.Contains(method);
|
|
}
|
|
|
|
public IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100)
|
|
{
|
|
var bounded = Math.Clamp(limit, 1, _options.EventBufferCapacity);
|
|
return _recentEvents
|
|
.ToArray()
|
|
.Reverse()
|
|
.Take(bounded)
|
|
.Select(item => item with { Payload = item.Payload?.DeepClone() })
|
|
.ToList();
|
|
}
|
|
|
|
public async Task RequestOperatorScopesAsync(
|
|
IReadOnlyCollection<string> scopes,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scopes);
|
|
var requested = scopes
|
|
.Where(scope => !string.IsNullOrWhiteSpace(scope))
|
|
.Select(scope => scope.Trim())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Order(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (requested.Length == 0 ||
|
|
!requested.Contains("operator.read", StringComparer.Ordinal) ||
|
|
requested.Any(scope => scope is not (
|
|
"operator.read" or
|
|
"operator.admin" or
|
|
"operator.approvals")))
|
|
{
|
|
throw new ArgumentException(
|
|
"Only an explicit OpenClaw operator scope set containing operator.read can be requested.",
|
|
nameof(scopes));
|
|
}
|
|
|
|
ClientWebSocket? socket;
|
|
lock (_stateLock)
|
|
{
|
|
_requestedScopes = requested.ToHashSet(StringComparer.Ordinal);
|
|
if (requested.All(_grantedScopes.Contains))
|
|
return;
|
|
|
|
socket = _socket;
|
|
_pairingRequired = false;
|
|
_pairingRequestId = null;
|
|
_statusMessage = "A new OpenClaw operator scope set was requested; reconnecting for pairing.";
|
|
}
|
|
|
|
if (socket is not { State: WebSocketState.Open })
|
|
{
|
|
SignalReconnect();
|
|
return;
|
|
}
|
|
|
|
await _sendLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (socket.State == WebSocketState.Open)
|
|
{
|
|
await socket.CloseOutputAsync(
|
|
WebSocketCloseStatus.NormalClosure,
|
|
"operator scope change",
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// The receive loop will reconnect and apply the requested scopes.
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// The receive loop already observed the disconnect.
|
|
}
|
|
finally
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
SignalReconnect();
|
|
}
|
|
|
|
public async Task ConfigureEndpointAsync(
|
|
string endpoint,
|
|
string? tlsFingerprint,
|
|
string? bootstrapToken,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!Uri.TryCreate(endpoint?.Trim(), UriKind.Absolute, out var target) ||
|
|
target.Scheme is not ("ws" or "wss") ||
|
|
string.IsNullOrWhiteSpace(target.Host) ||
|
|
!string.IsNullOrEmpty(target.UserInfo) ||
|
|
!string.IsNullOrEmpty(target.Query) ||
|
|
!string.IsNullOrEmpty(target.Fragment))
|
|
{
|
|
throw new ArgumentException("A normalized ws:// or wss:// OpenClaw endpoint is required.", nameof(endpoint));
|
|
}
|
|
|
|
var normalizedFingerprint = NormalizeFingerprint(tlsFingerprint);
|
|
if (!string.IsNullOrWhiteSpace(tlsFingerprint) && normalizedFingerprint.Length != 64)
|
|
throw new ArgumentException("TLS fingerprint must be a SHA-256 hexadecimal value.", nameof(tlsFingerprint));
|
|
if (bootstrapToken is { Length: > 4096 } || bootstrapToken?.Any(char.IsControl) == true)
|
|
throw new ArgumentException("Bootstrap token is invalid.", nameof(bootstrapToken));
|
|
|
|
ClientWebSocket? socket;
|
|
lock (_stateLock)
|
|
{
|
|
var endpointChanged = _endpointOverride is null ||
|
|
!string.Equals(_endpointOverride.AbsoluteUri, target.AbsoluteUri, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(
|
|
_tlsFingerprintOverride ?? string.Empty,
|
|
normalizedFingerprint,
|
|
StringComparison.Ordinal);
|
|
_endpointOverride = target;
|
|
_tlsFingerprintOverride = normalizedFingerprint;
|
|
if (endpointChanged)
|
|
_transientBootstrapToken = null;
|
|
if (!string.IsNullOrWhiteSpace(bootstrapToken))
|
|
_transientBootstrapToken = bootstrapToken;
|
|
_requestedScopes = new HashSet<string>(["operator.read"], StringComparer.Ordinal);
|
|
_grantedScopes.Clear();
|
|
_pairingRequired = false;
|
|
_pairingRequestId = null;
|
|
_reconnectAttempts = 0;
|
|
_state = GatewayConnectionState.Reconnecting;
|
|
_statusMessage = $"Connecting to the validated OpenClaw endpoint {target.Host}.";
|
|
socket = _socket;
|
|
}
|
|
|
|
if (socket is { State: WebSocketState.Open })
|
|
{
|
|
await _sendLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (socket.State == WebSocketState.Open)
|
|
{
|
|
await socket.CloseOutputAsync(
|
|
WebSocketCloseStatus.NormalClosure,
|
|
"setup endpoint change",
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// The receive loop already observed the endpoint transition.
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// The receive loop already disposed the socket.
|
|
}
|
|
finally
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
}
|
|
|
|
SignalReconnect();
|
|
}
|
|
|
|
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
ClientWebSocket? socket;
|
|
lock (_stateLock)
|
|
{
|
|
socket = _socket;
|
|
_deviceTokenConfigured = false;
|
|
_pairingRequired = false;
|
|
_pairingRequestId = null;
|
|
_grantedScopes.Clear();
|
|
_statusMessage = "The adopted OpenClaw connection was detached locally.";
|
|
}
|
|
|
|
if (socket is not { State: WebSocketState.Open })
|
|
return;
|
|
|
|
await _sendLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
if (socket.State == WebSocketState.Open)
|
|
{
|
|
await socket.CloseOutputAsync(
|
|
WebSocketCloseStatus.NormalClosure,
|
|
"connection detached",
|
|
cancellationToken);
|
|
}
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// The receive loop already observed the disconnect.
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// The receive loop already disposed the socket.
|
|
}
|
|
finally
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
}
|
|
|
|
public async Task<JsonNode?> InvokeAsync(
|
|
string method,
|
|
object? parameters = null,
|
|
TimeSpan? timeout = null,
|
|
CancellationToken cancellationToken = default,
|
|
OpenClawInvocationContext? invocationContext = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(method))
|
|
throw new ArgumentException("Gateway method is required.", nameof(method));
|
|
|
|
using var activity = NexusTelemetry.ActivitySource.StartActivity(
|
|
"nexus.openclaw.gateway.rpc",
|
|
ActivityKind.Client);
|
|
activity?.SetTag("rpc.system", "openclaw");
|
|
activity?.SetTag("rpc.method", method);
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var outcome = "error";
|
|
try
|
|
{
|
|
ClientWebSocket socket;
|
|
lock (_stateLock)
|
|
{
|
|
if (_state != GatewayConnectionState.Connected ||
|
|
_socket is not { State: WebSocketState.Open } currentSocket)
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"GATEWAY_DISCONNECTED",
|
|
"OpenClaw Gateway is not connected.",
|
|
retryable: true);
|
|
}
|
|
|
|
if (_advertisedMethods.Count > 0 && !_advertisedMethods.Contains(method))
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"METHOD_UNAVAILABLE",
|
|
$"OpenClaw Gateway does not advertise '{method}'.");
|
|
}
|
|
|
|
socket = currentSocket;
|
|
}
|
|
|
|
var requestId = BuildGatewayRequestId(invocationContext?.CorrelationId);
|
|
var completion = new TaskCompletionSource<JsonNode?>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
if (!_pending.TryAdd(requestId, completion))
|
|
throw new InvalidOperationException("Could not allocate a Gateway request id.");
|
|
|
|
var parametersNode = parameters switch
|
|
{
|
|
null => new JsonObject(),
|
|
JsonNode node => node.DeepClone(),
|
|
_ => JsonSerializer.SerializeToNode(parameters, JsonOptions) ?? new JsonObject()
|
|
};
|
|
|
|
var request = OpenClawGatewayProtocol.BuildRpcRequest(
|
|
requestId,
|
|
method,
|
|
parametersNode,
|
|
invocationContext);
|
|
|
|
try
|
|
{
|
|
await SendFrameAsync(socket, request, cancellationToken);
|
|
var response = await completion.Task.WaitAsync(
|
|
timeout ?? TimeSpan.FromSeconds(_options.RpcTimeoutSeconds),
|
|
cancellationToken);
|
|
outcome = "success";
|
|
return response;
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
outcome = "timeout";
|
|
throw new OpenClawGatewayRpcException(
|
|
"GATEWAY_TIMEOUT",
|
|
$"OpenClaw Gateway method '{method}' timed out.",
|
|
retryable: true);
|
|
}
|
|
finally
|
|
{
|
|
_pending.TryRemove(requestId, out _);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
outcome = "cancelled";
|
|
throw;
|
|
}
|
|
catch (OpenClawGatewayRpcException)
|
|
{
|
|
if (outcome == "error")
|
|
outcome = "gateway_error";
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
stopwatch.Stop();
|
|
activity?.SetTag("rpc.outcome", outcome);
|
|
NexusTelemetry.GatewayRpcDuration.Record(
|
|
stopwatch.Elapsed.TotalMilliseconds,
|
|
new KeyValuePair<string, object?>("outcome", outcome));
|
|
}
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await ConnectAndReceiveAsync(stoppingToken);
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (OpenClawGatewayRpcException exception)
|
|
{
|
|
CapturePairingFailure(exception);
|
|
_logger.LogWarning(
|
|
"OpenClaw Gateway handshake failed with {Code}: {Message}",
|
|
exception.Code,
|
|
exception.Message);
|
|
IncrementReconnectAttempts();
|
|
SetState(GatewayConnectionState.Disconnected, BuildSafeErrorMessage(exception));
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogWarning(exception, "OpenClaw Gateway connection failed");
|
|
IncrementReconnectAttempts();
|
|
SetState(GatewayConnectionState.Disconnected, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
lock (_stateLock)
|
|
_socket = null;
|
|
FailPending("OpenClaw Gateway connection closed.");
|
|
}
|
|
|
|
if (stoppingToken.IsCancellationRequested)
|
|
break;
|
|
|
|
if (ConnectionState == GatewayConnectionState.Failed)
|
|
{
|
|
try
|
|
{
|
|
await _reconnectSignal.WaitAsync(stoppingToken);
|
|
continue;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (_options.ReconnectMaxAttempts > 0 && ReconnectAttempts >= _options.ReconnectMaxAttempts)
|
|
{
|
|
SetState(
|
|
GatewayConnectionState.Failed,
|
|
$"Reconnect limit reached after {ReconnectAttempts} attempts.");
|
|
continue;
|
|
}
|
|
|
|
var delay = ComputeBackoff();
|
|
SetState(GatewayConnectionState.Reconnecting, $"Reconnect scheduled in {Math.Ceiling(delay.TotalSeconds)} seconds.");
|
|
try
|
|
{
|
|
await _reconnectSignal.WaitAsync(delay, stoppingToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ConnectAndReceiveAsync(CancellationToken stoppingToken)
|
|
{
|
|
OpenClawGatewayProtocol.ValidateExternalClientIdentity(_options);
|
|
|
|
using var socket = new ClientWebSocket();
|
|
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
|
socket.Options.UseDefaultCredentials = false;
|
|
|
|
var endpoint = BuildWebSocketUri();
|
|
ConfigureTransportTrust(socket, endpoint);
|
|
var gatewayBinding = BuildGatewayBinding(endpoint, GetActiveTlsFingerprint());
|
|
var requiresDeviceIdentity = OpenClawGatewayProtocol.RequiresDeviceIdentity(endpoint);
|
|
SetState(GatewayConnectionState.Reconnecting, $"Connecting to {endpoint.Host}.");
|
|
await socket.ConnectAsync(endpoint, stoppingToken);
|
|
|
|
using var handshakeTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
|
handshakeTimeout.CancelAfter(TimeSpan.FromSeconds(_options.HandshakeTimeoutSeconds));
|
|
|
|
var challenge = await ReceiveChallengeAsync(socket, handshakeTimeout.Token);
|
|
if (string.IsNullOrWhiteSpace(challenge))
|
|
throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "OpenClaw Gateway did not send connect.challenge.");
|
|
|
|
var connectId = Guid.NewGuid().ToString("N");
|
|
var (configuredToken, configuredPassword) = ResolveCredentials();
|
|
string[] scopes;
|
|
lock (_stateLock)
|
|
scopes = _requestedScopes.Order(StringComparer.Ordinal).ToArray();
|
|
string? token = configuredToken;
|
|
string? password = configuredPassword;
|
|
string? deviceToken = null;
|
|
OpenClawDeviceIdentity? identity = null;
|
|
OpenClawGatewayDeviceProof? deviceProof = null;
|
|
|
|
if (requiresDeviceIdentity)
|
|
{
|
|
identity = await _deviceIdentityStore.LoadOrCreateAsync(handshakeTimeout.Token);
|
|
var storedToken = await _deviceIdentityStore.LoadTokenAsync(
|
|
identity.DeviceId,
|
|
"operator",
|
|
gatewayBinding,
|
|
handshakeTimeout.Token);
|
|
if (storedToken is not null)
|
|
{
|
|
lock (_stateLock)
|
|
_deviceTokenConfigured = true;
|
|
|
|
if (string.IsNullOrWhiteSpace(configuredToken) &&
|
|
string.IsNullOrWhiteSpace(configuredPassword))
|
|
{
|
|
deviceToken = storedToken.Token;
|
|
token = storedToken.Token;
|
|
password = null;
|
|
}
|
|
}
|
|
|
|
var signedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
var platform = GetPlatform();
|
|
var signaturePayload = OpenClawGatewayProtocol.BuildDeviceAuthPayloadV3(
|
|
identity.DeviceId,
|
|
_options.ClientId,
|
|
_options.ClientMode,
|
|
"operator",
|
|
scopes,
|
|
signedAt,
|
|
token,
|
|
challenge,
|
|
platform,
|
|
_options.DeviceFamily);
|
|
deviceProof = new OpenClawGatewayDeviceProof(
|
|
identity.DeviceId,
|
|
identity.PublicKey,
|
|
identity.Sign(signaturePayload),
|
|
signedAt,
|
|
challenge);
|
|
|
|
lock (_stateLock)
|
|
_deviceId = identity.DeviceId;
|
|
}
|
|
|
|
var connectFrame = OpenClawGatewayProtocol.BuildConnectRequest(
|
|
connectId,
|
|
_options,
|
|
token,
|
|
password,
|
|
GetClientVersion(),
|
|
GetPlatform(),
|
|
CultureInfo.CurrentUICulture.Name,
|
|
deviceProof,
|
|
scopes,
|
|
_options.DeviceFamily,
|
|
deviceToken);
|
|
|
|
await SendFrameAsync(socket, connectFrame, handshakeTimeout.Token);
|
|
var hello = await ReceiveHelloAsync(socket, connectId, handshakeTimeout.Token);
|
|
|
|
if (identity is not null && !string.IsNullOrWhiteSpace(hello.DeviceToken))
|
|
{
|
|
await _deviceIdentityStore.StoreTokenAsync(
|
|
identity.DeviceId,
|
|
hello.Role ?? "operator",
|
|
gatewayBinding,
|
|
hello.DeviceToken,
|
|
hello.Scopes,
|
|
handshakeTimeout.Token);
|
|
lock (_stateLock)
|
|
{
|
|
_deviceTokenConfigured = true;
|
|
_transientBootstrapToken = null;
|
|
}
|
|
}
|
|
|
|
if (hello.Protocol != _options.ProtocolVersion)
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"PROTOCOL_MISMATCH",
|
|
$"Gateway negotiated protocol {hello.Protocol}; Nexus requires {_options.ProtocolVersion}.");
|
|
}
|
|
|
|
var (versionMatches, versionWarning) = EvaluateVersion(hello.ServerVersion);
|
|
if (!versionMatches && _options.FailFastOnVersionMismatch)
|
|
{
|
|
SetState(
|
|
GatewayConnectionState.Failed,
|
|
versionWarning ?? "Gateway version mismatch.");
|
|
await CloseBestEffortAsync(socket, "version mismatch");
|
|
return;
|
|
}
|
|
|
|
lock (_stateLock)
|
|
{
|
|
_socket = socket;
|
|
_state = GatewayConnectionState.Connected;
|
|
_gatewayVersion = hello.ServerVersion;
|
|
_protocolVersion = hello.Protocol;
|
|
_advertisedMethods = new HashSet<string>(hello.Methods, StringComparer.Ordinal);
|
|
_advertisedEvents = new HashSet<string>(hello.Events, StringComparer.Ordinal);
|
|
_grantedScopes = new HashSet<string>(hello.Scopes, StringComparer.Ordinal);
|
|
_lastConnectedAt = DateTimeOffset.UtcNow;
|
|
_reconnectAttempts = 0;
|
|
_statusMessage = versionWarning
|
|
?? $"Protocol v{hello.Protocol} ready; {_advertisedMethods.Count} methods advertised.";
|
|
_pairingRequired = false;
|
|
_pairingRequestId = null;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"OpenClaw Gateway connected using protocol {Protocol}; version {Version}; scopes {Scopes}",
|
|
hello.Protocol,
|
|
hello.ServerVersion ?? "unknown",
|
|
string.Join(",", hello.Scopes));
|
|
|
|
await ReceiveLoopAsync(socket, stoppingToken);
|
|
}
|
|
|
|
private async Task<string?> ReceiveChallengeAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 0; attempt < 5; attempt++)
|
|
{
|
|
var frame = await ReceiveFrameAsync(socket, cancellationToken);
|
|
if (frame is null)
|
|
return null;
|
|
if (OpenClawGatewayProtocol.IsConnectChallenge(frame, out var nonce))
|
|
return nonce;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<OpenClawGatewayHello> ReceiveHelloAsync(
|
|
ClientWebSocket socket,
|
|
string connectId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 0; attempt < 10; attempt++)
|
|
{
|
|
var frame = await ReceiveFrameAsync(socket, cancellationToken);
|
|
if (frame is null)
|
|
throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway closed before hello-ok.");
|
|
|
|
if (string.Equals(frame["type"]?.GetValue<string>(), "res", StringComparison.Ordinal) &&
|
|
string.Equals(frame["id"]?.GetValue<string>(), connectId, StringComparison.Ordinal))
|
|
{
|
|
return OpenClawGatewayProtocol.ParseHello(frame, connectId);
|
|
}
|
|
}
|
|
|
|
throw new OpenClawGatewayRpcException("INVALID_HANDSHAKE", "Gateway did not return hello-ok.");
|
|
}
|
|
|
|
private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken stoppingToken)
|
|
{
|
|
try
|
|
{
|
|
while (socket.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
|
|
{
|
|
var frame = await ReceiveFrameAsync(socket, stoppingToken);
|
|
if (frame is null)
|
|
break;
|
|
|
|
var type = frame["type"]?.GetValue<string>();
|
|
if (string.Equals(type, "res", StringComparison.Ordinal))
|
|
{
|
|
HandleResponse(frame);
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(type, "event", StringComparison.Ordinal))
|
|
HandleEvent(frame);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
|
{
|
|
// Normal application shutdown.
|
|
}
|
|
catch (WebSocketException exception)
|
|
{
|
|
_logger.LogInformation("OpenClaw Gateway WebSocket closed: {Message}", exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
await CloseBestEffortAsync(socket, "nexus connector shutdown");
|
|
if (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
IncrementReconnectAttempts();
|
|
SetState(GatewayConnectionState.Disconnected, "Gateway connection closed.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void HandleResponse(JsonNode frame)
|
|
{
|
|
var requestId = frame["id"]?.GetValue<string>();
|
|
if (string.IsNullOrWhiteSpace(requestId) || !_pending.TryRemove(requestId, out var completion))
|
|
return;
|
|
|
|
if (frame["ok"]?.GetValue<bool>() == true)
|
|
{
|
|
completion.TrySetResult(frame["payload"]?.DeepClone());
|
|
return;
|
|
}
|
|
|
|
completion.TrySetException(OpenClawGatewayProtocol.CreateRpcException(frame["error"]));
|
|
}
|
|
|
|
private void HandleEvent(JsonNode frame)
|
|
{
|
|
var eventName = frame["event"]?.GetValue<string>();
|
|
if (string.IsNullOrWhiteSpace(eventName))
|
|
return;
|
|
|
|
var receivedAt = DateTimeOffset.UtcNow;
|
|
var envelope = new GatewayEventEnvelope(
|
|
eventName,
|
|
frame["payload"]?.DeepClone(),
|
|
TryGetLong(frame["seq"]),
|
|
TryGetLong(frame["stateVersion"]),
|
|
receivedAt);
|
|
|
|
_recentEvents.Enqueue(envelope);
|
|
while (_recentEvents.Count > _options.EventBufferCapacity)
|
|
_recentEvents.TryDequeue(out _);
|
|
|
|
lock (_stateLock)
|
|
_lastEventAt = receivedAt;
|
|
}
|
|
|
|
private async Task SendFrameAsync(
|
|
ClientWebSocket socket,
|
|
JsonNode frame,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(frame.ToJsonString(JsonOptions));
|
|
await _sendLock.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
await socket.SendAsync(
|
|
new ArraySegment<byte>(bytes),
|
|
WebSocketMessageType.Text,
|
|
endOfMessage: true,
|
|
cancellationToken);
|
|
}
|
|
finally
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<JsonNode?> ReceiveFrameAsync(
|
|
ClientWebSocket socket,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[16 * 1024];
|
|
using var message = new MemoryStream();
|
|
|
|
while (true)
|
|
{
|
|
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
return null;
|
|
if (result.MessageType != WebSocketMessageType.Text)
|
|
throw new OpenClawGatewayRpcException("UNSUPPORTED_FRAME", "Gateway returned a non-text frame.");
|
|
|
|
await message.WriteAsync(buffer.AsMemory(0, result.Count), cancellationToken);
|
|
if (message.Length > _options.MaxFrameBytes)
|
|
throw new OpenClawGatewayRpcException("PAYLOAD_TOO_LARGE", "Gateway frame exceeded Nexus' configured receive limit.");
|
|
|
|
if (result.EndOfMessage)
|
|
break;
|
|
}
|
|
|
|
message.Position = 0;
|
|
try
|
|
{
|
|
return await JsonNode.ParseAsync(message, cancellationToken: cancellationToken);
|
|
}
|
|
catch (JsonException exception)
|
|
{
|
|
throw new OpenClawGatewayRpcException("INVALID_FRAME", $"Gateway returned invalid JSON: {exception.Message}");
|
|
}
|
|
}
|
|
|
|
private Uri BuildWebSocketUri()
|
|
{
|
|
lock (_stateLock)
|
|
return _endpointOverride ?? BuildConfiguredWebSocketUri();
|
|
}
|
|
|
|
private Uri BuildConfiguredWebSocketUri()
|
|
{
|
|
var configuredBase = _configuration["Integrations:OpenClaw:BaseUrl"]
|
|
?? "http://127.0.0.1:18789";
|
|
var builder = new UriBuilder(configuredBase)
|
|
{
|
|
Scheme = configuredBase.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws"
|
|
};
|
|
|
|
var configuredPath = string.IsNullOrWhiteSpace(_options.WebSocketPath)
|
|
? "/"
|
|
: _options.WebSocketPath.Trim();
|
|
builder.Path = configuredPath.StartsWith('/') ? configuredPath : "/" + configuredPath;
|
|
builder.Query = string.Empty;
|
|
builder.Fragment = string.Empty;
|
|
return builder.Uri;
|
|
}
|
|
|
|
private void ConfigureTransportTrust(ClientWebSocket socket, Uri endpoint)
|
|
{
|
|
if (string.Equals(endpoint.Scheme, "ws", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var trustedPlaintextHost = endpoint.IsLoopback ||
|
|
string.Equals(endpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) ||
|
|
_options.TrustedPlaintextHosts.Any(host =>
|
|
string.Equals(host?.Trim(), endpoint.Host, StringComparison.OrdinalIgnoreCase));
|
|
if (!trustedPlaintextHost)
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"INSECURE_GATEWAY_ENDPOINT",
|
|
"Plaintext OpenClaw WebSockets are allowed only for loopback or explicitly trusted internal hosts.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(endpoint.Scheme, "wss", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"INVALID_GATEWAY_ENDPOINT",
|
|
"OpenClaw Gateway endpoint must use ws or wss.");
|
|
}
|
|
|
|
var expectedFingerprint = GetActiveTlsFingerprint();
|
|
if (endpoint.IsLoopback && string.IsNullOrWhiteSpace(expectedFingerprint))
|
|
return;
|
|
if (string.IsNullOrWhiteSpace(expectedFingerprint))
|
|
{
|
|
throw new OpenClawGatewayRpcException(
|
|
"TLS_PIN_REQUIRED",
|
|
"Remote OpenClaw WSS endpoints require an explicitly confirmed SHA-256 TLS fingerprint.");
|
|
}
|
|
|
|
socket.Options.RemoteCertificateValidationCallback = (_, certificate, _, errors) =>
|
|
{
|
|
if (certificate is null || errors != System.Net.Security.SslPolicyErrors.None)
|
|
return false;
|
|
var actual = Convert.ToHexString(certificate.GetCertHash(System.Security.Cryptography.HashAlgorithmName.SHA256));
|
|
return string.Equals(
|
|
NormalizeFingerprint(actual),
|
|
expectedFingerprint,
|
|
StringComparison.Ordinal);
|
|
};
|
|
}
|
|
|
|
private static string NormalizeFingerprint(string? value)
|
|
=> string.Concat((value ?? string.Empty)
|
|
.Where(character => char.IsAsciiHexDigit(character)))
|
|
.ToUpperInvariant();
|
|
|
|
internal static string BuildGatewayBinding(Uri endpoint, string? tlsFingerprint)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(endpoint);
|
|
var normalizedEndpoint = endpoint.GetComponents(
|
|
UriComponents.SchemeAndServer | UriComponents.Path,
|
|
UriFormat.UriEscaped).TrimEnd('/').ToLowerInvariant();
|
|
var material = $"{normalizedEndpoint}|{NormalizeFingerprint(tlsFingerprint)}";
|
|
return OpenClawInvocationContextFactory.Hash(material);
|
|
}
|
|
|
|
private (string? Token, string? Password) ResolveCredentials()
|
|
{
|
|
var password = _configuration["Integrations:OpenClaw:Password"];
|
|
var token = _configuration["Integrations:OpenClaw:Token"];
|
|
lock (_stateLock)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_transientBootstrapToken))
|
|
token = _transientBootstrapToken;
|
|
}
|
|
return (
|
|
string.IsNullOrWhiteSpace(token) ? null : token,
|
|
string.IsNullOrWhiteSpace(password) ? null : password);
|
|
}
|
|
|
|
private string GetActiveTlsFingerprint()
|
|
{
|
|
lock (_stateLock)
|
|
return _tlsFingerprintOverride ?? NormalizeFingerprint(_options.TlsFingerprint);
|
|
}
|
|
|
|
private void SignalReconnect()
|
|
{
|
|
try
|
|
{
|
|
if (_reconnectSignal.CurrentCount == 0)
|
|
_reconnectSignal.Release();
|
|
}
|
|
catch (SemaphoreFullException)
|
|
{
|
|
// Another caller already woke the connector.
|
|
}
|
|
}
|
|
|
|
private (bool Matches, string? Warning) EvaluateVersion(string? detectedVersion)
|
|
{
|
|
if (_requiredVersion is null)
|
|
return (true, detectedVersion is null ? "Gateway version is unknown and unpinned." : null);
|
|
|
|
if (detectedVersion is null)
|
|
{
|
|
var warning = $"Gateway did not report a version; Nexus requires '{_requiredVersion}'.";
|
|
return (_options.FailFastOnMissingVersion ? false : true, warning);
|
|
}
|
|
|
|
if (!string.Equals(detectedVersion, _requiredVersion, StringComparison.OrdinalIgnoreCase))
|
|
return (false, $"Gateway version drift: detected '{detectedVersion}', required '{_requiredVersion}'.");
|
|
|
|
return (true, null);
|
|
}
|
|
|
|
private TimeSpan ComputeBackoff()
|
|
{
|
|
var attempts = Math.Max(0, ReconnectAttempts);
|
|
var backoffMs = Math.Min(
|
|
_options.ReconnectInitialDelayMs * Math.Pow(2, attempts),
|
|
_options.ReconnectMaxDelayMs);
|
|
var jitter = backoffMs * 0.25 * (Random.Shared.NextDouble() * 2 - 1);
|
|
return TimeSpan.FromMilliseconds(Math.Max(100, backoffMs + jitter));
|
|
}
|
|
|
|
private void IncrementReconnectAttempts()
|
|
{
|
|
lock (_stateLock)
|
|
_reconnectAttempts++;
|
|
}
|
|
|
|
private void SetState(GatewayConnectionState state, string? message = null)
|
|
{
|
|
lock (_stateLock)
|
|
{
|
|
_state = state;
|
|
if (message is not null)
|
|
_statusMessage = message;
|
|
}
|
|
}
|
|
|
|
private void FailPending(string message)
|
|
{
|
|
foreach (var request in _pending.ToArray())
|
|
{
|
|
if (_pending.TryRemove(request.Key, out var completion))
|
|
{
|
|
completion.TrySetException(new OpenClawGatewayRpcException(
|
|
"GATEWAY_DISCONNECTED",
|
|
message,
|
|
retryable: true));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task CloseBestEffortAsync(ClientWebSocket socket, string description)
|
|
{
|
|
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
|
return;
|
|
|
|
try
|
|
{
|
|
await socket.CloseOutputAsync(
|
|
WebSocketCloseStatus.NormalClosure,
|
|
description,
|
|
CancellationToken.None);
|
|
}
|
|
catch
|
|
{
|
|
// Connection is already gone.
|
|
}
|
|
}
|
|
|
|
private static string BuildSafeErrorMessage(OpenClawGatewayRpcException exception)
|
|
{
|
|
var recommendation = exception.Details?["recommendedNextStep"]?.GetValue<string>();
|
|
return string.IsNullOrWhiteSpace(recommendation)
|
|
? $"{exception.Code}: {exception.Message}"
|
|
: $"{exception.Code}: {exception.Message} ({recommendation})";
|
|
}
|
|
|
|
private void CapturePairingFailure(OpenClawGatewayRpcException exception)
|
|
{
|
|
var pairingRequired = OpenClawGatewayProtocol.TryReadPairingRequest(
|
|
exception,
|
|
out var requestId);
|
|
|
|
lock (_stateLock)
|
|
{
|
|
_pairingRequired = pairingRequired;
|
|
_pairingRequestId = pairingRequired ? requestId : null;
|
|
}
|
|
}
|
|
|
|
private static string BuildGatewayRequestId(string? correlationId)
|
|
{
|
|
var suffix = Guid.NewGuid().ToString("N");
|
|
if (string.IsNullOrWhiteSpace(correlationId))
|
|
return suffix;
|
|
|
|
var safe = new string(correlationId
|
|
.Where(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.')
|
|
.Take(64)
|
|
.ToArray());
|
|
return string.IsNullOrWhiteSpace(safe)
|
|
? suffix
|
|
: $"{safe}.{suffix}";
|
|
}
|
|
|
|
private static string GetClientVersion()
|
|
{
|
|
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
|
return version is null ? "0.1.0" : $"{version.Major}.{version.Minor}.{Math.Max(0, version.Build)}";
|
|
}
|
|
|
|
private static string GetPlatform()
|
|
{
|
|
if (OperatingSystem.IsWindows()) return "windows";
|
|
if (OperatingSystem.IsMacOS()) return "macos";
|
|
if (OperatingSystem.IsLinux()) return "linux";
|
|
return "unknown";
|
|
}
|
|
|
|
private static long? TryGetLong(JsonNode? node)
|
|
{
|
|
if (node is null)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
return node.GetValueKind() switch
|
|
{
|
|
JsonValueKind.Number => node.GetValue<long>(),
|
|
JsonValueKind.String when long.TryParse(node.GetValue<string>(), out var value) => value,
|
|
_ => null
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
public sealed class GatewayConnectorOptions
|
|
{
|
|
public const string SectionName = "GatewayConnector";
|
|
|
|
/// <summary>
|
|
/// WebSocket path on the Gateway. OpenClaw serves the control plane at root by default.
|
|
/// </summary>
|
|
public string WebSocketPath { get; set; } = "/";
|
|
|
|
public int ProtocolVersion { get; set; } = OpenClawGatewayProtocol.CurrentProtocol;
|
|
public int HandshakeTimeoutSeconds { get; set; } = 15;
|
|
public int RpcTimeoutSeconds { get; set; } = 30;
|
|
public int MaxFrameBytes { get; set; } = 4 * 1024 * 1024;
|
|
public int EventBufferCapacity { get; set; } = 500;
|
|
public string DeviceStatePath { get; set; } = string.Empty;
|
|
public string OperationAuditPath { get; set; } = string.Empty;
|
|
public string DeviceFamily { get; set; } = "server";
|
|
public string ClientId { get; set; } = "nexus";
|
|
public string ClientMode { get; set; } = "backend";
|
|
public string ClientDisplayName { get; set; } = "Nexus Mission Control";
|
|
public string ClientInstanceId { get; set; } = string.Empty;
|
|
public bool ExternalClientIdentitySupported { get; set; }
|
|
public bool AllowReservedInternalClientIdentity { get; set; }
|
|
public string TlsFingerprint { get; set; } = string.Empty;
|
|
public string[] TrustedPlaintextHosts { get; set; } =
|
|
[
|
|
"openclaw-gateway",
|
|
"host.docker.internal"
|
|
];
|
|
public int ReconnectInitialDelayMs { get; set; } = 1000;
|
|
public int ReconnectMaxDelayMs { get; set; } = 300_000;
|
|
public int ReconnectMaxAttempts { get; set; }
|
|
public bool FailFastOnVersionMismatch { get; set; } = true;
|
|
public bool FailFastOnMissingVersion { get; set; }
|
|
|
|
public string[] Scopes { get; set; } =
|
|
[
|
|
"operator.read"
|
|
];
|
|
|
|
public string[] Capabilities { get; set; } =
|
|
[
|
|
"approvals",
|
|
"exec-approvals",
|
|
"session-scoped-events",
|
|
"task-suggestions",
|
|
"tool-events"
|
|
];
|
|
}
|