Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 185 additions & 38 deletions src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security;
Expand All @@ -25,14 +26,18 @@ namespace OpenIPC.Viewer.Devices.Onvif;
/// the "XmlType reflection error" on <c>Onvif.Core.Client.Common.DeviceEntity</c>.
/// Same <see cref="IOnvifClient"/> contract, so the swap is one DI registration.
///
/// Auth mirrors the old builder: preemptive HTTP Basic (OpenIPC's
/// onvif_simple_server enforces it at the transport) plus a WS-Security
/// UsernameToken password digest. GetSystemDateAndTime (unauthenticated) yields
/// the camera clock offset the digest's Created stamp needs; it's cached per host.
/// Auth is three things at once, because cameras disagree about which they
/// want: preemptive HTTP Basic (OpenIPC's onvif_simple_server enforces it at
/// the transport and never challenges), HTTP Digest answered on a 401 by an
/// <see cref="HttpClient"/> whose handler carries the credentials, and a
/// WS-Security UsernameToken password digest in the envelope.
/// GetSystemDateAndTime (unauthenticated) yields the camera clock offset the
/// token's Created stamp needs; it's cached per host.
/// </summary>
public sealed class SoapOnvifClient : IOnvifClient
{
private const string Soap = "http://www.w3.org/2003/05/soap-envelope";
private const string Soap11 = "http://schemas.xmlsoap.org/soap/envelope/";
private const string Tds = "http://www.onvif.org/ver10/device/wsdl";
private const string Trt = "http://www.onvif.org/ver10/media/wsdl";
private const string Tptz = "http://www.onvif.org/ver20/ptz/wsdl";
Expand All @@ -52,19 +57,71 @@ public sealed class SoapOnvifClient : IOnvifClient
// first authed call, refreshed on an auth fault.
private readonly ConcurrentDictionary<string, TimeSpan> _shiftByHost = new(StringComparer.OrdinalIgnoreCase);

// Hosts that turned out to speak SOAP 1.1 only. Learned from the retry the
// first time a host answers 1.2 with nothing usable, then used as the first
// choice — so the discovery costs one extra request per host, ever, and a
// state-changing call is never the one doing the discovering.
private readonly ConcurrentDictionary<string, byte> _soap11Hosts = new(StringComparer.OrdinalIgnoreCase);

// One client per camera address. A handler that carries credentials is what
// lets HttpClient answer a 401 challenge on its own, which is the only way
// to satisfy a camera that asks for Digest rather than Basic.
//
// Keyed by host:port alone — a camera has one credential at a time — with
// the credential kept beside the client so a password change swaps the
// entry and disposes the superseded one, instead of caching every password
// this process has ever seen. Growth is bounded by the number of camera
// addresses. A plain lock rather than GetOrAdd: it also stops a concurrent
// miss from constructing a second client that nothing would ever dispose.
private readonly object _clientsGate = new();
private readonly Dictionary<string, (string Credential, HttpClient Client)> _authedClients = new(StringComparer.Ordinal);

public SoapOnvifClient(ILogger<SoapOnvifClient> logger)
{
_logger = logger;
_http = NewClient(credentials: null);
}

private static HttpClient NewClient(NetworkCredential? credentials)
{
// onvif_simple_server is CGI-style: one request per connection, then it
// closes the socket. Disable pooling so we never reuse a dead socket.
_http = new HttpClient(new SocketsHttpHandler
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.Zero,
ConnectTimeout = CallTimeout,
})
{
Timeout = CallTimeout,
};
if (credentials is not null)
{
handler.Credentials = credentials;
// Let the camera state its terms first: preemptive auth would send
// Basic to a device that only accepts Digest.
handler.PreAuthenticate = false;
}
return new HttpClient(handler) { Timeout = CallTimeout };
}

private HttpClient ClientFor(Uri service, CameraCredentials? credentials)
{
if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) return _http;

var key = $"{service.Host}:{service.Port}";
var credential = $"{c.Username}\u0000{c.Password}";
lock (_clientsGate)
{
if (_authedClients.TryGetValue(key, out var entry))
{
if (entry.Credential == credential) return entry.Client;
// The password changed. A request in flight on the old client
// was sent with the old password and is failing anyway, so
// disposing under it loses nothing.
entry.Client.Dispose();
}

var client = NewClient(new NetworkCredential(c.Username, c.Password ?? string.Empty));
_authedClients[key] = (credential, client);
return client;
}
}

