96ac509c24
- Replaced http.NewRequest with http.NewRequestWithContext in client tests for better context management. - Updated method names and comments for clarity, including renaming GetWsdlUrl to GetWsdlURL and StorageUri to StorageURI for consistency. - Enhanced comments across various files to provide clearer descriptions of functionality and ONVIF specifications.
534 lines
16 KiB
Go
534 lines
16 KiB
Go
package onvif
|
|
|
|
import (
|
|
"context"
|
|
"crypto/md5" //nolint:gosec // MD5 used for ONVIF digest authentication
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Default client configuration constants.
|
|
const (
|
|
// DefaultTimeout is the default HTTP client timeout.
|
|
DefaultTimeout = 30 * time.Second
|
|
// DefaultIdleConnTimeout is the default idle connection timeout.
|
|
DefaultIdleConnTimeout = 90 * time.Second
|
|
// DefaultMaxIdleConns is the default maximum idle connections.
|
|
DefaultMaxIdleConns = 10
|
|
// DefaultMaxIdleConnsPerHost is the default maximum idle connections per host.
|
|
DefaultMaxIdleConnsPerHost = 5
|
|
// NonceSize is the size of the nonce for digest authentication.
|
|
NonceSize = 16
|
|
)
|
|
|
|
// Client represents an ONVIF client for communicating with IP cameras.
|
|
type Client struct {
|
|
endpoint string
|
|
username string
|
|
password string
|
|
httpClient *http.Client
|
|
mu sync.RWMutex
|
|
|
|
// Service endpoints
|
|
mediaEndpoint string
|
|
ptzEndpoint string
|
|
imagingEndpoint string
|
|
eventEndpoint string
|
|
}
|
|
|
|
// ClientOption is a functional option for configuring the Client.
|
|
type ClientOption func(*Client)
|
|
|
|
// WithTimeout sets the HTTP client timeout.
|
|
func WithTimeout(timeout time.Duration) ClientOption {
|
|
return func(c *Client) {
|
|
c.httpClient.Timeout = timeout
|
|
}
|
|
}
|
|
|
|
// WithHTTPClient sets a custom HTTP client.
|
|
func WithHTTPClient(httpClient *http.Client) ClientOption {
|
|
return func(c *Client) {
|
|
c.httpClient = httpClient
|
|
}
|
|
}
|
|
|
|
// WithInsecureSkipVerify disables TLS certificate verification.
|
|
// WARNING: Only use this for testing or with trusted cameras on private networks.
|
|
func WithInsecureSkipVerify() ClientOption {
|
|
return func(c *Client) {
|
|
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
|
|
if transport.TLSClientConfig == nil {
|
|
transport.TLSClientConfig = &tls.Config{ //nolint:gosec // InsecureSkipVerify is intentional for testing
|
|
}
|
|
}
|
|
transport.TLSClientConfig.InsecureSkipVerify = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithCredentials sets the authentication credentials.
|
|
func WithCredentials(username, password string) ClientOption {
|
|
return func(c *Client) {
|
|
c.username = username
|
|
c.password = password
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new ONVIF client
|
|
// The endpoint can be provided in multiple formats:
|
|
// - Full URL: "http://192.168.1.100/onvif/device_service"
|
|
// - IP with port: "192.168.1.100:80" (http assumed, /onvif/device_service added)
|
|
// - IP only: "192.168.1.100" (http://IP:80/onvif/device_service used)
|
|
func NewClient(endpoint string, opts ...ClientOption) (*Client, error) {
|
|
// Normalize endpoint to full URL
|
|
normalizedEndpoint, err := normalizeEndpoint(endpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid endpoint: %w", err)
|
|
}
|
|
|
|
client := &Client{
|
|
endpoint: normalizedEndpoint,
|
|
httpClient: &http.Client{
|
|
Timeout: DefaultTimeout,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: DefaultMaxIdleConns,
|
|
MaxIdleConnsPerHost: DefaultMaxIdleConnsPerHost,
|
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
|
},
|
|
// Don't follow redirects automatically
|
|
// This prevents http:// from being silently upgraded to https://
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
}
|
|
|
|
// Apply options
|
|
for _, opt := range opts {
|
|
opt(client)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// normalizeEndpoint converts various endpoint formats to a full ONVIF URL.
|
|
func normalizeEndpoint(endpoint string) (string, error) {
|
|
// Check if endpoint starts with a scheme
|
|
if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
|
|
// Parse as full URL
|
|
parsedURL, err := url.Parse(endpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse endpoint URL: %w", err)
|
|
}
|
|
if parsedURL.Host == "" {
|
|
return "", fmt.Errorf("%w", ErrURLMissingHost)
|
|
}
|
|
// If path is empty or just "/", add default ONVIF path
|
|
if parsedURL.Path == "" || parsedURL.Path == "/" {
|
|
parsedURL.Path = "/onvif/device_service"
|
|
}
|
|
|
|
return parsedURL.String(), nil
|
|
}
|
|
|
|
// No scheme - treat as IP, IP:port, hostname, or hostname:port
|
|
// Add http:// scheme and validate
|
|
fullURL := "http://" + endpoint + "/onvif/device_service"
|
|
parsedURL, err := url.Parse(fullURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid IP address or hostname: %w", err)
|
|
}
|
|
|
|
if parsedURL.Host == "" {
|
|
return "", fmt.Errorf("%w", ErrInvalidEndpointFormat)
|
|
}
|
|
|
|
return fullURL, nil
|
|
}
|
|
|
|
// Some cameras incorrectly report localhost (127.0.0.1, 0.0.0.0, localhost) in their capability URLs.
|
|
func (c *Client) fixLocalhostURL(serviceURL string) string {
|
|
if serviceURL == "" {
|
|
return serviceURL
|
|
}
|
|
|
|
// Parse the service URL
|
|
parsedService, err := url.Parse(serviceURL)
|
|
if err != nil {
|
|
return serviceURL // Return original if parsing fails
|
|
}
|
|
|
|
// Check if the service URL has a localhost/loopback address
|
|
host := parsedService.Hostname()
|
|
if host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" {
|
|
// Parse the client's endpoint to get the actual camera address
|
|
parsedClient, err := url.Parse(c.endpoint)
|
|
if err != nil {
|
|
return serviceURL // Return original if parsing fails
|
|
}
|
|
|
|
// Replace the host but keep the port from service URL if specified
|
|
servicePort := parsedService.Port()
|
|
if servicePort != "" {
|
|
parsedService.Host = parsedClient.Hostname() + ":" + servicePort
|
|
} else {
|
|
parsedService.Host = parsedClient.Hostname()
|
|
// Use client's port if service doesn't specify one
|
|
if clientPort := parsedClient.Port(); clientPort != "" {
|
|
parsedService.Host = parsedClient.Hostname() + ":" + clientPort
|
|
}
|
|
}
|
|
|
|
return parsedService.String()
|
|
}
|
|
|
|
return serviceURL
|
|
}
|
|
|
|
// Initialize discovers and initializes service endpoints.
|
|
func (c *Client) Initialize(ctx context.Context) error {
|
|
// Get device information and capabilities
|
|
capabilities, err := c.GetCapabilities(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get capabilities: %w", err)
|
|
}
|
|
|
|
// Extract service endpoints and fix any localhost addresses
|
|
// Some cameras incorrectly report localhost instead of their actual IP
|
|
if capabilities.Media != nil && capabilities.Media.XAddr != "" {
|
|
c.mediaEndpoint = c.fixLocalhostURL(capabilities.Media.XAddr)
|
|
}
|
|
if capabilities.PTZ != nil && capabilities.PTZ.XAddr != "" {
|
|
c.ptzEndpoint = c.fixLocalhostURL(capabilities.PTZ.XAddr)
|
|
}
|
|
if capabilities.Imaging != nil && capabilities.Imaging.XAddr != "" {
|
|
c.imagingEndpoint = c.fixLocalhostURL(capabilities.Imaging.XAddr)
|
|
}
|
|
if capabilities.Events != nil && capabilities.Events.XAddr != "" {
|
|
c.eventEndpoint = c.fixLocalhostURL(capabilities.Events.XAddr)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Endpoint returns the device endpoint.
|
|
func (c *Client) Endpoint() string {
|
|
return c.endpoint
|
|
}
|
|
|
|
// SetCredentials updates the authentication credentials.
|
|
func (c *Client) SetCredentials(username, password string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.username = username
|
|
c.password = password
|
|
}
|
|
|
|
// GetCredentials returns the current credentials.
|
|
func (c *Client) GetCredentials() (username, password string) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
return c.username, c.password
|
|
}
|
|
|
|
// DownloadFile downloads a file from the given URL with authentication.
|
|
// Supports both Basic and Digest authentication (tries basic first, falls back to digest).
|
|
func (c *Client) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
|
|
// Try basic auth first
|
|
data, err := c.downloadWithBasicAuth(ctx, downloadURL)
|
|
if err == nil {
|
|
return data, nil
|
|
}
|
|
|
|
// If basic auth fails with 401, try digest auth
|
|
if strings.Contains(err.Error(), "401") {
|
|
digestData, digestErr := c.downloadWithDigestAuth(ctx, downloadURL)
|
|
if digestErr == nil {
|
|
return digestData, nil
|
|
}
|
|
// If digest auth also fails, return the original error
|
|
if strings.Contains(digestErr.Error(), "401") {
|
|
return nil, err // Return original error (both auth methods failed)
|
|
}
|
|
|
|
return nil, digestErr
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
// downloadWithBasicAuth performs an HTTP download with Basic authentication.
|
|
func (c *Client) downloadWithBasicAuth(ctx context.Context, downloadURL string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, http.NoBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
if c.username != "" {
|
|
req.SetBasicAuth(c.username, c.password)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", "onvif-go-client")
|
|
req.Header.Set("Connection", "close")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("download request failed: %w", err)
|
|
}
|
|
//nolint:errcheck // Close error in defer is intentionally ignored
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
//nolint:errcheck // Error response body preview - ignore read errors
|
|
bodyPreview, _ := io.ReadAll(resp.Body)
|
|
bodyStr := string(bodyPreview)
|
|
const maxBodyPreview = 200
|
|
if len(bodyStr) > maxBodyPreview {
|
|
bodyStr = bodyStr[:maxBodyPreview] + "..."
|
|
}
|
|
|
|
// Base error message for programmatic use
|
|
errorMsg := fmt.Sprintf("download failed with status code %d", resp.StatusCode)
|
|
|
|
// Add structured error details
|
|
switch resp.StatusCode {
|
|
case http.StatusUnauthorized:
|
|
errorMsg += ": authentication failed (401 Unauthorized); basic auth failed, trying digest auth"
|
|
case http.StatusForbidden:
|
|
errorMsg += ": access denied (403 Forbidden); user may not have permission to download snapshots"
|
|
case http.StatusNotFound:
|
|
errorMsg += ": snapshot URI not found (404); camera may have revoked the URI, try getting a fresh snapshot URI"
|
|
}
|
|
|
|
if bodyStr != "" {
|
|
errorMsg += fmt.Sprintf("; response: %s", bodyStr)
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrDownloadFailed, errorMsg)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// downloadWithDigestAuth performs an HTTP download with Digest authentication.
|
|
func (c *Client) downloadWithDigestAuth(ctx context.Context, downloadURL string) ([]byte, error) {
|
|
if c.username == "" {
|
|
return nil, fmt.Errorf("%w", ErrDigestAuthRequiresCredentials)
|
|
}
|
|
|
|
// Create a custom transport with digest auth
|
|
tr := &http.Transport{
|
|
Dial: (&net.Dialer{
|
|
Timeout: DefaultTimeout,
|
|
KeepAlive: DefaultTimeout,
|
|
}).Dial,
|
|
MaxIdleConns: DefaultMaxIdleConns,
|
|
MaxIdleConnsPerHost: DefaultMaxIdleConnsPerHost,
|
|
IdleConnTimeout: DefaultIdleConnTimeout,
|
|
}
|
|
|
|
// Create a custom HTTP client for digest auth
|
|
digestClient := &http.Client{
|
|
Transport: &digestAuthTransport{
|
|
transport: tr,
|
|
username: c.username,
|
|
password: c.password,
|
|
},
|
|
Timeout: DefaultTimeout,
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, http.NoBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", "onvif-go-client")
|
|
req.Header.Set("Connection", "close")
|
|
|
|
resp, err := digestClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("digest auth request failed: %w", err)
|
|
}
|
|
//nolint:errcheck // Close error in defer is intentionally ignored
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
//nolint:errcheck // Error response body preview - ignore read errors
|
|
bodyPreview, _ := io.ReadAll(resp.Body)
|
|
bodyStr := string(bodyPreview)
|
|
const maxBodyPreview = 200
|
|
if len(bodyStr) > maxBodyPreview {
|
|
bodyStr = bodyStr[:maxBodyPreview] + "..."
|
|
}
|
|
|
|
errorMsg := fmt.Sprintf("download failed with status code %d", resp.StatusCode)
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusUnauthorized:
|
|
errorMsg += ": digest authentication failed (401 Unauthorized); check camera credentials (username/password)"
|
|
case http.StatusForbidden:
|
|
errorMsg += ": access denied (403 Forbidden); user may not have permission to download snapshots"
|
|
case http.StatusNotFound:
|
|
errorMsg += ": snapshot URI not found (404); try getting a fresh snapshot URI"
|
|
}
|
|
|
|
if bodyStr != "" {
|
|
errorMsg += fmt.Sprintf("; response: %s", bodyStr)
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrDownloadFailed, errorMsg)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// digestAuthTransport implements digest authentication for HTTP transport.
|
|
type digestAuthTransport struct {
|
|
transport *http.Transport
|
|
username string
|
|
password string
|
|
nc int
|
|
ncMu sync.Mutex // Protects nc field from concurrent access
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper with digest auth support.
|
|
func (d *digestAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// First request without auth to get the challenge
|
|
resp, err := d.transport.RoundTrip(req)
|
|
if err != nil {
|
|
return resp, fmt.Errorf("transport round trip failed: %w", err)
|
|
}
|
|
|
|
// If we get 401, handle digest auth challenge
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
// Read the WWW-Authenticate header
|
|
authHeader := resp.Header.Get("WWW-Authenticate")
|
|
if strings.Contains(authHeader, "Digest") {
|
|
// Parse digest challenge and create auth header
|
|
authHeaderValue := d.createDigestAuthHeader(req, authHeader)
|
|
|
|
// Create new request with auth header
|
|
newReq := req.Clone(req.Context())
|
|
newReq.Header.Set("Authorization", authHeaderValue)
|
|
|
|
// Retry with auth
|
|
resp, err = d.transport.RoundTrip(newReq)
|
|
if err != nil {
|
|
return resp, fmt.Errorf("transport round trip with auth failed: %w", err)
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// createDigestAuthHeader creates a digest auth header from the challenge.
|
|
func (d *digestAuthTransport) createDigestAuthHeader(req *http.Request, authHeader string) string {
|
|
// Simple digest auth implementation - parse challenge and create response
|
|
// This is a basic implementation that handles most ONVIF cameras
|
|
|
|
// Extract digest parameters from WWW-Authenticate header
|
|
realm := extractParam(authHeader, "realm")
|
|
nonce := extractParam(authHeader, "nonce")
|
|
qop := extractParam(authHeader, "qop")
|
|
uri := req.URL.Path
|
|
if req.URL.RawQuery != "" {
|
|
uri += "?" + req.URL.RawQuery
|
|
}
|
|
|
|
// Generate response hash
|
|
ha1 := md5Hash(d.username + ":" + realm + ":" + d.password)
|
|
|
|
method := req.Method
|
|
ha2 := md5Hash(method + ":" + uri)
|
|
|
|
// Increment nonce count atomically to prevent race conditions
|
|
// HTTP transports must be safe for concurrent use
|
|
d.ncMu.Lock()
|
|
d.nc++
|
|
nc := d.nc
|
|
d.ncMu.Unlock()
|
|
ncStr := fmt.Sprintf("%08x", nc)
|
|
cnonce := generateNonce()
|
|
|
|
var responseStr string
|
|
if qop == "auth" {
|
|
responseStr = md5Hash(ha1 + ":" + nonce + ":" + ncStr + ":" + cnonce + ":auth:" + ha2)
|
|
} else {
|
|
responseStr = md5Hash(ha1 + ":" + nonce + ":" + ha2)
|
|
}
|
|
|
|
// Build Authorization header
|
|
authHeaderValue := fmt.Sprintf(`Digest username=%q, realm=%q, nonce=%q, uri=%q, response=%q`,
|
|
d.username, realm, nonce, uri, responseStr)
|
|
|
|
if qop == "auth" {
|
|
authHeaderValue += fmt.Sprintf(`, opaque=%q, qop=%s, nc=%s, cnonce=%q`,
|
|
extractParam(authHeader, "opaque"), qop, ncStr, cnonce)
|
|
}
|
|
|
|
return authHeaderValue
|
|
}
|
|
|
|
// Helper functions for digest auth.
|
|
func extractParam(authHeader, param string) string {
|
|
prefix := param + `="`
|
|
idx := strings.Index(authHeader, prefix)
|
|
if idx == -1 {
|
|
return ""
|
|
}
|
|
start := idx + len(prefix)
|
|
end := strings.Index(authHeader[start:], `"`)
|
|
if end == -1 {
|
|
return ""
|
|
}
|
|
|
|
return authHeader[start : start+end]
|
|
}
|
|
|
|
func md5Hash(s string) string {
|
|
return fmt.Sprintf("%x", md5sum(s))
|
|
}
|
|
|
|
func md5sum(s string) interface{} {
|
|
// Use crypto/md5 - import it if not already present
|
|
h := md5.New() //nolint:gosec // MD5 required for ONVIF digest auth
|
|
h.Write([]byte(s))
|
|
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// generateNonce generates a cryptographically secure random nonce for digest authentication.
|
|
func generateNonce() string {
|
|
bytes := make([]byte, NonceSize)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
// Fallback to time-based nonce if crypto/rand fails (shouldn't happen)
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
|
|
return hex.EncodeToString(bytes)
|
|
}
|