297 lines
9.9 KiB
Go
297 lines
9.9 KiB
Go
package onvif
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrAuthentication = errors.New("设备拒绝了当前凭据")
|
|
ErrRedirect = errors.New("设备返回了不允许的重定向")
|
|
)
|
|
|
|
type Credential struct{ Username, 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:"streamUri"`
|
|
}
|
|
type Client interface {
|
|
Profiles(context.Context, string, Credential) ([]Profile, error)
|
|
}
|
|
type HTTPClient struct {
|
|
client *http.Client
|
|
policy Policy
|
|
}
|
|
|
|
func NewHTTPClient(timeout time.Duration, policy Policy) *HTTPClient {
|
|
if timeout <= 0 {
|
|
timeout = 8 * time.Second
|
|
}
|
|
dialer := net.Dialer{Timeout: timeout}
|
|
transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, ErrAddressInvalid
|
|
}
|
|
_, ip, err := policy.ValidateURL(ctx, "http://"+net.JoinHostPort(host, port), "http")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
|
}}
|
|
return &HTTPClient{policy: policy, client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
|
}
|
|
|
|
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
|
|
device, _, err := c.policy.ValidateURL(ctx, address, "http", "https")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
capabilities, err := c.soap(ctx, device.String(), credential, `<?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>`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mediaRaw, err := parseElement(capabilities, "Media", "XAddr")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
media, err := c.normalizeService(ctx, device, mediaRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := c.soap(ctx, media.String(), credential, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles, err := parseProfiles(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for index := range profiles {
|
|
body := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, xmlEscape(profiles[index].Token))
|
|
response, requestErr := c.soap(ctx, media.String(), credential, body)
|
|
if requestErr != nil {
|
|
return nil, requestErr
|
|
}
|
|
raw, parseErr := parseElement(response, "", "Uri")
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
profiles[index].StreamURI, err = c.normalizeStream(ctx, device, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return profiles, nil
|
|
}
|
|
|
|
func (c *HTTPClient) normalizeService(ctx context.Context, device *url.URL, raw string) (*url.URL, error) {
|
|
advertised, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || advertised.Hostname() == "" || advertised.User != nil || (advertised.Scheme != "http" && advertised.Scheme != "https") {
|
|
return nil, ErrAddressInvalid
|
|
}
|
|
if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
|
|
advertised.Scheme = device.Scheme
|
|
advertised.Host = device.Host
|
|
}
|
|
if _, _, err = c.policy.ValidateURL(ctx, advertised.String(), "http", "https"); err != nil {
|
|
return nil, err
|
|
}
|
|
return advertised, nil
|
|
}
|
|
func (c *HTTPClient) normalizeStream(ctx context.Context, device *url.URL, raw string) (string, error) {
|
|
stream, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || len(raw) > 2048 || stream.Hostname() == "" || stream.User != nil || stream.Scheme != "rtsp" {
|
|
return "", ErrAddressInvalid
|
|
}
|
|
if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
|
|
port := stream.Port()
|
|
stream.Host = device.Hostname()
|
|
if port != "" {
|
|
stream.Host = net.JoinHostPort(device.Hostname(), port)
|
|
}
|
|
}
|
|
if _, _, err = c.policy.ValidateURL(ctx, stream.String(), "rtsp"); err != nil {
|
|
return "", err
|
|
}
|
|
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: %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 >= 300 && res.StatusCode < 400 {
|
|
return nil, ErrRedirect
|
|
}
|
|
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, ErrAuthentication
|
|
}
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("onvif http status %d", res.StatusCode)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type digestChallenge struct{ realm, nonce, opaque, algorithm, qop string }
|
|
|
|
func parseDigestChallenge(values []string) (digestChallenge, error) {
|
|
for _, value := range values {
|
|
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
|
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Digest") {
|
|
continue
|
|
}
|
|
params, err := parseAuthParameters(parts[1])
|
|
if err != nil {
|
|
return digestChallenge{}, err
|
|
}
|
|
c := digestChallenge{realm: params["realm"], nonce: params["nonce"], opaque: params["opaque"], algorithm: strings.ToUpper(params["algorithm"])}
|
|
if c.realm == "" || c.nonce == "" {
|
|
return digestChallenge{}, ErrAuthentication
|
|
}
|
|
if c.algorithm == "" {
|
|
c.algorithm = "MD5"
|
|
}
|
|
if c.algorithm != "MD5" && c.algorithm != "SHA-256" {
|
|
return digestChallenge{}, ErrAuthentication
|
|
}
|
|
for _, q := range strings.Split(params["qop"], ",") {
|
|
if strings.EqualFold(strings.TrimSpace(q), "auth") {
|
|
c.qop = "auth"
|
|
}
|
|
}
|
|
if params["qop"] != "" && c.qop == "" {
|
|
return digestChallenge{}, ErrAuthentication
|
|
}
|
|
return c, nil
|
|
}
|
|
return digestChallenge{}, ErrAuthentication
|
|
}
|
|
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, ErrAuthentication
|
|
}
|
|
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, ErrAuthentication
|
|
}
|
|
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, c digestChallenge) (string, error) {
|
|
random := make([]byte, 16)
|
|
if _, err := rand.Read(random); err != nil {
|
|
return "", err
|
|
}
|
|
cnonce := fmt.Sprintf("%x", random)
|
|
hash := func(value string) string {
|
|
if c.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 + ":" + c.realm + ":" + credential.Password)
|
|
ha2 := hash(method + ":" + uri)
|
|
nc := "00000001"
|
|
response := hash(ha1 + ":" + c.nonce + ":" + ha2)
|
|
if c.qop != "" {
|
|
response = hash(ha1 + ":" + c.nonce + ":" + nc + ":" + cnonce + ":" + c.qop + ":" + ha2)
|
|
}
|
|
values := []string{`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(c.realm), `nonce=` + strconv.Quote(c.nonce), `uri=` + strconv.Quote(uri), `response=` + strconv.Quote(response), `algorithm=` + c.algorithm}
|
|
if c.opaque != "" {
|
|
values = append(values, `opaque=`+strconv.Quote(c.opaque))
|
|
}
|
|
if c.qop != "" {
|
|
values = append(values, `qop=`+c.qop, `nc=`+nc, `cnonce=`+strconv.Quote(cnonce))
|
|
}
|
|
return "Digest " + strings.Join(values, ", "), nil
|
|
}
|
|
func xmlEscape(value string) string {
|
|
var b strings.Builder
|
|
_ = xml.EscapeText(&b, []byte(value))
|
|
return b.String()
|
|
}
|