// --- Device service -----------------------------------------------------
Expand Down Expand Up @@ -126,7 +183,11 @@ public async Task<Uri> GetStreamUriAsync(OnvifEndpoint endpoint, string profileT
$"<trt:ProfileToken>{Escape(profileToken)}</trt:ProfileToken></trt:GetStreamUri>";

var body = await CallAuthedAsync(media, endpoint, $"{Trt}/GetStreamUri", reqBody, ct).ConfigureAwait(false);
var uri = Value(body, "Uri");
// The spec nests this as MediaUri/Uri, and Hikvision (among others) sends
// exactly that. Reading it as a direct child only matched the flatter
// shape onvif_simple_server returns, so a compliant camera looked like
// it had answered with no stream at all.
var uri = Descendant(body, "Uri")?.Value;
if (string.IsNullOrWhiteSpace(uri))
throw new InvalidOperationException($"GetStreamUri returned no URI for profile {profileToken}");
return new Uri(uri, UriKind.Absolute);
Expand Down Expand Up @@ -193,8 +254,11 @@ public async Task<string> SetPresetAsync(OnvifEndpoint endpoint, string profileT
$"<tptz:SetPreset xmlns:tptz=\"{Tptz}\">" +
$"<tptz:ProfileToken>{Escape(profileToken)}</tptz:ProfileToken>" +
$"<tptz:PresetName>{Escape(name)}</tptz:PresetName></tptz:SetPreset>";
var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct).ConfigureAwait(false);
return Value(body, "PresetToken") ?? string.Empty;
// retryable: false — if the camera ran the request and answered
// garbage, a resend would create a second preset.
var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct, retryable: false).ConfigureAwait(false);
// Nested the same way on some firmwares, for the same reason.
return Descendant(body, "PresetToken")?.Value ?? string.Empty;
}

public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct)
Expand All @@ -204,7 +268,10 @@ public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken,
$"<tptz:RemovePreset xmlns:tptz=\"{Tptz}\">" +
$"<tptz:ProfileToken>{Escape(profileToken)}</tptz:ProfileToken>" +
$"<tptz:PresetToken>{Escape(presetToken)}</tptz:PresetToken></tptz:RemovePreset>";
await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct).ConfigureAwait(false);
// retryable: false — a resend after a successful-but-unreadable remove
// would fault on the now-missing preset and report failure for a
// removal that worked.
await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct, retryable: false).ConfigureAwait(false);
}

// --- Transport ----------------------------------------------------------
Expand All @@ -231,25 +298,30 @@ private async Task<Uri> ResolveServiceAsync(OnvifEndpoint endpoint, ServiceKind

// Authenticated call with a per-host clock shift; on a fault, refresh the
// shift once and retry (covers a stale/absent offset causing digest rejection).
private async Task<XElement> CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct)
private async Task<XElement> CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct, bool retryable = true)
{
var host = endpoint.DeviceServiceUri.Host;
if (!_shiftByHost.TryGetValue(host, out var shift))
{
// Also where the host's SOAP dialect gets discovered, since this
// probe runs before the first real call — so by the time a mutation
// goes out, the dialect is already known.
shift = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false);
_shiftByHost[host] = shift;
}

try
{
return await CallAsync(service, action, body, endpoint.Credentials, shift, ct).ConfigureAwait(false);
return await CallAsync(service, action, body, endpoint.Credentials, shift, retryable, ct).ConfigureAwait(false);
}
catch (OnvifFaultException)
{
// Maybe the clock drifted / the first shift was wrong — recompute and retry once.
// Maybe the clock drifted / the first shift was wrong — recompute and
// retry once. Safe for mutations too: a fault means the camera
// refused the request, not that it ran it.
var fresh = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false);
_shiftByHost[host] = fresh;
return await CallAsync(service, action, body, endpoint.Credentials, fresh, ct).ConfigureAwait(false);
return await CallAsync(service, action, body, endpoint.Credentials, fresh, retryable, ct).ConfigureAwait(false);
}
}

Expand All @@ -259,7 +331,7 @@ private async Task<TimeSpan> GetTimeShiftAsync(Uri deviceService, CancellationTo
{
var body = await CallAsync(deviceService, $"{Tds}/GetSystemDateAndTime",
$"<tds:GetSystemDateAndTime xmlns:tds=\"{Tds}\"/>",
credentials: null, shift: TimeSpan.Zero, ct).ConfigureAwait(false);
credentials: null, shift: TimeSpan.Zero, retryable: true, ct).ConfigureAwait(false);

var utc = Descendant(body, "UTCDateTime");
var date = Child(utc, "Date");
Expand All @@ -280,38 +352,53 @@ private async Task<TimeSpan> GetTimeShiftAsync(Uri deviceService, CancellationTo
}
}

private async Task<XElement> CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, CancellationToken ct)
private async Task<XElement> CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, bool retryable, CancellationToken ct)
{
var header = SecurityHeader(credentials, shift);
var envelope =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
$"<s:Envelope xmlns:s=\"{Soap}\">{header}<s:Body>{body}</s:Body></s:Envelope>";

using var req = new HttpRequestMessage(HttpMethod.Post, service);
req.Headers.ConnectionClose = true;
if (credentials is { } c && !string.IsNullOrEmpty(c.Username))
// SOAP 1.2 first — the version ONVIF specifies — unless this host has
// already shown it only answers 1.1. A camera that answers the first
// choice with nothing usable gets one retry in the other dialect, which
// several firmwares need and which costs one request to find out. The
// winner is remembered per host, so the discovery happens once.
//
// Except for mutations (retryable: false). An unusable response does
// not prove the request was not executed — a camera that ran SetPreset
// and then answered garbage would get a duplicate preset from a resend.
// Mutations rely on the dialect already learned from this host's
// earlier read calls (the clock probe at minimum) and fail honestly
// rather than guessing.
var soap12First = !_soap11Hosts.ContainsKey(service.Host);
var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: soap12First, ct)
.ConfigureAwait(false);

