diff --git a/Device.go b/Device.go index 3fdb258..e00cf59 100644 --- a/Device.go +++ b/Device.go @@ -94,11 +94,12 @@ type Device struct { } type DeviceParams struct { - Xaddr string - Username string - Password string - HttpClient *http.Client - AuthMode string + Xaddr string + EndpointRefAddress string + Username string + Password string + HttpClient *http.Client + AuthMode string } // GetServices return available endpoints @@ -106,30 +107,21 @@ func (dev *Device) GetServices() map[string]string { return dev.endpoints } -// GetDeviceInfo return available endpoints +// GetServices return available endpoints func (dev *Device) GetDeviceInfo() DeviceInfo { return dev.info } -// GetDeviceParams return available endpoints -func (dev *Device) GetDeviceParams() DeviceParams { - return dev.params -} - -func readResponse(resp *http.Response) string { - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - panic(err) - } - return string(b) -} - -// GetAvailableDevicesAtSpecificEthernetInterface ... -func GetAvailableDevicesAtSpecificEthernetInterface(interfaceName string) ([]Device, error) { - // Call a ws-discovery Probe Message to Discover NVT type Devices - devices, err := wsdiscovery.SendProbe(interfaceName, nil, []string{"dn:" + NVT.String()}, map[string]string{"dn": "http://www.onvif.org/ver10/network/wsdl"}) - if err != nil { - return nil, err +// 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 { @@ -240,7 +232,7 @@ func (dev *Device) buildMethodSOAP(msg string) (gosoap.SoapMessage, error) { } // getEndpoint functions get the target service endpoint in a better way -func (dev Device) getEndpoint(endpoint string) (string, error) { +func (dev *Device) getEndpoint(endpoint string) (string, error) { // common condition, endpointMark in map we use this. if endpointURL, bFound := dev.endpoints[endpoint]; bFound { @@ -291,20 +283,6 @@ func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Respo return dev.sendSOAP(endpoint, soap) } -// Authentication modes selectable through DeviceParams.AuthMode. -const ( - // NoAuth disables authentication entirely. - NoAuth = "none" - // DigestAuth uses HTTP digest only, without a WS-Security header. - DigestAuth = "digest" - // UsernameTokenAuth uses a WS-Security UsernameToken header only and never - // falls back to HTTP digest. - UsernameTokenAuth = "usernametoken" - // Both adds a WS-Security header (when credentials exist) and additionally - // answers an HTTP digest challenge if the device requests one. - Both = "both" -) - // sendSOAP dispatches an assembled SOAP message to the endpoint using the // authentication mechanism selected through DeviceParams.AuthMode. // @@ -316,8 +294,9 @@ const ( // - DigestAuth ("digest"): HTTP digest only; no WS-Security header. // - Both ("both") / unset (""): WS-Security credentials are added (when // available) and HTTP digest is attempted only if the device answers with -// an authentication challenge (HTTP 401 Unauthorized). The WS-Security -// header is never stripped. +// an authentication challenge (HTTP 401 Unauthorized). On that digest +// retry the WS-Security header is dropped so the credentials are not sent +// twice. func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Response, error) { hasCredentials := dev.params.Username != "" || dev.params.Password != "" @@ -341,3 +320,222 @@ func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Resp return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password) } } + +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.SendSoapWithOptions(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 one or more +// SOAP Header child elements (siblings are supported; the spec lets +// each reference parameter be its own header block). Malformed or +// element-free content errors before any request is made. +// +// SECURITY: do not pass content sourced from untrusted clients. The +// API assumes the caller is authoritative for the envelope. Header +// content forwards verbatim — among others, overrides +// auth, overrides intent, // +// redirect responses, enables replay- +// token forgery, and bypasses freshness checks. +func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) { + return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent)) +} + +// SendSoapOption tweaks a single SendSoapWithOptions call. New options +// (per-call timeout, context, custom envelope namespaces, ...) should +// be added as WithX constructors here rather than as new method +// variants on Device. +type SendSoapOption func(*soapConfig) + +type soapConfig struct { + headerContent string +} + +// WithSOAPHeader adds inner-Header XML to the envelope. See +// SendSoapWithHeader for the content contract. +func WithSOAPHeader(headerContent string) SendSoapOption { + return func(c *soapConfig) { c.headerContent = headerContent } +} + +// SendSoapWithOptions is the workhorse behind SendSoap and +// SendSoapWithHeader; call it directly when you need to combine +// options or pass options not surfaced by the convenience wrappers. +func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SendSoapOption) (*http.Response, error) { + var cfg soapConfig + for _, o := range opts { + o(&cfg) + } + soap := gosoap.NewEmptySOAP() + soap.AddStringBodyContent(xmlRequestBody) + soap.AddRootNamespaces(Xlmns) + soap.AddAction() + if cfg.headerContent != "" { + if err := soap.AddStringHeaderContents(cfg.headerContent); err != nil { + return nil, fmt.Errorf("add header content: %w", 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 +} + +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.SendSoapWithOptions(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 +} diff --git a/event/types.go b/event/types.go index 08aae92..22056ce 100644 --- a/event/types.go +++ b/event/types.go @@ -52,8 +52,8 @@ type EndpointReferenceType struct { //wsa http://www.w3.org/2005/08/addressing/w // FilterType struct type FilterType struct { - TopicExpression TopicExpressionType `xml:"wsnt:TopicExpression"` - MessageContent *QueryExpressionType `xml:"wsnt:MessageContent"` + TopicExpression *TopicExpressionType `xml:"wsnt:TopicExpression,omitempty"` + MessageContent *QueryExpressionType `xml:"wsnt:MessageContent,omitempty"` } // EndpointReference alias diff --git a/networking/networking.go b/networking/networking.go index 4638c0f..d86cc61 100644 --- a/networking/networking.go +++ b/networking/networking.go @@ -12,7 +12,6 @@ import ( "strings" "github.com/beevik/etree" - "github.com/icholy/digest" "github.com/juju/errors" ) @@ -33,83 +32,24 @@ func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response return resp, nil } -func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) { - doc := etree.NewDocument() - if err := doc.ReadFromString(message); err != nil { - return nil, err - } - - e := doc.FindElement("./Envelope/Header/Security") - if e != nil { - bodyTag := doc.Root().SelectElement("Header") - bodyTag.RemoveChild(e) - data, err := doc.WriteToString() - if err != nil { - return nil, err - } - message = data - } - - req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(message)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") - resp, err := httpClient.Do(req) - if err != nil { - return resp, errors.Annotate(err, "Post with digest") - } - - if resp.StatusCode != http.StatusUnauthorized { - return resp, err - } - - wwwAuth := resp.Header.Get("WWW-Authenticate") - chal, err := digest.ParseChallenge(wwwAuth) - if err != nil { - return resp, fmt.Errorf("fail to parse challenge: %w", err) - } - - cred, err := digest.Digest(chal, digest.Options{ - Method: "POST", - URI: req.URL.RequestURI(), - Username: username, - Password: password, - }) - - if err != nil { - return resp, fmt.Errorf("fail to build digest: %w", err) - } - - // Readout body to close the connection - resp.Body.Close() - - req.Header.Add("Authorization", cred.String()) - req.Body = io.NopCloser((bytes.NewBufferString(message))) - resp, err = httpClient.Do(req) - if err != nil { - return nil, errors.Annotate(err, "Post with digest") - } - if resp.StatusCode >= 400 && resp.StatusCode < 600 { - return resp, errors.Errorf("Post with digest error: %d: %s", resp.StatusCode, resp.Status) - } - - return resp, nil -} - // SendSoapWithDigest sends a soap message and, when the device answers with an // HTTP 401 digest challenge, transparently retries the request with the // computed HTTP digest Authorization header. // -// The initial request is sent as-is, so any WS-Security header already present -// in the message is preserved. HTTP digest is only attempted when the device -// explicitly requests it, which keeps WS-Security-only cameras working while -// also supporting cameras that require HTTP digest for authenticated calls. +// Any wsse:Security header present in the message is stripped before sending: +// when a device requires HTTP digest the credentials travel in the +// Authorization header, so keeping the WS-Security UsernameToken in the body +// would put the credentials on the wire twice. Other SOAP header blocks (for +// example WS-Addressing reference parameters) are preserved so vendor-specific +// routing keeps working across the retry. func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) { if httpClient == nil { httpClient = new(http.Client) } + // Avoid sending the credentials twice (WS-Security + digest) on the retry. + message = stripWSSecurityHeader(message) + resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message)) if err != nil { return resp, errors.Annotate(err, "Post") @@ -149,6 +89,30 @@ func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, pa return resp, nil } +// stripWSSecurityHeader removes the wsse:Security header block from a SOAP +// envelope, leaving all other header blocks intact. The message is returned +// unchanged if it cannot be parsed as XML or has no such header. +func stripWSSecurityHeader(message string) string { + doc := etree.NewDocument() + if err := doc.ReadFromString(message); err != nil { + return message + } + security := doc.FindElement("./Envelope/Header/Security") + if security == nil { + return message + } + header := doc.Root().SelectElement("Header") + if header == nil { + return message + } + header.RemoveChild(security) + data, err := doc.WriteToString() + if err != nil { + return message + } + return data +} + var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`) // parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header. diff --git a/sdk/event/PullMessages_auto.go b/sdk/event/PullMessages_auto.go index ddfaaa0..5095ea3 100644 --- a/sdk/event/PullMessages_auto.go +++ b/sdk/event/PullMessages_auto.go @@ -6,10 +6,11 @@ package event import ( "context" + "github.com/juju/errors" - "github.com/use-go/onvif" - "github.com/use-go/onvif/sdk" - "github.com/use-go/onvif/event" + "github.com/kerberos-io/onvif" + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/sdk" ) // Call_PullMessages forwards the call to dev.CallMethod() then parses the payload of the reply as a PullMessagesResponse. diff --git a/sdk/event/event.go b/sdk/event/event.go index f033175..c2ebca3 100644 --- a/sdk/event/event.go +++ b/sdk/event/event.go @@ -1,8 +1,8 @@ package event -//go:generate go run github.com/use-go/onvif/sdk/codegen event event CreatePullPointSubscription -//go:generate go run github.com/use-go/onvif/sdk/codegen event event GetEventProperties -//go:generate go run github.com/use-go/onvif/sdk/codegen event event GetServiceCapabilities -//go:generate go run github.com/use-go/onvif/sdk/codegen event event Subscribe -//go:generate go run github.com/use-go/onvif/sdk/codegen event event Unsubscribe -//go:generate go run github.com/use-go/onvif/sdk/codegen event event PullMessages +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event CreatePullPointSubscription +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetEventProperties +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetServiceCapabilities +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Subscribe +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Unsubscribe +//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event PullMessages diff --git a/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000..2a986bd --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,25 @@ +package sdk + +import ( + "context" + "encoding/xml" + "io" + "net/http" + + "github.com/juju/errors" +) + +// ReadAndParse reads the body of the given HTTP reply and unmarshals it into +// reply. The tag identifies the ONVIF action for diagnostic purposes. +func ReadAndParse(ctx context.Context, httpReply *http.Response, reply interface{}, tag string) error { + // TODO(jfsmig): extract the deadline from ctx.Deadline() and apply it on the reply reading + b, err := io.ReadAll(httpReply.Body) + if err != nil { + return errors.Annotate(err, "read") + } + + httpReply.Body.Close() + + err = xml.Unmarshal(b, reply) + return errors.Annotate(err, "decode") +}