mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Critical
- Renew busy-loop on persistent failure: after a failed
renewPullPoint, the loop re-read the (now in-past)
GrantedTermination and nextRenewInterval floored to 1s, hammering
the camera at 1 Hz until reconnect. nextRenewIntervalAfterError
decouples the failure path from the stale grant and backs off
at opts.RetryBackoff. renewLoop also routes through s.now() so
test clocks can drive it deterministically.
- Lost-update race on GrantedTermination: a renew result for an
old subscription could overwrite the grant on a new one if
attemptRecreate swapped pullPoint mid-flight. A generation
counter on Stream tracks subscription rotation; the renew loop
captures the generation before the SOAP call and discards the
result if the subscription was rotated.
- Credential leak when <Security> straddled the 64 KiB error cap:
the non-greedy regex required a closing tag and missed the
truncated case. wsseSecurityRE now matches close-tag-or-EOF.
Belt-and-braces wssePasswordRE redacts <Password> elements
outside any Security wrapper.
- addHeaderChildren accepted well-formed-but-element-free input
and produced a header-less request. Now errors out.
Important
- Wrapper detection in buildRefParamsHeader was HasSuffix-based and
misfired on children named *ReferenceParameters. Replaced with the
unambiguous wrapper-only contract: input must be the full
<*:ReferenceParameters> element returned by extractReferenceParameters.
- Renamed SoapOption → SendSoapOption and WithHeader → WithSOAPHeader
to disambiguate at call sites.
- Renamed WithHeader's `xml` parameter to headerContent to avoid
shadowing the encoding/xml package name.
- Documented terminationTimeRE's "first match only" semantics so
nobody re-uses it from PullMessages context where multiple
TerminationTime elements appear.
- goleak is now a direct require (go mod tidy).
Performance
- Added gosoap.AddStringHeaderContents (plural) for multi-root
header content. AddStringHeaderContent remains as-is for
backwards compatibility with external consumers. Device.go's
addHeaderChildren workaround is gone — one etree parse per
SendSoapWithOptions call instead of two.
Migration
- Device.go's own CallOnvifFunction and the three examples now
call SendSoapWithOptions, modelling the canonical path.
Docs / tests
- Trust-boundary warning on SendSoapWithHeader/Options godoc.
- Comments on createPullPointResp/Alt explain why TerminationTime
is intentionally omitted (renew timing fixtures).
- New tests: digest retry strips WS-Security, duplicate
WithSOAPHeader is last-wins, malformed TerminationTime yields
zero, margin==base falls to base/2, empty SubscriptionReference
yields empty ref params, Security straddling cap is redacted,
bare Password redacted, wrapper-only contract is enforced.
364 lines
12 KiB
Go
364 lines
12 KiB
Go
package stream
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/beevik/etree"
|
|
"github.com/kerberos-io/onvif/event"
|
|
"github.com/kerberos-io/onvif/xsd"
|
|
)
|
|
|
|
// maxResponseBytes caps SOAP response buffering on success paths.
|
|
// ONVIF PullMessages bodies are normally <100KB even with dense
|
|
// analytics payloads; 10 MiB is well above legitimate traffic while
|
|
// keeping a hostile or buggy camera from OOMing the process.
|
|
const maxResponseBytes = 10 << 20
|
|
|
|
// maxErrorBodyBytes caps the body read by enrichSOAPErr. The pull
|
|
// retry loop runs every RetryBackoff (~1s) so an unbounded read on
|
|
// the error path would churn 10 MiB/s per wedged camera. Fault bodies
|
|
// are always small.
|
|
const maxErrorBodyBytes = 64 << 10
|
|
|
|
func createPullPoint(c caller, opts Options) (subscriptionRef, error) {
|
|
term := xsd.String(durationToXSD(opts.InitialTermination))
|
|
req := event.CreatePullPointSubscription{InitialTerminationTime: &term}
|
|
if opts.RawTopicFilter != "" {
|
|
req.Filter = &event.FilterType{
|
|
TopicExpression: &event.TopicExpressionType{
|
|
Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"),
|
|
TopicKinds: xsd.String(opts.RawTopicFilter),
|
|
},
|
|
}
|
|
}
|
|
resp, err := c.CallMethod(req)
|
|
if err != nil {
|
|
return subscriptionRef{}, enrichSOAPErr(resp, err)
|
|
}
|
|
body, err := readClose(resp)
|
|
if err != nil {
|
|
return subscriptionRef{}, err
|
|
}
|
|
var decoded event.CreatePullPointSubscriptionResponse
|
|
if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil {
|
|
return subscriptionRef{}, err
|
|
}
|
|
addr := string(decoded.SubscriptionReference.Address)
|
|
if addr == "" {
|
|
return subscriptionRef{}, errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address")
|
|
}
|
|
return subscriptionRef{
|
|
Address: addr,
|
|
RefParamsXML: extractReferenceParameters(body),
|
|
GrantedTermination: extractTerminationTime(body),
|
|
}, nil
|
|
}
|
|
|
|
// extractTerminationTime parses the absolute UTC instant the camera
|
|
// granted as the subscription expiry. Returns zero on absence or parse
|
|
// failure — callers fall back to opts.InitialTermination.
|
|
func extractTerminationTime(body string) time.Time {
|
|
m := terminationTimeRE.FindStringSubmatch(body)
|
|
if len(m) < 2 {
|
|
return time.Time{}
|
|
}
|
|
t, err := time.Parse(time.RFC3339, strings.TrimSpace(m[1]))
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// terminationTimeRE matches the first <*:TerminationTime> in the body.
|
|
// Only safe on responses that contain exactly one — currently
|
|
// CreatePullPointSubscriptionResponse and RenewResponse via
|
|
// extractTerminationTime. PullMessagesResponse also has a
|
|
// TerminationTime element; do not call extractTerminationTime on pull
|
|
// bodies.
|
|
var terminationTimeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?TerminationTime\b[^>]*>(.*?)</(?:[^:>\s]+:)?TerminationTime>`)
|
|
|
|
// pullMessages returns an empty slice (no error) when the camera had
|
|
// nothing within PullTimeout.
|
|
func pullMessages(c caller, ref subscriptionRef, opts Options) ([]event.NotificationMessage, error) {
|
|
req := event.PullMessages{
|
|
Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)),
|
|
MessageLimit: xsd.Int(opts.MessageLimit),
|
|
}
|
|
body, err := xml.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal PullMessages: %w", err)
|
|
}
|
|
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build ref params header: %w", err)
|
|
}
|
|
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
|
|
if err != nil {
|
|
return nil, enrichSOAPErr(resp, err)
|
|
}
|
|
respBody, err := readClose(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var decoded event.PullMessagesResponse
|
|
if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
return decoded.NotificationMessage, nil
|
|
}
|
|
|
|
// unsubscribePullPoint is best-effort. Empty Address is a no-op
|
|
// (construction failed before installing a subscription).
|
|
func unsubscribePullPoint(c caller, ref subscriptionRef) error {
|
|
if ref.Address == "" {
|
|
return nil
|
|
}
|
|
body, err := xml.Marshal(event.Unsubscribe{})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal Unsubscribe: %w", err)
|
|
}
|
|
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
|
|
if err != nil {
|
|
return fmt.Errorf("build ref params header: %w", err)
|
|
}
|
|
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
|
|
if err != nil {
|
|
return enrichSOAPErr(resp, err)
|
|
}
|
|
_, err = readClose(resp)
|
|
return err
|
|
}
|
|
|
|
func readClose(resp *http.Response) (string, error) {
|
|
if resp == nil || resp.Body == nil {
|
|
return "", errors.New("nil HTTP response")
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
|
if err != nil {
|
|
return "", fmt.Errorf("read response body: %w", err)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// unmarshalNode finds the first XML start element with the given local
|
|
// name and decodes it into out. ONVIF SOAP responses are wrapped in an
|
|
// envelope with many namespace prefixes; keying on local name only
|
|
// sidesteps namespace matching.
|
|
//
|
|
// When the camera returns a SOAP Fault, the fault reason is returned
|
|
// as the error so callers can distinguish auth / expired-subscription
|
|
// from "unparseable response".
|
|
func unmarshalNode(body, localName string, out any) error {
|
|
if reason := extractSOAPFault(body); reason != "" {
|
|
return fmt.Errorf("ONVIF SOAP fault: %s", reason)
|
|
}
|
|
dec := xml.NewDecoder(bytes.NewBufferString(body))
|
|
for {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return fmt.Errorf("ONVIF response missing %s element", localName)
|
|
}
|
|
return fmt.Errorf("scan ONVIF response: %w", err)
|
|
}
|
|
start, ok := tok.(xml.StartElement)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if start.Name.Local != localName {
|
|
continue
|
|
}
|
|
if err := dec.DecodeElement(out, &start); err != nil {
|
|
return fmt.Errorf("decode %s: %w", localName, err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var (
|
|
// SOAP 1.1: <faultstring>reason</faultstring>
|
|
soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)</(?:[^:>\s]+:)?faultstring>`)
|
|
// SOAP 1.2: <Fault>...<Reason><Text>reason</Text></Reason>...</Fault>
|
|
soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)</(?:[^:>\s]+:)?Text>`)
|
|
// SOAP 1.2 Subcode: <Code>...<Subcode><Value>ter:InvalidArgs</Value></Subcode>...
|
|
soap12SubcodeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Subcode\b[^>]*>.*?<(?:[^:>\s]+:)?Value[^>]*>(.*?)</(?:[^:>\s]+:)?Value>`)
|
|
|
|
// WS-Security blocks may carry our Username/Password if the camera
|
|
// echoes the request in a fault; scrub before logging. The
|
|
// alternation handles the truncated case where the read cap fell
|
|
// between <Security> and </Security>: in that case nothing past
|
|
// the opening tag is safe to retain. wssePasswordRE is the
|
|
// belt-and-braces fallback for non-conformant cameras emitting
|
|
// Password / UsernameToken outside a Security wrapper.
|
|
wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Security>|.*)`)
|
|
wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>.*?</(?:[^:>\s]+:)?Password>`)
|
|
)
|
|
|
|
// extractSOAPFault returns the reason text from a SOAP fault, falling
|
|
// back to the Subcode value when Reason/Text is empty (AXIS pattern).
|
|
// Returns "" when the body is not a fault.
|
|
func extractSOAPFault(body string) string {
|
|
if !strings.Contains(body, "Fault") {
|
|
return ""
|
|
}
|
|
if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 {
|
|
if r := strings.TrimSpace(m[1]); r != "" {
|
|
return r
|
|
}
|
|
}
|
|
if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 {
|
|
if r := strings.TrimSpace(m[1]); r != "" {
|
|
return r
|
|
}
|
|
}
|
|
return extractSOAPSubcode(body)
|
|
}
|
|
|
|
// Anchored to SubscriptionReference because other WS-Addressing
|
|
// endpoint references in the same envelope (wsa:ReplyTo, wsa:FaultTo,
|
|
// wsa:From) may also carry ReferenceParameters that are not ours.
|
|
var (
|
|
subscriptionRefRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?SubscriptionReference\b[^>]*>(.*?)</(?:[^:>\s]+:)?SubscriptionReference>`)
|
|
refParamsRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?ReferenceParameters\b[^>]*>(.*?)</(?:[^:>\s]+:)?ReferenceParameters>`)
|
|
)
|
|
|
|
// buildRefParamsHeader produces the SOAP <Header> inner XML from the
|
|
// full <*:ReferenceParameters> element returned by
|
|
// extractReferenceParameters. Each child element is re-emitted with
|
|
// wsa:IsReferenceParameter="true" added and any xmlns:* declared on
|
|
// the parent inherited onto it (so the standalone child stays valid).
|
|
// Empty input yields empty output.
|
|
func buildRefParamsHeader(refParamsXML string) (string, error) {
|
|
if strings.TrimSpace(refParamsXML) == "" {
|
|
return "", nil
|
|
}
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromString(refParamsXML); err != nil {
|
|
return "", fmt.Errorf("parse ref params: %w", err)
|
|
}
|
|
wrapper := doc.Root()
|
|
if wrapper == nil || wrapper.Tag != "ReferenceParameters" {
|
|
return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", rootTag(wrapper))
|
|
}
|
|
var out strings.Builder
|
|
for _, child := range wrapper.ChildElements() {
|
|
c := child.Copy()
|
|
inheritXmlns(c, wrapper)
|
|
c.CreateAttr("wsa:IsReferenceParameter", "true")
|
|
d := etree.NewDocument()
|
|
d.SetRoot(c)
|
|
s, err := d.WriteToString()
|
|
if err != nil {
|
|
return "", fmt.Errorf("serialise ref param child: %w", err)
|
|
}
|
|
out.WriteString(strings.TrimRight(s, "\n"))
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func rootTag(e *etree.Element) string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.Tag
|
|
}
|
|
|
|
// inheritXmlns copies xmlns / xmlns:* declarations from src onto dst
|
|
// when dst doesn't already declare them, so a child whose namespace
|
|
// prefix was declared on an ancestor stays valid in isolation.
|
|
func inheritXmlns(dst, src *etree.Element) {
|
|
for _, attr := range src.Attr {
|
|
isDefault := attr.Space == "" && attr.Key == "xmlns"
|
|
isPrefixed := attr.Space == "xmlns"
|
|
if !isDefault && !isPrefixed {
|
|
continue
|
|
}
|
|
key := attr.Key
|
|
if isPrefixed {
|
|
key = "xmlns:" + attr.Key
|
|
}
|
|
if dst.SelectAttr(key) != nil {
|
|
continue
|
|
}
|
|
dst.CreateAttr(key, attr.Value)
|
|
}
|
|
}
|
|
|
|
// extractReferenceParameters returns the verbatim inner XML so callers
|
|
// can echo it (with wsa:IsReferenceParameter="true") into the SOAP
|
|
// Header of subscription-scoped requests per WS-Addressing 1.0 §3.1.
|
|
// Without that echo, AXIS rejects PullMessages with ter:InvalidArgs.
|
|
func extractReferenceParameters(body string) string {
|
|
sub := subscriptionRefRE.FindStringSubmatch(body)
|
|
if len(sub) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(refParamsRE.FindString(sub[1]))
|
|
}
|
|
|
|
// extractSOAPSubcode is the fallback when Reason/Text is empty — AXIS
|
|
// routinely sends an empty <Text/> alongside a populated Subcode
|
|
// (e.g. "ter:InvalidArgs"), and that subcode is the only actionable
|
|
// signal the operator gets.
|
|
func extractSOAPSubcode(body string) string {
|
|
m := soap12SubcodeRE.FindStringSubmatch(body)
|
|
if len(m) > 1 {
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// maxErrExcerpt caps the body excerpt appended to an enriched error so
|
|
// a wedged camera streaming a multi-megabyte HTML error page can not
|
|
// flood logs with every retry.
|
|
const maxErrExcerpt = 512
|
|
|
|
// enrichSOAPErr appends the camera's actual complaint (SOAP Fault
|
|
// reason, then Subcode, then raw body excerpt) to a transport error so
|
|
// operators see *why* the camera said 400 instead of just "400 Bad
|
|
// Request". The original err is preserved via %w for errors.Is/As.
|
|
func enrichSOAPErr(resp *http.Response, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if resp == nil || resp.Body == nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
|
if readErr != nil || len(b) == 0 {
|
|
return err
|
|
}
|
|
body := wsseSecurityRE.ReplaceAllString(string(b), "<Security>[REDACTED]</Security>")
|
|
body = wssePasswordRE.ReplaceAllString(body, "<Password>[REDACTED]</Password>")
|
|
if reason := extractSOAPFault(body); reason != "" {
|
|
return fmt.Errorf("SOAP fault: %s: %w", reason, err)
|
|
}
|
|
excerpt := strings.TrimSpace(body)
|
|
if len(excerpt) > maxErrExcerpt {
|
|
excerpt = excerpt[:maxErrExcerpt] + "...(truncated)"
|
|
}
|
|
return fmt.Errorf("response body: %s: %w", excerpt, err)
|
|
}
|
|
|
|
// durationToXSD formats a duration as xsd:duration PTnS. Second
|
|
// precision is sufficient — ONVIF cameras do not honour sub-second
|
|
// pull timeouts.
|
|
func durationToXSD(d time.Duration) string {
|
|
secs := int(d.Round(time.Second).Seconds())
|
|
if secs <= 0 {
|
|
secs = 1
|
|
}
|
|
return "PT" + strconv.Itoa(secs) + "S"
|
|
}
|