if (!IsUsable(text) && retryable)
{
var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}"));
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
_logger.LogDebug("ONVIF {Action}: SOAP {First} gave HTTP {Status} and {Length} bytes; retrying as SOAP {Second}",
action, soap12First ? "1.2" : "1.1", (int)status, text.Length, soap12First ? "1.1" : "1.2");
(status, text) = await SendAsync(service, action, body, credentials, shift, soap12: !soap12First, ct)
.ConfigureAwait(false);

if (IsUsable(text))
{
// The other dialect is the one this host speaks; remember it in
// whichever direction the flip went.
if (soap12First) _soap11Hosts[service.Host] = 1;
else _soap11Hosts.TryRemove(service.Host, out _);
}
}

var content = new StringContent(envelope, Encoding.UTF8);
content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" };
content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\""));
req.Content = content;

using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false);
var text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(text))
throw new InvalidOperationException($"ONVIF {action}: empty response (HTTP {(int)resp.StatusCode})");
if (string.IsNullOrWhiteSpace(text)) throw EmptyBody(action, status);

XElement root;
try { root = XDocument.Parse(text).Root!; }
catch (Exception ex) { throw new InvalidOperationException($"ONVIF {action}: malformed response", ex); }

var bodyEl = Child(Child(root, "Body"), null);
if (bodyEl is null)
throw new InvalidOperationException($"ONVIF {action}: empty SOAP body");
{
_logger.LogDebug("ONVIF {Action}: HTTP {Status}, body: {Body}",
action, (int)status, text.Length > 400 ? text[..400] : text);
throw EmptyBody(action, status);
}
if (bodyEl.Name.LocalName == "Fault")
{
var reason = Descendant(bodyEl, "Text")?.Value
Expand All @@ -322,6 +409,66 @@ private async Task<XElement> CallAsync(Uri service, string action, string body,
return bodyEl;
}

private async Task<(HttpStatusCode Status, string Text)> SendAsync(
Uri service, string action, string body, CameraCredentials? credentials,
TimeSpan shift, bool soap12, CancellationToken ct)
{
var header = SecurityHeader(credentials, shift);
var envelope =
"<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
$"<s:Envelope xmlns:s=\"{(soap12 ? Soap : Soap11)}\">{header}<s:Body>{body}</s:Body></s:Envelope>";

using var req = new HttpRequestMessage(HttpMethod.Post, service);
req.Headers.ConnectionClose = true;
if (credentials is { } c && !string.IsNullOrEmpty(c.Username))
{
// Preemptive Basic for onvif_simple_server, which enforces it at the
// transport and never challenges. A camera that wants Digest answers
// 401 instead, and the handler's credentials settle that exchange.
var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}"));
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
}

var content = new StringContent(envelope, Encoding.UTF8);
if (soap12)
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" };
content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\""));
}
else
{
// SOAP 1.1 has no action parameter on the content type; it travels
// in a header of its own.
content.Headers.ContentType = new MediaTypeHeaderValue("text/xml") { CharSet = "utf-8" };
req.Headers.TryAddWithoutValidation("SOAPAction", $"\"{action}\"");
}
req.Content = content;

using var resp = await ClientFor(service, credentials)
.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false);
return (resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false) ?? string.Empty);
}

// Worth reading: it parses, and its Body holds something. A firmware built
// for SOAP 1.1 typically answers a 1.2 request with no bytes at all or with
// an envelope whose Body is empty, and both mean "ask again differently".
// A fault is a usable answer — a camera that says why it refused is not
// asked twice.
private static bool IsUsable(string text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
try { return Child(Child(XDocument.Parse(text).Root!, "Body"), null) is not null; }
catch (Exception) { return false; }
}

// An empty body is what a camera sends when it will not say why. In
// practice it means ONVIF is switched off in the camera's own settings or
// the account has no ONVIF rights — neither of which arrives as a fault, so
// the message has to name them. The status code is the only other clue.
private static InvalidOperationException EmptyBody(string action, HttpStatusCode status) =>
new($"ONVIF {action}: the camera returned an empty SOAP body (HTTP {(int)status}). " +
"Check that ONVIF is enabled on the camera and that this account may use it.");

private static string SecurityHeader(CameraCredentials? credentials, TimeSpan shift)
{
if (credentials is not { } c || string.IsNullOrEmpty(c.Username))
Expand Down
Loading