mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Critical
- Device.SendSoapWithHeader now parses the supplied header content
with etree and adds each top-level child as its own SOAP Header
block. gosoap.AddStringHeaderContent only accepts a single root
element; previously a multi-child ref-params header silently
produced a header-less request because the parse error was
discarded. Errors are now propagated.
- enrichSOAPErr scrubs <*:Security> blocks from response bodies
before fault extraction or excerpt slicing so a camera that
echoes the WS-Security header in a fault response cannot leak
Username/Password into operator logs.
Important
- extractSOAPFault falls back to the SOAP 1.2 Subcode (e.g.
ter:InvalidArgs) when Reason/Text is empty — consistent with
enrichSOAPErr and surfaces actionable detail on 200-OK fault
bodies reached via unmarshalNode.
- subscriptionRef captures the camera-granted TerminationTime
from CreatePullPointSubscription and Renew responses. renewLoop
schedules from it via the new nextRenewInterval helper so we
never miss a renew when the camera grants less than requested.
renew is now a sleep-loop driven by the latest granted time.
- enrichSOAPErr reads at most 64 KiB from the body (vs. 10 MiB
on success paths). Fault bodies are always small; the prior cap
let a wedged camera churn 10 MiB/s through the retry loop.
Suggestions
- extractReferenceParameters anchors to <SubscriptionReference> so
a wsa:ReplyTo / wsa:FaultTo that also carries ReferenceParameters
elsewhere in the envelope cannot leak through and break PullMessages.
- buildRefParamsHeader accepts either raw children or the full
<*:ReferenceParameters> wrapper, and propagates ancestor xmlns:*
onto each child so a vendor that declares the prefix on the
parent (not the child itself, as AXIS does) still produces valid
standalone children on the wire.
- SendSoapWithHeader documents that xmlHeaderContent must be
well-formed XML and that the caller is responsible for escaping
any externally sourced data.
- Error-message ordering is now context-first
("SOAP fault: X: <wrapped err>") per Go convention.
- Dead headerEnd slicing removed from the SendSoapWithHeader test.
Tests
- End-to-end multi-child wiring through pullMessages.
- Digest auth retry preserves the injected header.
- Malformed-XML header content fast-fails before any request.
- buildRefParamsHeader malformed / whitespace-only edge cases.
- goleak.VerifyTestMain in event/stream catches any pull/renew
goroutine that outlives its Stream.
No new behavioural surface added to onvif core; SendSoap retains
its signature, SendSoapWithHeader is the only new public method.
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package onvif
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kerberos-io/onvif/networking"
|
|
"github.com/kerberos-io/onvif/xsd/onvif"
|
|
|
|
"github.com/beevik/etree"
|
|
"github.com/kerberos-io/onvif/device"
|
|
"github.com/kerberos-io/onvif/gosoap"
|
|
)
|
|
|
|
// Xlmns XML Scheam
|
|
var Xlmns = map[string]string{
|
|
"onvif": "http://www.onvif.org/ver10/schema",
|
|
"tds": "http://www.onvif.org/ver10/device/wsdl",
|
|
"trt": "http://www.onvif.org/ver10/media/wsdl",
|
|
"tr2": "http://www.onvif.org/ver20/media/wsdl",
|
|
"tev": "http://www.onvif.org/ver10/events/wsdl",
|
|
"tptz": "http://www.onvif.org/ver20/ptz/wsdl",
|
|
"timg": "http://www.onvif.org/ver20/imaging/wsdl",
|
|
"tan": "http://www.onvif.org/ver20/analytics/wsdl",
|
|
"xmime": "http://www.w3.org/2005/05/xmlmime",
|
|
"wsnt": "http://docs.oasis-open.org/wsn/b-2",
|
|
"xop": "http://www.w3.org/2004/08/xop/include",
|
|
"wsa": "http://www.w3.org/2005/08/addressing",
|
|
"wstop": "http://docs.oasis-open.org/wsn/t-1",
|
|
"wsntw": "http://docs.oasis-open.org/wsn/bw-2",
|
|
"wsrf-rw": "http://docs.oasis-open.org/wsrf/rw-2",
|
|
"wsaw": "http://www.w3.org/2006/05/addressing/wsdl",
|
|
"tt": "http://www.onvif.org/ver10/recording/wsdl",
|
|
"wsse": "http://docs.oasis-open.org/wss/2004/01/oasis-200401",
|
|
"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd",
|
|
}
|
|
|
|
// DeviceType alias for int
|
|
type DeviceType int
|
|
|
|
// Onvif Device Tyoe
|
|
const (
|
|
NVD DeviceType = iota
|
|
NVS
|
|
NVA
|
|
NVT
|
|
|
|
ContentType = "Content-Type"
|
|
)
|
|
|
|
func (devType DeviceType) String() string {
|
|
stringRepresentation := []string{
|
|
"NetworkVideoDisplay",
|
|
"NetworkVideoStorage",
|
|
"NetworkVideoAnalytics",
|
|
"NetworkVideoTransmitter",
|
|
}
|
|
i := uint8(devType)
|
|
switch {
|
|
case i <= uint8(NVT):
|
|
return stringRepresentation[i]
|
|
default:
|
|
return strconv.Itoa(int(i))
|
|
}
|
|
}
|
|
|
|
// DeviceInfo struct contains general information about ONVIF device
|
|
type DeviceInfo struct {
|
|
Name string
|
|
Manufacturer string
|
|
Model string
|
|
FirmwareVersion string
|
|
SerialNumber string
|
|
HardwareId string
|
|
}
|
|
|
|
// Device for a new device of onvif and DeviceInfo
|
|
// struct represents an abstract ONVIF device.
|
|
// It contains methods, which helps to communicate with ONVIF device
|
|
type Device struct {
|
|
params DeviceParams
|
|
endpoints map[string]string
|
|
info DeviceInfo
|
|
digestClient *DigestClient
|
|
}
|
|
|
|
type DeviceParams struct {
|
|
Xaddr string
|
|
EndpointRefAddress string
|
|
Username string
|
|
Password string
|
|
HttpClient *http.Client
|
|
AuthMode string
|
|
}
|
|
|
|
// GetServices return available endpoints
|
|
func (dev *Device) GetServices() map[string]string {
|
|
return dev.endpoints
|
|
}
|
|
|
|
// GetServices return available endpoints
|
|
func (dev *Device) GetDeviceInfo() DeviceInfo {
|
|
return dev.info
|
|
}
|
|
|
|
// SetDeviceInfoFromScopes goes through the scopes and sets the device info fields for supported categories (currently name and hardware).
|
|
// See 7.3.2.2 Scopes in the ONVIF Core Specification (https://www.onvif.org/specs/core/ONVIF-Core-Specification.pdf).
|
|
func (dev *Device) SetDeviceInfoFromScopes(scopes []string) {
|
|
newInfo := dev.info
|
|
supportedScopes := []struct {
|
|
category string
|
|
setField func(s string)
|
|
}{
|
|
{category: "name", setField: func(s string) { newInfo.Name = s }},
|
|
{category: "hardware", setField: func(s string) { newInfo.Model = s }},
|
|
}
|
|
|
|
for _, s := range scopes {
|
|
for _, supp := range supportedScopes {
|
|
fullScope := fmt.Sprintf("onvif://www.onvif.org/%s/", supp.category)
|
|
scopeValue, matchesScope := strings.CutPrefix(s, fullScope)
|
|
if matchesScope {
|
|
unescaped, err := url.QueryUnescape(scopeValue)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
supp.setField(unescaped)
|
|
}
|
|
}
|
|
}
|
|
dev.info = newInfo
|
|
}
|
|
|
|
func readResponse(resp *http.Response) string {
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (dev *Device) getSupportedServices(resp *http.Response) {
|
|
doc := etree.NewDocument()
|
|
|
|
data, _ := ioutil.ReadAll(resp.Body)
|
|
|
|
if err := doc.ReadFromBytes(data); err != nil {
|
|
//log.Println(err.Error())
|
|
return
|
|
}
|
|
services := doc.FindElements("./Envelope/Body/GetCapabilitiesResponse/Capabilities/*/XAddr")
|
|
for _, j := range services {
|
|
dev.addEndpoint(j.Parent().Tag, j.Text())
|
|
}
|
|
|
|
extensionServices := doc.FindElements("./Envelope/Body/GetCapabilitiesResponse/Capabilities/Extension/*/XAddr")
|
|
for _, j := range extensionServices {
|
|
dev.addEndpoint(j.Parent().Tag, j.Text())
|
|
}
|
|
}
|
|
|
|
// NewDevice function construct a ONVIF Device entity
|
|
func NewDevice(params DeviceParams) (*Device, error) {
|
|
dev := new(Device)
|
|
dev.params = params
|
|
dev.endpoints = make(map[string]string)
|
|
dev.addEndpoint("Device", "http://"+dev.params.Xaddr+"/onvif/device_service")
|
|
|
|
if dev.params.HttpClient == nil {
|
|
dev.params.HttpClient = new(http.Client)
|
|
}
|
|
dev.digestClient = NewDigestClient(dev.params.HttpClient, dev.params.Username, dev.params.Password)
|
|
|
|
getCapabilities := device.GetCapabilities{Category: []onvif.CapabilityCategory{"All"}}
|
|
|
|
resp, err := dev.CallMethod(getCapabilities)
|
|
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
return nil, errors.New("camera is not available at " + dev.params.Xaddr + " or it does not support ONVIF services")
|
|
}
|
|
|
|
dev.getSupportedServices(resp)
|
|
return dev, nil
|
|
}
|
|
|
|
func (dev *Device) addEndpoint(Key, Value string) {
|
|
//use lowCaseKey
|
|
//make key having ability to handle Mixed Case for Different vendor devcie (e.g. Events EVENTS, events)
|
|
lowCaseKey := strings.ToLower(Key)
|
|
|
|
// Replace host with host from device params.
|
|
if u, err := url.Parse(Value); err == nil {
|
|
u.Host = dev.params.Xaddr
|
|
Value = u.String()
|
|
}
|
|
|
|
dev.endpoints[lowCaseKey] = Value
|
|
|
|
if lowCaseKey == strings.ToLower(MediaWebService) {
|
|
// Media2 uses the same endpoint but different XML name space
|
|
dev.endpoints[strings.ToLower(Media2WebService)] = Value
|
|
}
|
|
}
|
|
|
|
// GetEndpoint returns specific ONVIF service endpoint address
|
|
func (dev *Device) GetEndpoint(name string) string {
|
|
return dev.endpoints[name]
|
|
}
|
|
|
|
func (dev *Device) buildMethodSOAP(msg string) (gosoap.SoapMessage, error) {
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromString(msg); err != nil {
|
|
//log.Println("Got error")
|
|
|
|
return "", err
|
|
}
|
|
element := doc.Root()
|
|
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddBodyContent(element)
|
|
|
|
return soap, nil
|
|
}
|
|
|
|
// getEndpoint functions get the target service endpoint in a better way
|
|
func (dev *Device) getEndpoint(endpoint string) (string, error) {
|
|
|
|
// common condition, endpointMark in map we use this.
|
|
if endpointURL, bFound := dev.endpoints[endpoint]; bFound {
|
|
return endpointURL, nil
|
|
}
|
|
|
|
//but ,if we have endpoint like event、analytic
|
|
//and sametime the Targetkey like : events、analytics
|
|
//we use fuzzy way to find the best match url
|
|
var endpointURL string
|
|
for targetKey := range dev.endpoints {
|
|
if strings.Contains(targetKey, endpoint) {
|
|
endpointURL = dev.endpoints[targetKey]
|
|
return endpointURL, nil
|
|
}
|
|
}
|
|
return endpointURL, errors.New("target endpoint service not found")
|
|
}
|
|
|
|
// CallMethod functions call an method, defined <method> struct.
|
|
// You should use Authenticate method to call authorized requests.
|
|
func (dev Device) CallMethod(method interface{}) (*http.Response, error) {
|
|
pkgPath := strings.Split(reflect.TypeOf(method).PkgPath(), "/")
|
|
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
|
|
|
|
endpoint, err := dev.getEndpoint(pkg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dev.callMethodDo(endpoint, method)
|
|
}
|
|
|
|
// CallMethod functions call an method, defined <method> struct with authentication data
|
|
func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Response, error) {
|
|
output, err := xml.MarshalIndent(method, " ", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
soap, err := dev.buildMethodSOAP(string(output))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
soap.AddRootNamespaces(Xlmns)
|
|
soap.AddAction()
|
|
|
|
//Auth Handling
|
|
if dev.params.Username != "" && dev.params.Password != "" {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
|
if err != nil {
|
|
// Close server response body to reuse the connection
|
|
if servResp != nil {
|
|
servResp.Body.Close()
|
|
}
|
|
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
return servResp, err
|
|
}
|
|
|
|
func (dev *Device) GetDeviceParams() DeviceParams {
|
|
return dev.params
|
|
}
|
|
|
|
func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string, error) {
|
|
pkgPath := strings.Split(reflect.TypeOf(requestStruct).Elem().PkgPath(), "/")
|
|
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
|
|
|
|
endpoint, err := dev.getEndpoint(pkg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return endpoint, err
|
|
}
|
|
|
|
/*func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.Response, err error) {
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddStringBodyContent(xmlRequestBody)
|
|
soap.AddRootNamespaces(Xlmns)
|
|
if dev.params.AuthMode == UsernameTokenAuth || dev.params.AuthMode == Both {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
|
|
resp, err = dev.digestClient.Do(http.MethodPost, endpoint, soap.String())
|
|
} else {
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodPost, endpoint, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
}
|
|
return resp, err
|
|
}*/
|
|
|
|
// SendSoap POSTs the given body wrapped in a SOAP envelope.
|
|
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
|
|
return dev.SendSoapWithHeader(endpoint, xmlRequestBody, "")
|
|
}
|
|
|
|
// SendSoapWithHeader is SendSoap plus arbitrary inner-Header XML —
|
|
// needed to echo WS-Addressing ReferenceParameters (with
|
|
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
|
|
// identify pull-point subscriptions through them rather than the URL.
|
|
//
|
|
// xmlHeaderContent must be well-formed XML representing zero or more
|
|
// SOAP Header child elements (sibling top-level elements are
|
|
// supported; the spec lets each reference parameter be its own header
|
|
// block). The caller is responsible for escaping any externally
|
|
// sourced data inside it. Malformed XML returns an error before any
|
|
// request is made.
|
|
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddStringBodyContent(xmlRequestBody)
|
|
soap.AddRootNamespaces(Xlmns)
|
|
soap.AddAction()
|
|
if err := addHeaderChildren(&soap, xmlHeaderContent); err != nil {
|
|
return nil, err
|
|
}
|
|
if dev.params.Username != "" && dev.params.Password != "" {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
|
if err != nil {
|
|
if servResp != nil {
|
|
servResp.Body.Close()
|
|
}
|
|
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
|
}
|
|
return servResp, err
|
|
}
|
|
|
|
// addHeaderChildren wraps the fragment so etree can parse multi-root
|
|
// XML, then adds each top-level child as its own SOAP Header block.
|
|
// gosoap.AddStringHeaderContent only accepts a single root element.
|
|
func addHeaderChildren(soap *gosoap.SoapMessage, xmlHeaderContent string) error {
|
|
if xmlHeaderContent == "" {
|
|
return nil
|
|
}
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromString("<wrap>" + xmlHeaderContent + "</wrap>"); err != nil {
|
|
return fmt.Errorf("parse header content: %w", err)
|
|
}
|
|
wrap := doc.SelectElement("wrap")
|
|
if wrap == nil {
|
|
return errors.New("parse header content: missing wrap root")
|
|
}
|
|
for _, child := range wrap.ChildElements() {
|
|
d := etree.NewDocument()
|
|
d.SetRoot(child.Copy())
|
|
s, err := d.WriteToString()
|
|
if err != nil {
|
|
return fmt.Errorf("serialise header child: %w", err)
|
|
}
|
|
if err := soap.AddStringHeaderContent(s); err != nil {
|
|
return fmt.Errorf("add header child: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createHttpRequest(httpMethod string, endpoint string, soap string) (req *http.Request, err error) {
|
|
req, err = http.NewRequest(httpMethod, endpoint, bytes.NewBufferString(soap))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set(ContentType, "application/soap+xml; charset=utf-8")
|
|
return req, nil
|
|
}
|
|
|
|
func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []byte) (interface{}, error) {
|
|
function, err := FunctionByServiceAndFunctionName(serviceName, functionName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request, err := createRequest(function, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to create '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
|
|
endpoint, err := dev.GetEndpointByRequestStruct(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestBody, err := xml.Marshal(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
xmlRequestBody := string(requestBody)
|
|
|
|
servResp, err := dev.SendSoap(endpoint, xmlRequestBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
defer servResp.Body.Close()
|
|
|
|
rsp, err := ioutil.ReadAll(servResp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseEnvelope, err := createResponse(function, rsp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to create '%s' response for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
|
|
if servResp.StatusCode == http.StatusUnauthorized {
|
|
return nil, fmt.Errorf("fail to verify the authentication for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
} else if servResp.StatusCode == http.StatusBadRequest {
|
|
return nil, fmt.Errorf("invalid request for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
} else if servResp.StatusCode > http.StatusNoContent {
|
|
return nil, fmt.Errorf("fail to execute the request for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
}
|
|
return responseEnvelope.Body.Content, nil
|
|
}
|
|
|
|
func createRequest(function Function, data []byte) (interface{}, error) {
|
|
request := function.Request()
|
|
if len(data) > 0 {
|
|
err := json.Unmarshal(data, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func createResponse(function Function, data []byte) (*gosoap.SOAPEnvelope, error) {
|
|
response := function.Response()
|
|
responseEnvelope := gosoap.NewSOAPEnvelope(response)
|
|
err := xml.Unmarshal(data, responseEnvelope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseEnvelope, nil
|
|
}
|
|
|
|
// SendGetSnapshotRequest sends the Get request to retrieve the snapshot from the Onvif camera
|
|
// The parameter url is come from the "GetSnapshotURI" command.
|
|
func (dev *Device) SendGetSnapshotRequest(url string) (resp *http.Response, err error) {
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddRootNamespaces(Xlmns)
|
|
if dev.params.AuthMode == UsernameTokenAuth {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodGet, url, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Basic auth might work for some camera
|
|
req.SetBasicAuth(dev.params.Username, dev.params.Password)
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
|
|
} else if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
resp, err = dev.digestClient.Do(http.MethodGet, url, soap.String())
|
|
|
|
} else {
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodGet, url, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
}
|
|
return resp, err
|
|
}
|