fix: support ONVIF Digest media services (#48)
This commit is contained in:
@@ -3,11 +3,15 @@ package onvif
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -34,10 +38,28 @@ func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
return &HTTPClient{client: &http.Client{Timeout: timeout}}
|
||||
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) {
|
||||
endpoint, err := validateEndpoint(address)
|
||||
deviceEndpoint, err := validateEndpoint(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capabilitiesBody := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`
|
||||
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
|
||||
}
|
||||
@@ -64,12 +86,18 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
|
||||
return profiles, 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 credential.Username != "" {
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
} else if credential.Username != "" {
|
||||
req.SetBasicAuth(credential.Username, credential.Password)
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
@@ -82,6 +110,16 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
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 {
|
||||
@@ -89,6 +127,157 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
}
|
||||
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 == "" {
|
||||
@@ -107,3 +296,4 @@ func xmlEscape(value string) string {
|
||||
_ = xml.EscapeText(&b, []byte(value))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user