343 lines
12 KiB
Go
343 lines
12 KiB
Go
package onvif
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha1"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/xml"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
deviceNamespace = "http://www.onvif.org/ver10/device/wsdl"
|
||
|
|
mediaNamespace = "http://www.onvif.org/ver10/media/wsdl"
|
||
|
|
)
|
||
|
|
|
||
|
|
type HTTPOptions struct {
|
||
|
|
RTSPRewriteHost string
|
||
|
|
RTSPRewritePort int
|
||
|
|
StripRTSPQuery bool
|
||
|
|
}
|
||
|
|
|
||
|
|
type HTTPAdapter struct {
|
||
|
|
credentials CredentialProvider
|
||
|
|
client *http.Client
|
||
|
|
options HTTPOptions
|
||
|
|
now func() time.Time
|
||
|
|
random io.Reader
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewHTTPAdapter(credentials CredentialProvider, client *http.Client, options HTTPOptions) *HTTPAdapter {
|
||
|
|
if client == nil {
|
||
|
|
client = &http.Client{Timeout: 10 * time.Second}
|
||
|
|
}
|
||
|
|
return &HTTPAdapter{
|
||
|
|
credentials: credentials,
|
||
|
|
client: client,
|
||
|
|
options: options,
|
||
|
|
now: time.Now,
|
||
|
|
random: rand.Reader,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *HTTPAdapter) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||
|
|
endpoint, credentials, err := a.target(target)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
infoBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetDeviceInformation",
|
||
|
|
`<tds:GetDeviceInformation xmlns:tds="`+deviceNamespace+`"/>`, credentials)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
var info deviceInformationEnvelope
|
||
|
|
if err := xml.Unmarshal(infoBody, &info); err != nil {
|
||
|
|
return ProbeResult{}, invalidResponse("decode device information")
|
||
|
|
}
|
||
|
|
|
||
|
|
servicesBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetServices",
|
||
|
|
`<tds:GetServices xmlns:tds="`+deviceNamespace+`"><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>`, credentials)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
var services servicesEnvelope
|
||
|
|
if err := xml.Unmarshal(servicesBody, &services); err != nil {
|
||
|
|
return ProbeResult{}, invalidResponse("decode services")
|
||
|
|
}
|
||
|
|
mediaEndpoint, err := externalMediaEndpoint(endpoint, services.Body.Response.Services)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
profilesBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetProfiles",
|
||
|
|
`<trt:GetProfiles xmlns:trt="`+mediaNamespace+`"/>`, credentials)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
var profilesResponse profilesEnvelope
|
||
|
|
if err := xml.Unmarshal(profilesBody, &profilesResponse); err != nil {
|
||
|
|
return ProbeResult{}, invalidResponse("decode profiles")
|
||
|
|
}
|
||
|
|
profiles := make([]Profile, 0, len(profilesResponse.Body.Response.Profiles))
|
||
|
|
selectedToken := ""
|
||
|
|
for _, value := range profilesResponse.Body.Response.Profiles {
|
||
|
|
video := value.VideoEncoder != nil
|
||
|
|
profiles = append(profiles, Profile{Token: value.Token, Name: value.Name, VideoEncoder: video})
|
||
|
|
if selectedToken == "" && video && value.Token != "" {
|
||
|
|
selectedToken = value.Token
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if selectedToken == "" {
|
||
|
|
return ProbeResult{}, invalidResponse("no video profile")
|
||
|
|
}
|
||
|
|
|
||
|
|
streamRequest := `<trt:GetStreamUri xmlns:trt="` + mediaNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||
|
|
`<trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>` +
|
||
|
|
`<trt:ProfileToken>` + escapeXML(selectedToken) + `</trt:ProfileToken></trt:GetStreamUri>`
|
||
|
|
streamBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetStreamUri", streamRequest, credentials)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
var streamResponse streamURIEnvelope
|
||
|
|
if err := xml.Unmarshal(streamBody, &streamResponse); err != nil {
|
||
|
|
return ProbeResult{}, invalidResponse("decode stream URI")
|
||
|
|
}
|
||
|
|
streamURI, err := a.rewriteStreamURI(endpoint, streamResponse.Body.Response.MediaURI.URI, credentials)
|
||
|
|
if err != nil {
|
||
|
|
return ProbeResult{}, err
|
||
|
|
}
|
||
|
|
return ProbeResult{
|
||
|
|
Manufacturer: info.Body.Response.Manufacturer,
|
||
|
|
Model: info.Body.Response.Model,
|
||
|
|
FirmwareVersion: info.Body.Response.FirmwareVersion,
|
||
|
|
SerialNumber: info.Body.Response.SerialNumber,
|
||
|
|
Profiles: profiles,
|
||
|
|
StreamURI: streamURI,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *HTTPAdapter) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
|
||
|
|
endpoint, credentials, err := a.target(target)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
utc := value.UTC()
|
||
|
|
body := `<tds:SetSystemDateAndTime xmlns:tds="` + deviceNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
|
||
|
|
`<tds:DateTimeType>Manual</tds:DateTimeType><tds:DaylightSavings>false</tds:DaylightSavings>` +
|
||
|
|
`<tds:UTCDateTime><tt:Time><tt:Hour>` + strconv.Itoa(utc.Hour()) + `</tt:Hour><tt:Minute>` + strconv.Itoa(utc.Minute()) +
|
||
|
|
`</tt:Minute><tt:Second>` + strconv.Itoa(utc.Second()) + `</tt:Second></tt:Time><tt:Date><tt:Year>` + strconv.Itoa(utc.Year()) +
|
||
|
|
`</tt:Year><tt:Month>` + strconv.Itoa(int(utc.Month())) + `</tt:Month><tt:Day>` + strconv.Itoa(utc.Day()) +
|
||
|
|
`</tt:Day></tt:Date></tds:UTCDateTime></tds:SetSystemDateAndTime>`
|
||
|
|
_, err = a.call(ctx, endpoint, deviceNamespace+"/SetSystemDateAndTime", body, credentials)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *HTTPAdapter) target(target Target) (*url.URL, Credentials, error) {
|
||
|
|
endpoint, err := url.Parse(target.EndpointRef)
|
||
|
|
if err != nil || endpoint.Host == "" || endpoint.User != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
|
||
|
|
return nil, Credentials{}, invalidResponse("invalid ONVIF endpoint")
|
||
|
|
}
|
||
|
|
credentials, err := a.credentials.Resolve(target.CredentialRef)
|
||
|
|
if err != nil {
|
||
|
|
return nil, Credentials{}, &Error{Code: ErrorAuthentication, Err: err}
|
||
|
|
}
|
||
|
|
return endpoint, credentials, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *HTTPAdapter) call(ctx context.Context, endpoint *url.URL, action, body string, credentials Credentials) ([]byte, error) {
|
||
|
|
nonce := make([]byte, 20)
|
||
|
|
if _, err := io.ReadFull(a.random, nonce); err != nil {
|
||
|
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create authentication nonce")}
|
||
|
|
}
|
||
|
|
created := a.now().UTC().Format("2006-01-02T15:04:05Z")
|
||
|
|
digestInput := append(append(append([]byte{}, nonce...), []byte(created)...), []byte(credentials.ONVIFPassword)...)
|
||
|
|
digest := sha1.Sum(digestInput)
|
||
|
|
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
|
||
|
|
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
|
||
|
|
`<s:Header><wsse:Security s:mustUnderstand="1"><wsse:UsernameToken><wsse:Username>` + escapeXML(credentials.ONVIFUsername) +
|
||
|
|
`</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
|
||
|
|
base64.StdEncoding.EncodeToString(digest[:]) + `</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
|
||
|
|
base64.StdEncoding.EncodeToString(nonce) + `</wsse:Nonce><wsu:Created>` + created +
|
||
|
|
`</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header><s:Body>` + body + `</s:Body></s:Envelope>`
|
||
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(envelope))
|
||
|
|
if err != nil {
|
||
|
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create ONVIF request")}
|
||
|
|
}
|
||
|
|
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
|
||
|
|
response, err := a.client.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
code := ErrorUnavailable
|
||
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||
|
|
code = ErrorTimeout
|
||
|
|
}
|
||
|
|
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF transport failed")}
|
||
|
|
}
|
||
|
|
defer response.Body.Close()
|
||
|
|
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||
|
|
if err != nil {
|
||
|
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("read ONVIF response")}
|
||
|
|
}
|
||
|
|
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||
|
|
return nil, &Error{Code: ErrorAuthentication, Err: fmt.Errorf("ONVIF authorization failed")}
|
||
|
|
}
|
||
|
|
if fault := soapFault(responseBody); fault != "" {
|
||
|
|
code := ErrorInvalidReply
|
||
|
|
lower := strings.ToLower(fault)
|
||
|
|
if strings.Contains(lower, "authoriz") || strings.Contains(lower, "notauthorized") {
|
||
|
|
code = ErrorAuthentication
|
||
|
|
}
|
||
|
|
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF SOAP fault")}
|
||
|
|
}
|
||
|
|
if response.StatusCode != http.StatusOK {
|
||
|
|
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("ONVIF returned HTTP status %d", response.StatusCode)}
|
||
|
|
}
|
||
|
|
return responseBody, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func externalMediaEndpoint(deviceEndpoint *url.URL, services []service) (*url.URL, error) {
|
||
|
|
for _, value := range services {
|
||
|
|
if value.Namespace != mediaNamespace || value.XAddr == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
mediaEndpoint, err := url.Parse(value.XAddr)
|
||
|
|
if err != nil || mediaEndpoint.Host == "" {
|
||
|
|
return nil, invalidResponse("invalid media service address")
|
||
|
|
}
|
||
|
|
mediaEndpoint.Scheme = deviceEndpoint.Scheme
|
||
|
|
mediaEndpoint.Host = deviceEndpoint.Host
|
||
|
|
mediaEndpoint.User = nil
|
||
|
|
return mediaEndpoint, nil
|
||
|
|
}
|
||
|
|
return nil, invalidResponse("media service is unavailable")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *HTTPAdapter) rewriteStreamURI(deviceEndpoint *url.URL, raw string, credentials Credentials) (string, error) {
|
||
|
|
stream, err := url.Parse(raw)
|
||
|
|
if err != nil || stream.Host == "" || (stream.Scheme != "rtsp" && stream.Scheme != "rtsps") {
|
||
|
|
return "", invalidResponse("invalid stream URI")
|
||
|
|
}
|
||
|
|
host := a.options.RTSPRewriteHost
|
||
|
|
if host == "" {
|
||
|
|
host = deviceEndpoint.Hostname()
|
||
|
|
}
|
||
|
|
port := a.options.RTSPRewritePort
|
||
|
|
if port == 0 {
|
||
|
|
if parsedPort := stream.Port(); parsedPort != "" {
|
||
|
|
value, parseErr := strconv.Atoi(parsedPort)
|
||
|
|
if parseErr != nil {
|
||
|
|
return "", invalidResponse("invalid stream port")
|
||
|
|
}
|
||
|
|
port = value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if port > 0 {
|
||
|
|
stream.Host = net.JoinHostPort(host, strconv.Itoa(port))
|
||
|
|
} else {
|
||
|
|
stream.Host = host
|
||
|
|
}
|
||
|
|
stream.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
|
||
|
|
if a.options.StripRTSPQuery {
|
||
|
|
stream.RawQuery = ""
|
||
|
|
stream.ForceQuery = false
|
||
|
|
}
|
||
|
|
return stream.String(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func invalidResponse(message string) error {
|
||
|
|
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("%s", message)}
|
||
|
|
}
|
||
|
|
|
||
|
|
func escapeXML(value string) string {
|
||
|
|
var buffer bytes.Buffer
|
||
|
|
_ = xml.EscapeText(&buffer, []byte(value))
|
||
|
|
return buffer.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
func soapFault(body []byte) string {
|
||
|
|
decoder := xml.NewDecoder(bytes.NewReader(body))
|
||
|
|
inFault := false
|
||
|
|
for {
|
||
|
|
token, err := decoder.Token()
|
||
|
|
if errors.Is(err, io.EOF) {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
switch value := token.(type) {
|
||
|
|
case xml.StartElement:
|
||
|
|
if value.Name.Local == "Fault" {
|
||
|
|
inFault = true
|
||
|
|
}
|
||
|
|
if inFault && (value.Name.Local == "Text" || value.Name.Local == "faultstring") {
|
||
|
|
var message string
|
||
|
|
if decoder.DecodeElement(&message, &value) == nil {
|
||
|
|
return message
|
||
|
|
}
|
||
|
|
}
|
||
|
|
case xml.EndElement:
|
||
|
|
if value.Name.Local == "Fault" {
|
||
|
|
return "SOAP fault"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type deviceInformationEnvelope struct {
|
||
|
|
Body struct {
|
||
|
|
Response struct {
|
||
|
|
Manufacturer string `xml:"Manufacturer"`
|
||
|
|
Model string `xml:"Model"`
|
||
|
|
FirmwareVersion string `xml:"FirmwareVersion"`
|
||
|
|
SerialNumber string `xml:"SerialNumber"`
|
||
|
|
} `xml:"GetDeviceInformationResponse"`
|
||
|
|
} `xml:"Body"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type service struct {
|
||
|
|
Namespace string `xml:"Namespace"`
|
||
|
|
XAddr string `xml:"XAddr"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type servicesEnvelope struct {
|
||
|
|
Body struct {
|
||
|
|
Response struct {
|
||
|
|
Services []service `xml:"Service"`
|
||
|
|
} `xml:"GetServicesResponse"`
|
||
|
|
} `xml:"Body"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type profileResponse struct {
|
||
|
|
Token string `xml:"token,attr"`
|
||
|
|
Name string `xml:"Name"`
|
||
|
|
VideoEncoder *struct{} `xml:"VideoEncoderConfiguration"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type profilesEnvelope struct {
|
||
|
|
Body struct {
|
||
|
|
Response struct {
|
||
|
|
Profiles []profileResponse `xml:"Profiles"`
|
||
|
|
} `xml:"GetProfilesResponse"`
|
||
|
|
} `xml:"Body"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type streamURIEnvelope struct {
|
||
|
|
Body struct {
|
||
|
|
Response struct {
|
||
|
|
MediaURI struct {
|
||
|
|
URI string `xml:"Uri"`
|
||
|
|
} `xml:"MediaUri"`
|
||
|
|
} `xml:"GetStreamUriResponse"`
|
||
|
|
} `xml:"Body"`
|
||
|
|
}
|