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", ``, 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", `false`, 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", ``, 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 := `` + `RTP-UnicastRTSP` + `` + escapeXML(selectedToken) + `` 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 := `` + `Manualfalse` + `` + strconv.Itoa(utc.Hour()) + `` + strconv.Itoa(utc.Minute()) + `` + strconv.Itoa(utc.Second()) + `` + strconv.Itoa(utc.Year()) + `` + strconv.Itoa(int(utc.Month())) + `` + strconv.Itoa(utc.Day()) + `` _, 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 := `` + `` + `` + escapeXML(credentials.ONVIFUsername) + `` + base64.StdEncoding.EncodeToString(digest[:]) + `` + base64.StdEncoding.EncodeToString(nonce) + `` + created + `` + body + `` 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"` }