package onvif import ( "bytes" "context" "crypto/md5" "crypto/rand" "crypto/sha256" "encoding/xml" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" ) type Credential struct { Username string Password string } type Profile struct { Token string `json:"token"` Name string `json:"name"` Width int `json:"width"` Height int `json:"height"` Encoding string `json:"encoding"` StreamURI string `json:"stream_uri"` } type Client interface { Profiles(context.Context, string, Credential) ([]Profile, error) } type HTTPClient struct{ client *http.Client } func NewHTTPClient(timeout time.Duration) *HTTPClient { if timeout <= 0 { timeout = 8 * time.Second } return &HTTPClient{client: &http.Client{ Timeout: timeout, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }} } func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) { deviceEndpoint, err := validateEndpoint(address) if err != nil { return nil, err } capabilitiesBody := `All` capabilities, err := c.soap(ctx, deviceEndpoint, credential, capabilitiesBody) if err != nil { return nil, err } mediaAddress, err := ParseMediaServiceAddress(capabilities) if err != nil { return nil, err } endpoint, err := normalizeServiceEndpoint(deviceEndpoint, mediaAddress) if err != nil { return nil, err } body := `` data, err := c.soap(ctx, endpoint, credential, body) if err != nil { return nil, err } profiles, err := ParseProfiles(data) if err != nil { return nil, err } for i := range profiles { uriBody := fmt.Sprintf(`RTP-UnicastRTSP%s`, xmlEscape(profiles[i].Token)) streamData, err := c.soap(ctx, endpoint, credential, uriBody) if err != nil { return nil, err } profiles[i].StreamURI, err = ParseStreamURI(streamData) if err != nil { return nil, err } profiles[i].StreamURI, err = normalizeStreamURI(deviceEndpoint, profiles[i].StreamURI) if err != nil { return nil, err } } return profiles, nil } func normalizeStreamURI(deviceEndpoint, streamURI string) (string, error) { device, err := url.Parse(deviceEndpoint) if err != nil || device.Hostname() == "" { return "", fmt.Errorf("invalid ONVIF address") } stream, err := url.Parse(streamURI) if err != nil || stream.Scheme != "rtsp" || stream.Host == "" || stream.User != nil { return "", fmt.Errorf("invalid RTSP stream URI") } if !strings.EqualFold(stream.Hostname(), device.Hostname()) { port := stream.Port() stream.Host = device.Hostname() if port != "" { stream.Host = net.JoinHostPort(device.Hostname(), port) } } return stream.String(), nil } func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) { return c.soapAttempt(ctx, endpoint, credential, body, "") } func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") if authorization != "" { req.Header.Set("Authorization", authorization) } else if credential.Username != "" { req.SetBasicAuth(credential.Username, credential.Password) } res, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("onvif request failed: %w", err) } defer res.Body.Close() data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20)) if err != nil { return nil, err } if res.StatusCode == http.StatusUnauthorized { if authorization == "" && credential.Username != "" { challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate")) if challengeErr == nil { digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge) if digestErr != nil { return nil, digestErr } return c.soapAttempt(ctx, endpoint, credential, body, digest) } } return nil, fmt.Errorf("authentication_failed") } if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("onvif_http_%d", res.StatusCode) } return data, nil } type digestChallenge struct { realm string nonce string opaque string algorithm string qop string } func parseDigestChallenge(values []string) (digestChallenge, error) { for _, value := range values { if !strings.EqualFold(strings.TrimSpace(strings.SplitN(value, " ", 2)[0]), "Digest") { continue } parts := strings.SplitN(strings.TrimSpace(value), " ", 2) if len(parts) != 2 { break } params, err := parseAuthParameters(parts[1]) if err != nil { return digestChallenge{}, err } challenge := digestChallenge{ realm: strings.TrimSpace(params["realm"]), nonce: strings.TrimSpace(params["nonce"]), opaque: strings.TrimSpace(params["opaque"]), algorithm: strings.ToUpper(strings.TrimSpace(params["algorithm"])), } if challenge.realm == "" || challenge.nonce == "" { return digestChallenge{}, fmt.Errorf("invalid_digest_challenge") } if challenge.algorithm == "" { challenge.algorithm = "MD5" } if challenge.algorithm != "MD5" && challenge.algorithm != "SHA-256" { return digestChallenge{}, fmt.Errorf("unsupported_digest_algorithm") } qops := strings.Split(params["qop"], ",") for _, qop := range qops { if strings.EqualFold(strings.TrimSpace(qop), "auth") { challenge.qop = "auth" break } } if params["qop"] != "" && challenge.qop == "" { return digestChallenge{}, fmt.Errorf("unsupported_digest_qop") } return challenge, nil } return digestChallenge{}, fmt.Errorf("digest_challenge_not_found") } func parseAuthParameters(value string) (map[string]string, error) { result := map[string]string{} for position := 0; position < len(value); { for position < len(value) && (value[position] == ' ' || value[position] == ',') { position++ } start := position for position < len(value) && value[position] != '=' && value[position] != ',' { position++ } if position == start || position >= len(value) || value[position] != '=' { return nil, fmt.Errorf("invalid_digest_challenge") } name := strings.ToLower(strings.TrimSpace(value[start:position])) position++ var parameter string if position < len(value) && value[position] == '"' { position++ var builder strings.Builder closed := false for position < len(value) { if value[position] == '"' { position++ closed = true break } if value[position] == '\\' && position+1 < len(value) { position++ } builder.WriteByte(value[position]) position++ } if !closed { return nil, fmt.Errorf("invalid_digest_challenge") } parameter = builder.String() } else { start = position for position < len(value) && value[position] != ',' { position++ } parameter = strings.TrimSpace(value[start:position]) } result[name] = parameter } return result, nil } func digestAuthorization(method, uri string, credential Credential, challenge digestChallenge) (string, error) { cnonceBytes := make([]byte, 16) if _, err := rand.Read(cnonceBytes); err != nil { return "", fmt.Errorf("generate_digest_cnonce: %w", err) } cnonce := fmt.Sprintf("%x", cnonceBytes) hash := func(value string) string { if challenge.algorithm == "SHA-256" { sum := sha256.Sum256([]byte(value)) return fmt.Sprintf("%x", sum) } sum := md5.Sum([]byte(value)) return fmt.Sprintf("%x", sum) } ha1 := hash(credential.Username + ":" + challenge.realm + ":" + credential.Password) ha2 := hash(method + ":" + uri) nonceCount := "00000001" response := hash(ha1 + ":" + challenge.nonce + ":" + ha2) if challenge.qop != "" { response = hash(ha1 + ":" + challenge.nonce + ":" + nonceCount + ":" + cnonce + ":" + challenge.qop + ":" + ha2) } values := []string{ `username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(challenge.realm), `nonce=` + strconv.Quote(challenge.nonce), `uri=` + strconv.Quote(uri), `response=` + strconv.Quote(response), `algorithm=` + challenge.algorithm, } if challenge.opaque != "" { values = append(values, `opaque=`+strconv.Quote(challenge.opaque)) } if challenge.qop != "" { values = append(values, `qop=`+challenge.qop, `nc=`+nonceCount, `cnonce=`+strconv.Quote(cnonce)) } return "Digest " + strings.Join(values, ", "), nil } func normalizeServiceEndpoint(deviceEndpoint, advertisedEndpoint string) (string, error) { device, err := url.Parse(deviceEndpoint) if err != nil { return "", fmt.Errorf("invalid ONVIF address") } advertised, err := url.Parse(advertisedEndpoint) if err != nil || advertised.Scheme == "" || advertised.Host == "" || advertised.User != nil { return "", fmt.Errorf("invalid ONVIF media address") } if advertised.Scheme != "http" && advertised.Scheme != "https" { return "", fmt.Errorf("unsupported ONVIF media scheme") } if !strings.EqualFold(advertised.Hostname(), device.Hostname()) { advertised.Scheme = device.Scheme advertised.Host = device.Host } return advertised.String(), nil } func validateEndpoint(value string) (string, error) { parsed, err := url.Parse(value) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid ONVIF address") } if parsed.User != nil { return "", fmt.Errorf("credentials must not appear in ONVIF address") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", fmt.Errorf("unsupported ONVIF scheme") } return parsed.String(), nil } func xmlEscape(value string) string { var b strings.Builder _ = xml.EscapeText(&b, []byte(value)) return b.String() }