Skip to content

Commit 8bb8e74

Browse files
matteiusclaude
andcommitted
fix(onvif): make events work with strict ONVIF servers (Reolink) (#374)
Reolink cameras' ONVIF events stack was rejecting every PullMessages call with HTTP 400 + Code=SOAP-ENV:Sender and an empty Reason, even though CreatePullPointSubscription succeeded. For a second Reolink model (Argus) we couldn't even discover the events XAddr from GetServices. Four changes land together: A. Add WS-Addressing headers (wsa:MessageID, wsa:To, wsa:Action) to every SOAP request this file emits, and set the SOAP 1.2 `action=` parameter on the Content-Type header. Permissive ONVIF servers (Hikvision, Dahua, Axis, Amcrest) accept our requests either way, but strict stacks (Reolink, some Vivotek firmwares) reject anything without these. Thread a `const char *action` through create_onvif_request / send_onvif_request_to_url / send_onvif_request; action URIs per the ONVIF WSDLs for GetServices, CreatePullPointSubscription, and PullMessages. Use the full request URL as `wsa:To` for requests to subscription-specific endpoints (PullMessages landing on `…/PullSubManager?Idx=…` URLs). B. The Content-Type change is folded into the same threading: when an action is provided we emit Content-Type: application/soap+xml; charset=utf-8; action="…" which SOAP 1.2 spec names as the canonical way to convey the action; some strict servers use this instead of the wsa header. C. Improve SOAP fault logging. When the parsed fault has a Code but no Reason/Text element (Reolink's exact failure shape), dump a truncated snippet of the raw response XML at ERROR so future bug reports carry the full vendor payload — previously the diagnostic was "Code=SOAP-ENV:Sender, Reason=(none)" and no pcap, which left us unable to see what the camera was actually complaining about. Added a unit test for the Reolink fault shape. D. Rewrite discover_event_service_url to parse GetServices with ezxml and walk <Service> children comparing <Namespace> text. The old substring-and-512-byte-window heuristic missed the events XAddr whenever a service block included a Capabilities subtree (#374, Argus case). The old heuristic is kept as a last-resort fallback with a wider 2048-byte window, and the /onvif/Events path is added to the fallback probe list for firmwares that serve events there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0154777 commit 8bb8e74

3 files changed

Lines changed: 190 additions & 49 deletions

File tree

src/video/onvif_detection.c

Lines changed: 157 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
#include "video/onvif_motion_recording.h"
2323
#include "video/zone_filter.h"
2424
#include "database/db_detections.h"
25+
#include "ezxml.h"
26+
27+
/* WS-Addressing action URIs for the ONVIF operations this file emits.
28+
* Strict ONVIF servers (Reolink, some Vivotek) reject requests that don't
29+
* carry a matching wsa:Action header and `action=` Content-Type parameter —
30+
* see #374. */
31+
#define ONVIF_ACTION_GET_SERVICES "http://www.onvif.org/ver10/device/wsdl/GetServices"
32+
#define ONVIF_ACTION_CREATE_PULLPOINT "http://www.onvif.org/ver10/events/wsdl/EventPortType/CreatePullPointSubscriptionRequest"
33+
#define ONVIF_ACTION_PULL_MESSAGES "http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesRequest"
34+
35+
/* External UUID generator (used for wsa:MessageID). */
36+
extern void generate_uuid(char *uuid, size_t size);
2537

2638
// Global variables
2739
static bool initialized = false;
@@ -70,8 +82,15 @@ static size_t write_memory_callback(void *contents, size_t size, size_t nmemb, v
7082
return realsize;
7183
}
7284

73-
// Create ONVIF SOAP request with WS-Security (if credentials provided)
74-
static char *create_onvif_request(const char *username, const char *password, const char *request_body) {
85+
/*
86+
* Create ONVIF SOAP request with WS-Security (if credentials provided) and
87+
* WS-Addressing headers. `action` and `to` are the WS-Addressing action URI
88+
* and destination URL respectively; both are required (strict ONVIF servers
89+
* such as Reolink reject requests missing either — #374).
90+
*/
91+
static char *create_onvif_request(const char *username, const char *password,
92+
const char *request_body,
93+
const char *action, const char *to) {
7594
bool has_credentials = (username && strlen(username) > 0 && password && strlen(password) > 0);
7695
char *security_header = NULL;
7796

@@ -87,8 +106,22 @@ static char *create_onvif_request(const char *username, const char *password, co
87106
}
88107
}
89108

90-
// Allocate envelope dynamically to accommodate variable-length body and header
91-
size_t envelope_size = strlen(request_body) + strlen(security_header) + 1024;
109+
/* Build a WS-Addressing fragment if an action was provided. Using
110+
* `urn:uuid:<v4>` for MessageID and leaving mustUnderstand off on To/Action
111+
* — cameras that ignore WS-Addressing shouldn't then fault because they
112+
* "didn't understand" an optional header. */
113+
char uuid[48] = {0};
114+
generate_uuid(uuid, sizeof(uuid));
115+
116+
const char *safe_action = action ? action : "";
117+
const char *safe_to = to ? to : "";
118+
119+
/* The WS-Addressing block inflates the envelope; reserve enough for
120+
* MessageID (urn:uuid:… = ~45 chars), the action URI, the destination URL,
121+
* the security header, and the body. 2 KB of slack above those sizes is
122+
* plenty. */
123+
size_t envelope_size = strlen(request_body) + strlen(security_header)
124+
+ strlen(safe_action) + strlen(safe_to) + 2048;
92125
char *soap_request = malloc(envelope_size);
93126
if (!soap_request) {
94127
free(security_header);
@@ -97,28 +130,43 @@ static char *create_onvif_request(const char *username, const char *password, co
97130

98131
snprintf(soap_request, envelope_size,
99132
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
100-
"<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\">"
101-
"<s:Header>%s</s:Header>"
133+
"<s:Envelope xmlns:s=\"http://www.w3.org/2003/05/soap-envelope\" "
134+
"xmlns:wsa=\"http://www.w3.org/2005/08/addressing\">"
135+
"<s:Header>"
136+
"<wsa:MessageID>urn:uuid:%s</wsa:MessageID>"
137+
"<wsa:To>%s</wsa:To>"
138+
"<wsa:Action>%s</wsa:Action>"
139+
"%s"
140+
"</s:Header>"
102141
"<s:Body xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
103-
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">%s</s:Body>"
142+
"xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">%s</s:Body>"
104143
"</s:Envelope>",
105-
security_header, request_body);
144+
uuid, safe_to, safe_action, security_header, request_body);
106145

107146
free(security_header);
108147
return soap_request;
109148
}
110149

111-
// Send ONVIF request to a full URL (bypassing the base URL + /onvif/ + service construction)
150+
/*
151+
* Send ONVIF request to a full URL (bypassing the base URL + /onvif/ + service
152+
* construction). `action` is the WS-Addressing action URI; it's also added as
153+
* the `action=` parameter on the Content-Type header per SOAP 1.2, which
154+
* strict ONVIF servers require (#374).
155+
*/
112156
static char *send_onvif_request_to_url(const char *full_url, const char *username,
113-
const char *password, const char *request_body) {
157+
const char *password, const char *request_body,
158+
const char *action) {
114159
if (!initialized || !curl_handle) {
115160
log_error("ONVIF detection system not initialized");
116161
return NULL;
117162
}
118163

119164
log_info("ONVIF Detection: Sending request to %s", full_url);
120165

121-
char *soap_request = create_onvif_request(username, password, request_body);
166+
/* Use full_url as the WS-Addressing To, which is exactly what the request
167+
* is being POSTed to. */
168+
char *soap_request = create_onvif_request(username, password, request_body,
169+
action, full_url);
122170
if (!soap_request) {
123171
log_error("Failed to create ONVIF request");
124172
return NULL;
@@ -131,9 +179,20 @@ static char *send_onvif_request_to_url(const char *full_url, const char *usernam
131179
curl_easy_setopt(curl_handle, CURLOPT_POSTFIELDS, soap_request);
132180
curl_easy_setopt(curl_handle, CURLOPT_POSTFIELDSIZE, strlen(soap_request));
133181

134-
// Set headers
182+
/* Content-Type carries the SOAP 1.2 action parameter in addition to the
183+
* charset. Fall back to the plain type when no action is provided. */
184+
char content_type[512];
185+
if (action && action[0] != '\0') {
186+
snprintf(content_type, sizeof(content_type),
187+
"Content-Type: application/soap+xml; charset=utf-8; action=\"%s\"",
188+
action);
189+
} else {
190+
snprintf(content_type, sizeof(content_type),
191+
"Content-Type: application/soap+xml; charset=utf-8");
192+
}
193+
135194
struct curl_slist *headers = NULL;
136-
headers = curl_slist_append(headers, "Content-Type: application/soap+xml; charset=utf-8");
195+
headers = curl_slist_append(headers, content_type);
137196
curl_easy_setopt(curl_handle, CURLOPT_HTTPHEADER, headers);
138197

139198
// Set up response buffer
@@ -179,8 +238,9 @@ static char *send_onvif_request_to_url(const char *full_url, const char *usernam
179238
}
180239

181240
// Send ONVIF request and get response
182-
static char *send_onvif_request(const char *url, const char *username, const char *password,
183-
const char *request_body, const char *service) {
241+
static char *send_onvif_request(const char *url, const char *username, const char *password,
242+
const char *request_body, const char *service,
243+
const char *action) {
184244
if (!initialized || !curl_handle) {
185245
log_error("ONVIF detection system not initialized");
186246
return NULL;
@@ -190,7 +250,7 @@ static char *send_onvif_request(const char *url, const char *username, const cha
190250
char full_url[512];
191251
snprintf(full_url, sizeof(full_url), "%s/onvif/%s", url, service);
192252

193-
return send_onvif_request_to_url(full_url, username, password, request_body);
253+
return send_onvif_request_to_url(full_url, username, password, request_body, action);
194254
}
195255

196256
// Extract subscription address from response
@@ -219,45 +279,92 @@ static char *extract_subscription_address(const char *response) {
219279
return NULL;
220280
}
221281

222-
// Query GetServices and return the event service URL, or NULL if not found.
223-
// The returned string is heap-allocated; caller must free() it.
282+
/* Return the local name of an ezxml element (strips any "prefix:" prefix). */
283+
static const char *local_name(ezxml_t el) {
284+
if (!el || !el->name) return "";
285+
const char *colon = strchr(el->name, ':');
286+
return colon ? colon + 1 : el->name;
287+
}
288+
289+
/* Find a direct child with the given local name, ignoring namespace prefix. */
290+
static ezxml_t child_by_local_name(ezxml_t parent, const char *local) {
291+
if (!parent || !local) return NULL;
292+
for (ezxml_t c = parent->child; c; c = c->ordered) {
293+
if (strcmp(local_name(c), local) == 0) return c;
294+
}
295+
return NULL;
296+
}
297+
298+
/*
299+
* Query GetServices and return the event service URL, or NULL if not found.
300+
* The returned string is heap-allocated; caller must free() it.
301+
*
302+
* Previous implementation used substring matching with a 512-byte window,
303+
* which misses the XAddr when a Service element carries a Capabilities
304+
* subtree (#374, Reolink Argus). We now parse the response with ezxml and
305+
* walk Service elements, comparing the Namespace text to the events WSDL.
306+
*/
224307
static char *discover_event_service_url(const char *url, const char *username, const char *password) {
225308
const char *request_body =
226309
"<GetServices xmlns=\"http://www.onvif.org/ver10/device/wsdl\">"
227310
"<IncludeCapability>false</IncludeCapability>"
228311
"</GetServices>";
229312

230313
// GetServices is always sent to the standard device management endpoint
231-
char *response = send_onvif_request(url, username, password, request_body, "device_service");
314+
char *response = send_onvif_request(url, username, password, request_body,
315+
"device_service", ONVIF_ACTION_GET_SERVICES);
232316
if (!response) {
233317
log_warn("ONVIF: GetServices to device_service endpoint failed");
234318
return NULL;
235319
}
236320

237-
// Find the events service namespace in the response
238-
const char *events_ns = "http://www.onvif.org/ver10/events/wsdl";
239-
const char *ns_pos = strstr(response, events_ns);
240-
if (!ns_pos) {
241-
log_warn("ONVIF: Events service namespace not found in GetServices response");
242-
free(response);
243-
return NULL;
244-
}
245-
246-
// Search forward from the namespace position for XAddr (prefixed and un-prefixed variants)
247-
const char *xaddr_open[] = {"<tds:XAddr>", "<XAddr>", NULL};
248-
const char *xaddr_close[] = {"</tds:XAddr>", "</XAddr>", NULL};
321+
/* ezxml_parse_str modifies the buffer in place, and the returned
322+
* tree references it, so keep `response` alive until we copy the
323+
* matched URL out. */
324+
ezxml_t xml = ezxml_parse_str(response, strlen(response));
249325
char *event_url = NULL;
250326

251-
for (int i = 0; xaddr_open[i] != NULL; i++) {
252-
const char *tag = strstr(ns_pos, xaddr_open[i]);
253-
// Only accept the XAddr if it is within the same Service element (~512 bytes window)
254-
if (tag && (tag - ns_pos) < 512) {
255-
const char *val_start = tag + strlen(xaddr_open[i]);
256-
const char *val_end = strstr(val_start, xaddr_close[i]);
257-
if (val_end) {
258-
size_t url_len = (size_t)(val_end - val_start);
259-
event_url = strndup(val_start, url_len);
260-
break;
327+
if (xml) {
328+
ezxml_t body = child_by_local_name(xml, "Body");
329+
ezxml_t resp = body ? child_by_local_name(body, "GetServicesResponse") : NULL;
330+
331+
if (resp) {
332+
const char *events_ns = "http://www.onvif.org/ver10/events/wsdl";
333+
for (ezxml_t svc = resp->child; svc && !event_url; svc = svc->ordered) {
334+
if (strcmp(local_name(svc), "Service") != 0) continue;
335+
336+
ezxml_t ns = child_by_local_name(svc, "Namespace");
337+
ezxml_t xaddr = child_by_local_name(svc, "XAddr");
338+
const char *ns_text = ns ? ezxml_txt(ns) : "";
339+
const char *xaddr_text = xaddr ? ezxml_txt(xaddr) : "";
340+
341+
if (ns_text && xaddr_text && xaddr_text[0] != '\0'
342+
&& strcmp(ns_text, events_ns) == 0) {
343+
event_url = strdup(xaddr_text);
344+
}
345+
}
346+
}
347+
ezxml_free(xml);
348+
}
349+
350+
/* Fallback: the old substring heuristic, for responses that ezxml
351+
* can't parse (malformed XML from buggy firmware). */
352+
if (!event_url) {
353+
const char *events_ns = "http://www.onvif.org/ver10/events/wsdl";
354+
const char *ns_pos = strstr(response, events_ns);
355+
if (ns_pos) {
356+
const char *xaddr_open[] = {"<tds:XAddr>", "<XAddr>", NULL};
357+
const char *xaddr_close[] = {"</tds:XAddr>", "</XAddr>", NULL};
358+
for (int i = 0; xaddr_open[i] != NULL; i++) {
359+
const char *tag = strstr(ns_pos, xaddr_open[i]);
360+
if (tag && (tag - ns_pos) < 2048) {
361+
const char *val_start = tag + strlen(xaddr_open[i]);
362+
const char *val_end = strstr(val_start, xaddr_close[i]);
363+
if (val_end) {
364+
event_url = strndup(val_start, (size_t)(val_end - val_start));
365+
break;
366+
}
367+
}
261368
}
262369
}
263370
}
@@ -311,16 +418,18 @@ static onvif_subscription_t *get_subscription(const char *url, const char *usern
311418

312419
if (discovered_url) {
313420
log_info("ONVIF: Sending CreatePullPointSubscription to discovered URL: %s", discovered_url);
314-
response = send_onvif_request_to_url(discovered_url, username, password, request_body);
421+
response = send_onvif_request_to_url(discovered_url, username, password, request_body,
422+
ONVIF_ACTION_CREATE_PULLPOINT);
315423
free(discovered_url);
316424
}
317425

318426
if (!response) {
319427
// Fall back through common event service path suffixes
320-
const char *fallback_services[] = {"service", "event_service", NULL};
428+
const char *fallback_services[] = {"service", "event_service", "Events", NULL};
321429
for (int i = 0; fallback_services[i] && !response; i++) {
322430
log_info("ONVIF: Trying fallback event endpoint: onvif/%s", fallback_services[i]);
323-
response = send_onvif_request(url, username, password, request_body, fallback_services[i]);
431+
response = send_onvif_request(url, username, password, request_body,
432+
fallback_services[i], ONVIF_ACTION_CREATE_PULLPOINT);
324433
}
325434
}
326435

@@ -554,7 +663,8 @@ int detect_motion_onvif(const char *onvif_url, const char *username, const char
554663
response = send_onvif_request_to_url(subscription->subscription_address,
555664
subscription->username,
556665
subscription->password,
557-
request_body);
666+
request_body,
667+
ONVIF_ACTION_PULL_MESSAGES);
558668
} else {
559669
// Legacy fallback: extract last path component and re-append under /onvif/
560670
log_warn("ONVIF Detection: subscription_address is not a full URL ('%s'), using legacy path extraction",
@@ -565,7 +675,8 @@ int detect_motion_onvif(const char *onvif_url, const char *username, const char
565675
subscription->username,
566676
subscription->password,
567677
request_body,
568-
service);
678+
service,
679+
ONVIF_ACTION_PULL_MESSAGES);
569680
free(service);
570681
}
571682
}

src/video/onvif_soap.c

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,21 +238,23 @@ void onvif_log_soap_fault(const char *response, size_t response_len, const char
238238
if (faultstring) reason_str = ezxml_txt(faultstring);
239239
}
240240

241+
bool have_reason = (reason_str && reason_str[0] != '\0');
242+
241243
/* Build and log the message */
242244
if (code_str && code_str[0] != '\0') {
243245
if (subcode_str && subcode_str[0] != '\0') {
244246
log_error("[%s] SOAP Fault: Code=%s, Subcode=%s, Reason=%s",
245247
ctx,
246248
code_str,
247249
subcode_str,
248-
(reason_str && reason_str[0] != '\0') ? reason_str : "(none)");
250+
have_reason ? reason_str : "(none)");
249251
} else {
250252
log_error("[%s] SOAP Fault: Code=%s, Reason=%s",
251253
ctx,
252254
code_str,
253-
(reason_str && reason_str[0] != '\0') ? reason_str : "(none)");
255+
have_reason ? reason_str : "(none)");
254256
}
255-
} else if (reason_str && reason_str[0] != '\0') {
257+
} else if (have_reason) {
256258
log_error("[%s] SOAP Fault: %s", ctx, reason_str);
257259
} else {
258260
/* Could not extract anything useful — log raw */
@@ -263,6 +265,18 @@ void onvif_log_soap_fault(const char *response, size_t response_len, const char
263265
log_error("[%s] SOAP Fault (could not extract details): %s", ctx, snippet);
264266
}
265267

268+
/* Extra diagnostic: when the structured fault lacks a Reason — as happens
269+
* with strict ONVIF servers like Reolink (#374) — dump a snippet of the
270+
* raw XML so future bug reports include enough for us to spot vendor
271+
* quirks (Subcode values, Detail elements, etc.) without needing a pcap. */
272+
if (!have_reason && code_str && code_str[0] != '\0') {
273+
char snippet[768];
274+
size_t n = response_len < sizeof(snippet) - 1 ? response_len : sizeof(snippet) - 1;
275+
memcpy(snippet, response, n);
276+
snippet[n] = '\0';
277+
log_error("[%s] SOAP Fault raw XML (no Reason/Text): %s", ctx, snippet);
278+
}
279+
266280
ezxml_free(xml);
267281
free(buf);
268282
}

tests/unit/test_onvif_soap_fault.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ void test_soap_env_namespace(void) {
162162
TEST_PASS();
163163
}
164164

165+
/* ================================================================
166+
* Reolink-style fault: Code present but no Reason/Text element (#374)
167+
* Exercises the raw-XML-snippet diagnostic branch in onvif_log_soap_fault.
168+
* ================================================================ */
169+
void test_fault_code_no_reason(void) {
170+
const char *xml =
171+
"<SOAP-ENV:Envelope xmlns:SOAP-ENV=\"http://www.w3.org/2003/05/soap-envelope\">"
172+
"<SOAP-ENV:Body><SOAP-ENV:Fault>"
173+
"<SOAP-ENV:Code><SOAP-ENV:Value>SOAP-ENV:Sender</SOAP-ENV:Value></SOAP-ENV:Code>"
174+
"</SOAP-ENV:Fault></SOAP-ENV:Body></SOAP-ENV:Envelope>";
175+
176+
onvif_log_soap_fault(xml, strlen(xml), "TestReolink");
177+
TEST_PASS();
178+
}
179+
165180
/* ================================================================
166181
* Uppercase S namespace prefix
167182
* ================================================================ */
@@ -196,6 +211,7 @@ int main(void) {
196211
RUN_TEST(test_null_context);
197212
RUN_TEST(test_empty_fault);
198213
RUN_TEST(test_soap_env_namespace);
214+
RUN_TEST(test_fault_code_no_reason);
199215
RUN_TEST(test_uppercase_s_namespace);
200216

201217
return UNITY_END();

0 commit comments

Comments
 (0)