mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aecf54890 | ||
|
|
fe86ea942d | ||
|
|
42ac2bf892 | ||
|
|
3af23e5756 | ||
|
|
bc9bab3de0 | ||
|
|
3634cee483 | ||
|
|
513c0a8473 | ||
|
|
43bc40babd | ||
|
|
685b65c35e | ||
|
|
4c67d896e3 | ||
|
|
9a2e9ce9fe | ||
|
|
96918255a9 | ||
|
|
2fb619deda | ||
|
|
119a6511b1 | ||
|
|
0e0bc0ed0a | ||
|
|
3bd8516f94 | ||
|
|
16312b236d | ||
|
|
07b0f6c2e0 | ||
|
|
d7cfee56a1 | ||
|
|
c7ef445d6a | ||
|
|
d726ed8edb | ||
|
|
de3f049a63 | ||
|
|
a796a23058 | ||
|
|
cad23c996e | ||
|
|
70e6765a7d | ||
|
|
fcc3a90f9b | ||
|
|
badcc8fba2 | ||
|
|
a1fc7832ef | ||
|
|
1ceef725ec | ||
|
|
c6cad2c35d | ||
|
|
4fd92dd229 | ||
|
|
94572504fc | ||
|
|
fd71109514 | ||
|
|
6fc9b23e9f | ||
|
|
d718145bd3 | ||
|
|
93620f04a3 | ||
|
|
176e0d8f3c | ||
|
|
6465564f2a | ||
|
|
82f98cb824 | ||
|
|
9b5b626113 | ||
|
|
fb05c31d7d | ||
|
|
b461ec8ded | ||
|
|
da1ecf8e0a | ||
|
|
4da4842f61 | ||
|
|
f679aab0d5 | ||
|
|
2cc266714e | ||
|
|
ce67879ee5 | ||
|
|
174de6954b | ||
|
|
a732b9fa82 | ||
|
|
03101ec271 | ||
|
|
4fc5593195 | ||
|
|
9f92238275 | ||
|
|
a7815a692c | ||
|
|
d80b15daf0 | ||
|
|
9ca534b5bb | ||
|
|
ede8b81fc8 | ||
|
|
76463bb61a | ||
|
|
ca27d2a9d8 | ||
|
|
f3f03cc827 | ||
|
|
542f83433b | ||
|
|
6fc6d9a99e | ||
|
|
67386c9fec | ||
|
|
703c8c836e | ||
|
|
02115f9be9 | ||
|
|
ca935c7e96 | ||
|
|
fd737e6327 |
8
.devcontainer/Dockerfile
Normal file
8
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
|
||||
|
||||
# Install node environment
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
15
.devcontainer/devcontainer.json
Normal file
15
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,15 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "go:1.24.2-bookworm",
|
||||
"dockerFile": "Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"golang.go",
|
||||
"GitHub.vscode-pull-request-github"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
51
.github/workflows/pr-build.yml
vendored
Normal file
51
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Pull Request Build
|
||||
|
||||
# Builds, vets and tests the Go library on every pull request.
|
||||
# This repository is a Go library (no Dockerfile), so unlike the Docker-oriented
|
||||
# uug-ai/workflows pr-build.yml it validates the module directly instead of
|
||||
# building and pushing a container image.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Test (Go)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Vet (non-blocking)
|
||||
continue-on-error: true
|
||||
run: go vet ./...
|
||||
|
||||
- name: Gofmt check (non-blocking)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "The following files are not gofmt-clean:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test
|
||||
# ws-discovery/TestDevicesFromProbeResponses is a network-dependent
|
||||
# integration test that probes real ONVIF cameras on the LAN, so it
|
||||
# cannot pass on hosted runners. Run it locally against real devices.
|
||||
run: go test $(go list ./... | grep -v '/ws-discovery')
|
||||
19
.github/workflows/pr-description.yml
vendored
Normal file
19
.github/workflows/pr-description.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Autofill PR description
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
openai-pr-description:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Autofill PR description if empty using OpenAI
|
||||
uses: cedricve/azureopenai-pr-description@master
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
overwrite_description: true
|
||||
28
.github/workflows/release-bump.yml
vendored
Normal file
28
.github/workflows/release-bump.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Bump release
|
||||
|
||||
# Manually bump the semantic version: determines the next tag from the latest
|
||||
# v* tag, creates a GitHub release with generated notes and exposes the new tag.
|
||||
# Thin wrapper around the reusable uug-ai/workflows release-bump workflow.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump-release:
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
secrets: inherit
|
||||
36
.github/workflows/release.yml
vendored
Normal file
36
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Release
|
||||
|
||||
# Validates the tagged code when a release is published (build + test).
|
||||
# For a Go library a "release" is simply a git tag consumers depend on, so this
|
||||
# workflow guards that a released tag builds and passes its tests rather than
|
||||
# publishing a container image.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate release (Go)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
# See pr-build.yml: the ws-discovery probe test needs real cameras.
|
||||
run: go test $(go list ./... | grep -v '/ws-discovery')
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.idea
|
||||
*.iml
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
@@ -1,24 +1,6 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"extensions.ignoreRecommendations": true,
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.background": "#759570",
|
||||
"activityBar.activeBorder": "#cfd3ef",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.hoverBackground": "#352cea",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#cfd3ef",
|
||||
"activityBarBadge.foreground": "#15202b",
|
||||
"titleBar.activeBackground": "#5e7959",
|
||||
"titleBar.inactiveBackground": "#5e795999",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveForeground": "#e7e7e799",
|
||||
"statusBarItem.hoverBackground": "#352cea",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"panel.border": "#759570",
|
||||
"sideBar.border": "#759570",
|
||||
"editorGroup.border": "#759570"
|
||||
},
|
||||
"go.languageServerFlags": [],
|
||||
"go.lintOnSave": "file",
|
||||
"go.vetOnSave": "package",
|
||||
|
||||
109
Device.go
109
Device.go
@@ -280,17 +280,45 @@ func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Respo
|
||||
soap.AddRootNamespaces(Xlmns)
|
||||
soap.AddAction()
|
||||
|
||||
//Auth Handling
|
||||
if dev.params.Username != "" && dev.params.Password != "" {
|
||||
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
||||
}
|
||||
return dev.sendSOAP(endpoint, soap)
|
||||
}
|
||||
|
||||
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
||||
if err != nil {
|
||||
servResp, err = networking.SendSoapWithDigest(new(http.Client), endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
||||
}
|
||||
// sendSOAP dispatches an assembled SOAP message to the endpoint using the
|
||||
// authentication mechanism selected through DeviceParams.AuthMode.
|
||||
//
|
||||
// Behaviour per AuthMode:
|
||||
// - NoAuth ("none"): no authentication is added.
|
||||
// - UsernameTokenAuth: WS-Security UsernameToken only; never falls
|
||||
// back to HTTP digest, which keeps cameras that authenticate exclusively
|
||||
// through WS-Security working.
|
||||
// - DigestAuth ("digest"): HTTP digest only; no WS-Security header.
|
||||
// - Both ("both") / unset (""): WS-Security credentials are added (when
|
||||
// available) and HTTP digest is attempted only if the device answers with
|
||||
// an authentication challenge (HTTP 401 Unauthorized). On that digest
|
||||
// retry the WS-Security header is dropped so the credentials are not sent
|
||||
// twice.
|
||||
func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Response, error) {
|
||||
hasCredentials := dev.params.Username != "" || dev.params.Password != ""
|
||||
|
||||
return servResp, err
|
||||
switch dev.params.AuthMode {
|
||||
case NoAuth:
|
||||
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
||||
|
||||
case UsernameTokenAuth:
|
||||
if hasCredentials {
|
||||
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
||||
}
|
||||
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
||||
|
||||
case DigestAuth:
|
||||
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
||||
|
||||
default: // Both and the empty/unset default.
|
||||
if hasCredentials {
|
||||
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
||||
}
|
||||
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func (dev *Device) GetDeviceParams() DeviceParams {
|
||||
@@ -329,24 +357,75 @@ func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string
|
||||
return resp, err
|
||||
}*/
|
||||
|
||||
// CallMethod functions call an method, defined <method> struct with authentication data
|
||||
// SendSoap POSTs the given body wrapped in a SOAP envelope.
|
||||
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
|
||||
return dev.SendSoapWithOptions(endpoint, xmlRequestBody)
|
||||
}
|
||||
|
||||
// SendSoapWithHeader is SendSoap plus arbitrary inner-Header XML —
|
||||
// needed to echo WS-Addressing ReferenceParameters (with
|
||||
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
|
||||
// identify pull-point subscriptions through them rather than the URL.
|
||||
//
|
||||
// xmlHeaderContent must be well-formed XML representing one or more
|
||||
// SOAP Header child elements (siblings are supported; the spec lets
|
||||
// each reference parameter be its own header block). Malformed or
|
||||
// element-free content errors before any request is made.
|
||||
//
|
||||
// SECURITY: do not pass content sourced from untrusted clients. The
|
||||
// API assumes the caller is authoritative for the envelope. Header
|
||||
// content forwards verbatim — among others, <wsse:Security> overrides
|
||||
// auth, <wsa:Action> overrides intent, <wsa:To>/<wsa:ReplyTo>/
|
||||
// <wsa:FaultTo> redirect responses, <wsa:MessageID> enables replay-
|
||||
// token forgery, and <wsu:Timestamp> bypasses freshness checks.
|
||||
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
|
||||
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent))
|
||||
}
|
||||
|
||||
// SendSoapOption tweaks a single SendSoapWithOptions call. New options
|
||||
// (per-call timeout, context, custom envelope namespaces, ...) should
|
||||
// be added as WithX constructors here rather than as new method
|
||||
// variants on Device.
|
||||
type SendSoapOption func(*soapConfig)
|
||||
|
||||
type soapConfig struct {
|
||||
headerContent string
|
||||
}
|
||||
|
||||
// WithSOAPHeader adds inner-Header XML to the envelope. See
|
||||
// SendSoapWithHeader for the content contract.
|
||||
func WithSOAPHeader(headerContent string) SendSoapOption {
|
||||
return func(c *soapConfig) { c.headerContent = headerContent }
|
||||
}
|
||||
|
||||
// SendSoapWithOptions is the workhorse behind SendSoap and
|
||||
// SendSoapWithHeader; call it directly when you need to combine
|
||||
// options or pass options not surfaced by the convenience wrappers.
|
||||
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SendSoapOption) (*http.Response, error) {
|
||||
var cfg soapConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
soap := gosoap.NewEmptySOAP()
|
||||
soap.AddStringBodyContent(xmlRequestBody)
|
||||
soap.AddRootNamespaces(Xlmns)
|
||||
soap.AddAction()
|
||||
|
||||
//Auth Handling
|
||||
if cfg.headerContent != "" {
|
||||
if err := soap.AddStringHeaderContents(cfg.headerContent); err != nil {
|
||||
return nil, fmt.Errorf("add header content: %w", err)
|
||||
}
|
||||
}
|
||||
if dev.params.Username != "" && dev.params.Password != "" {
|
||||
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
||||
}
|
||||
|
||||
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
||||
if err != nil {
|
||||
servResp, err = networking.SendSoapWithDigest(new(http.Client), endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
||||
if servResp != nil {
|
||||
servResp.Body.Close()
|
||||
}
|
||||
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
||||
}
|
||||
|
||||
return servResp, err
|
||||
}
|
||||
|
||||
@@ -380,7 +459,7 @@ func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []by
|
||||
}
|
||||
xmlRequestBody := string(requestBody)
|
||||
|
||||
servResp, err := dev.SendSoap(endpoint, xmlRequestBody)
|
||||
servResp, err := dev.SendSoapWithOptions(endpoint, xmlRequestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
||||
}
|
||||
|
||||
244
Device_test.go
244
Device_test.go
@@ -1,9 +1,14 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDevice_SetDeviceInfoFromScopes(t *testing.T) {
|
||||
@@ -22,3 +27,242 @@ func TestDevice_SetDeviceInfoFromScopes(t *testing.T) {
|
||||
assert.Equal(t, device.info.Name, name)
|
||||
assert.Equal(t, device.info.Model, hardware)
|
||||
}
|
||||
|
||||
// TestDevice_SendSoapWithHeader_InjectsHeaderXML verifies that the
|
||||
// supplied header XML lands inside the SOAP <Header> element of the
|
||||
// outgoing request. AXIS-style WS-Addressing reference parameter
|
||||
// echoing depends on this — without it the camera returns
|
||||
// ter:InvalidArgs on every PullMessages.
|
||||
func TestDevice_SendSoapWithHeader_InjectsHeaderXML(t *testing.T) {
|
||||
const headerXML = `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event" wsa:IsReferenceParameter="true">297</dom0:SubscriptionId>`
|
||||
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"><tev:Timeout>PT5S</tev:Timeout><tev:MessageLimit>32</tev:MessageLimit></tev:PullMessages>`
|
||||
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{
|
||||
params: DeviceParams{
|
||||
Xaddr: strings.TrimPrefix(srv.URL, "http://"),
|
||||
HttpClient: srv.Client(),
|
||||
},
|
||||
}
|
||||
resp, err := dev.SendSoapWithHeader(srv.URL, bodyXML, headerXML)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
headerStart := strings.Index(captured, "Header>")
|
||||
bodyStart := strings.Index(captured, "Body>")
|
||||
require.NotEqual(t, -1, headerStart, "envelope must contain <Header>; got: %s", captured)
|
||||
require.Greater(t, bodyStart, headerStart, "Body must follow Header in the envelope")
|
||||
|
||||
headerSlice := captured[headerStart:bodyStart]
|
||||
assert.Contains(t, headerSlice, "SubscriptionId",
|
||||
"injected header element must land inside SOAP <Header>")
|
||||
assert.Contains(t, headerSlice, "297")
|
||||
|
||||
bodySlice := captured[bodyStart:]
|
||||
assert.Contains(t, bodySlice, "PullMessages",
|
||||
"body content must land inside SOAP <Body>")
|
||||
}
|
||||
|
||||
// Per WS-Addressing 1.0 SOAP Binding §3.4 every reference parameter is a separate
|
||||
// SOAP Header block. Vendors that declare two ref params would silently
|
||||
// produce a header-less request if the implementation only accepts a
|
||||
// single top-level element.
|
||||
func TestDevice_SendSoapWithHeader_AcceptsMultipleTopLevelChildren(t *testing.T) {
|
||||
const headerXML = `<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>`
|
||||
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`
|
||||
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{
|
||||
Xaddr: strings.TrimPrefix(srv.URL, "http://"),
|
||||
HttpClient: srv.Client(),
|
||||
}}
|
||||
resp, err := dev.SendSoapWithHeader(srv.URL, bodyXML, headerXML)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
headerSlice := captured[strings.Index(captured, "Header>"):strings.Index(captured, "Body>")]
|
||||
assert.Contains(t, headerSlice, "Foo")
|
||||
assert.Contains(t, headerSlice, "Bar")
|
||||
}
|
||||
|
||||
func TestDevice_SendSoapWithHeader_RejectsElementFreeContent(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
|
||||
// Well-formed XML but contains no child elements — would otherwise
|
||||
// parse, yield zero ChildElements, and send a header-less request.
|
||||
_, err := dev.SendSoapWithHeader(srv.URL, "<body/>", "just text content")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, hits,
|
||||
"non-empty header content with no element children must fail fast")
|
||||
}
|
||||
|
||||
// SendSoapWithOptions is the variadic shape that future per-call
|
||||
// options (timeout, context, ...) will hang off. SendSoap and
|
||||
// SendSoapWithHeader stay as thin convenience wrappers so existing
|
||||
// callers are not forced to migrate.
|
||||
func TestDevice_SendSoapWithOptions_WithHeaderMatchesSendSoapWithHeader(t *testing.T) {
|
||||
const headerXML = `<dom0:SubscriptionId xmlns:dom0="urn:test">42</dom0:SubscriptionId>`
|
||||
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`
|
||||
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, bodyXML, WithSOAPHeader(headerXML))
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
assert.Contains(t, captured, "SubscriptionId")
|
||||
assert.Contains(t, captured, "42")
|
||||
}
|
||||
|
||||
func TestDevice_SendSoapWithOptions_NoOptsMatchesSendSoap(t *testing.T) {
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, `<tev:Body xmlns:tev="x"/>`)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
assert.NotContains(t, captured, "IsReferenceParameter",
|
||||
"no opts should produce a header-less envelope")
|
||||
}
|
||||
|
||||
// Digest auth fallback path: the camera 401s the first POST and the
|
||||
// retry computes a digest. The ref-params header must survive the
|
||||
// retry — losing it would silently re-introduce the AXIS regression
|
||||
// on every authenticated camera.
|
||||
func TestDevice_SendSoapWithHeader_PreservesHeaderAcrossDigestRetry(t *testing.T) {
|
||||
const headerXML = `<dom0:SubscriptionId xmlns:dom0="urn:vendor:axis" wsa:IsReferenceParameter="true">297</dom0:SubscriptionId>`
|
||||
var capturedSecondBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
capturedSecondBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{
|
||||
Xaddr: strings.TrimPrefix(srv.URL, "http://"),
|
||||
HttpClient: srv.Client(),
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
}}
|
||||
resp, err := dev.SendSoapWithHeader(srv.URL, `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`, headerXML)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
assert.Contains(t, capturedSecondBody, "SubscriptionId",
|
||||
"digest retry must carry the same ref-params header as the first attempt")
|
||||
assert.Contains(t, capturedSecondBody, "297")
|
||||
}
|
||||
|
||||
func TestDevice_SendSoapWithHeader_PropagatesMalformedHeaderError(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
|
||||
_, err := dev.SendSoapWithHeader(srv.URL, "<body/>", "<not-closed")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 0, hits,
|
||||
"malformed header XML must fail fast — no request should reach the camera with a missing header block")
|
||||
}
|
||||
|
||||
// Digest retry: networking.SendSoapWithDigest strips the wsse:Security
|
||||
// element from the envelope before re-POSTing so credentials don't go
|
||||
// on the wire twice (once via WS-Security, once via the digest header).
|
||||
// Pin the behaviour from the Device layer.
|
||||
func TestDevice_SendSoapWithOptions_DigestRetryStripsWSSE(t *testing.T) {
|
||||
var authedBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
authedBody = string(b)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{
|
||||
HttpClient: srv.Client(),
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
}}
|
||||
resp, err := dev.SendSoapWithOptions(srv.URL, "<tev:X xmlns:tev='x'/>")
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
require.NotEmpty(t, authedBody, "expected an authenticated POST after the 401 challenge")
|
||||
assert.NotContains(t, authedBody, "UsernameToken",
|
||||
"digest retry must strip wsse:Security/UsernameToken; otherwise credentials go on the wire twice")
|
||||
}
|
||||
|
||||
// Last-write-wins on duplicate SendSoapOption — pin the behaviour so
|
||||
// the next maintainer adding an option doesn't accidentally introduce
|
||||
// a merge or first-wins semantic.
|
||||
func TestDevice_SendSoapWithOptions_DuplicateWithSOAPHeaderLastWins(t *testing.T) {
|
||||
var captured string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
captured = string(b)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
|
||||
_, err := dev.SendSoapWithOptions(srv.URL, "<body/>",
|
||||
WithSOAPHeader(`<a:First xmlns:a="x"/>`),
|
||||
WithSOAPHeader(`<b:Second xmlns:b="y"/>`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, captured, "First", "first WithSOAPHeader must be overwritten")
|
||||
assert.Contains(t, captured, "Second")
|
||||
}
|
||||
|
||||
@@ -32,3 +32,23 @@ python3 python/gen_commands.py
|
||||
|
||||
> **Note:** You can also typically run the generator within your IDE thanks to the `//go:generate` lines
|
||||
> towards the top of the `types.go` files.
|
||||
|
||||
## Higher-level helpers
|
||||
|
||||
Some web service directories ship hand-written, higher-level helpers
|
||||
built on top of the wire-layer commands. These are normal Go packages
|
||||
— **not** covered by the `gen_commands.py` workflow above and not
|
||||
expected to be regenerated.
|
||||
|
||||
- [event/stream](../event/stream) — channel-based event consumer that
|
||||
owns the pull-point subscription lifecycle (Create, Pull, Renew,
|
||||
Unsubscribe, reconnect with jittered backoff) and decodes
|
||||
notifications into normalized typed Events. Vendor topic strings
|
||||
(AXIS, Hikvision, Avigilon, Hanwha, Bosch, Dahua) are classified
|
||||
into a small set of `Kind` values. See the package `doc.go` for the
|
||||
public surface and usage.
|
||||
- [event/topic](../event/topic) — topic identifier helpers.
|
||||
|
||||
When adding a similar higher-level helper, place it under the relevant
|
||||
web service directory as a sub-package so consumers find it next to
|
||||
the wire-layer types it builds on.
|
||||
|
||||
96
event/stream/decode.go
Normal file
96
event/stream/decode.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif/event"
|
||||
)
|
||||
|
||||
// decode converts a single ONVIF NotificationMessage into a normalized
|
||||
// Event. Topic, Source and Data are always populated even when Kind is
|
||||
// KindUnknown so consumers can fall back to the wire form.
|
||||
func decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event {
|
||||
topic := string(msg.Topic.TopicKinds)
|
||||
desc := msg.Message.Message
|
||||
return Event{
|
||||
Kind: Classify(topic),
|
||||
State: extractState(desc.Data.SimpleItem),
|
||||
Operation: parsePropertyOperation(string(desc.PropertyOperation)),
|
||||
DeviceID: deviceID,
|
||||
Source: simpleItemsToMap(desc.Source.SimpleItem),
|
||||
Data: simpleItemsToMap(desc.Data.SimpleItem),
|
||||
Topic: topic,
|
||||
Timestamp: observedAt,
|
||||
DeviceTime: parseDeviceTime(string(desc.UtcTime)),
|
||||
}
|
||||
}
|
||||
|
||||
// simpleItemsToMap returns nil for an empty list so empty notifications
|
||||
// do not allocate.
|
||||
func simpleItemsToMap(items []event.SimpleItem) map[string]string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(items))
|
||||
for _, it := range items {
|
||||
m[string(it.Name)] = string(it.Value)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// extractState scans Data items for a boolean-like value, returning the
|
||||
// first match. Returns StateUnknown for edge-triggered topics like
|
||||
// LineDetector/Crossed whose Data carries only an ObjectId.
|
||||
func extractState(items []event.SimpleItem) State {
|
||||
for _, it := range items {
|
||||
switch strings.ToLower(strings.TrimSpace(string(it.Value))) {
|
||||
case "true", "1", "active":
|
||||
return StateActive
|
||||
case "false", "0", "inactive":
|
||||
return StateInactive
|
||||
}
|
||||
}
|
||||
return StateUnknown
|
||||
}
|
||||
|
||||
// parsePropertyOperation returns PropertyUnknown for absent (optional
|
||||
// per WS-Notification) or unrecognised values.
|
||||
func parsePropertyOperation(s string) PropertyOperation {
|
||||
switch s {
|
||||
case "Initialized":
|
||||
return PropertyInitialized
|
||||
case "Changed":
|
||||
return PropertyChanged
|
||||
case "Deleted":
|
||||
return PropertyDeleted
|
||||
default:
|
||||
return PropertyUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// parseDeviceTime parses wsnt:UtcTime, returning the zero time when
|
||||
// absent or unparseable. Real cameras emit several flavours: with /
|
||||
// without sub-seconds, colon or compact ("+0200") offsets, and some
|
||||
// older Hikvision firmwares omit the timezone entirely (treated as
|
||||
// UTC per WS-BaseNotification which mandates UTC for UtcTime).
|
||||
func parseDeviceTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range deviceTimeLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
var deviceTimeLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999-0700", // Geovision
|
||||
"2006-01-02T15:04:05-0700", // some Dahua
|
||||
"2006-01-02T15:04:05.999",
|
||||
"2006-01-02T15:04:05", // older Hikvision (no timezone)
|
||||
}
|
||||
350
event/stream/decode_test.go
Normal file
350
event/stream/decode_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif/event"
|
||||
"github.com/kerberos-io/onvif/xsd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// msg builds a NotificationMessage from the topic and a (PropertyOperation,
|
||||
// UtcTime, source items, data items) tuple so tests stay short and intent
|
||||
// is visible at the call site.
|
||||
func msg(topic, propOp, utcTime string, source, data map[string]string) event.NotificationMessage {
|
||||
toItems := func(m map[string]string) []event.SimpleItem {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]event.SimpleItem, 0, len(m))
|
||||
for k, v := range m {
|
||||
items = append(items, event.SimpleItem{
|
||||
Name: xsd.AnyType(k),
|
||||
Value: xsd.AnyType(v),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
return event.NotificationMessage{
|
||||
Topic: event.Topic{TopicKinds: xsd.String(topic)},
|
||||
Message: event.MessageBody{
|
||||
Message: event.MessageDescription{
|
||||
PropertyOperation: xsd.AnyType(propOp),
|
||||
UtcTime: xsd.AnyType(utcTime),
|
||||
Source: event.Source{SimpleItem: toItems(source)},
|
||||
Data: event.Data{SimpleItem: toItems(data)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_MotionActive(t *testing.T) {
|
||||
observedAt := time.Date(2026, 5, 21, 10, 30, 1, 0, time.UTC)
|
||||
in := msg(
|
||||
"tns1:RuleEngine/CellMotionDetector/Motion",
|
||||
"Changed",
|
||||
"2026-05-21T10:30:00Z",
|
||||
map[string]string{
|
||||
"VideoSourceConfigurationToken": "VideoSourceConfigToken0",
|
||||
"Rule": "MyMotionRule",
|
||||
},
|
||||
map[string]string{"IsMotion": "true"},
|
||||
)
|
||||
|
||||
ev := decode(in, "axis-cam-01", observedAt)
|
||||
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
assert.Equal(t, StateActive, ev.State)
|
||||
assert.Equal(t, PropertyChanged, ev.Operation)
|
||||
assert.Equal(t, "axis-cam-01", ev.DeviceID)
|
||||
assert.Equal(t, "VideoSourceConfigToken0", ev.Source["VideoSourceConfigurationToken"])
|
||||
assert.Equal(t, "MyMotionRule", ev.Source["Rule"])
|
||||
assert.Equal(t, "true", ev.Data["IsMotion"])
|
||||
assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic)
|
||||
assert.True(t, ev.Timestamp.Equal(observedAt))
|
||||
assert.Equal(t, time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC), ev.DeviceTime)
|
||||
}
|
||||
|
||||
func TestDecode_MotionInactive(t *testing.T) {
|
||||
in := msg(
|
||||
"tns1:VideoSource/MotionAlarm",
|
||||
"Changed",
|
||||
"",
|
||||
nil,
|
||||
map[string]string{"State": "false"},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
assert.Equal(t, StateInactive, ev.State)
|
||||
}
|
||||
|
||||
func TestDecode_HanwhaNumericMotionValue(t *testing.T) {
|
||||
// Hanwha emits xsd:string values "0"/"1" instead of xsd:boolean.
|
||||
in := msg(
|
||||
"tns1:VideoAnalytics/tnssamsung:MotionDetection",
|
||||
"Changed",
|
||||
"",
|
||||
nil,
|
||||
map[string]string{"Motion": "1"},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
assert.Equal(t, StateActive, ev.State)
|
||||
}
|
||||
|
||||
func TestDecode_AvigilonActiveLiteral(t *testing.T) {
|
||||
// Avigilon and a handful of older firmwares emit "active"/"inactive"
|
||||
// as the Data value rather than a boolean.
|
||||
in := msg(
|
||||
"tns1:Device/tns1:Trigger/tns1:Relay",
|
||||
"Changed",
|
||||
"",
|
||||
map[string]string{"RelayToken": "Relay-1"},
|
||||
map[string]string{"LogicalState": "active"},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindDigitalOutput, ev.Kind)
|
||||
assert.Equal(t, StateActive, ev.State)
|
||||
assert.Equal(t, "Relay-1", ev.Source["RelayToken"])
|
||||
}
|
||||
|
||||
func TestDecode_AxisObjectAnalyticsMultiItem(t *testing.T) {
|
||||
// AOA emits active + classType + confidence in the same Data list.
|
||||
// The decoder must preserve every item; State picks the first
|
||||
// boolean-like value, which is 'active'.
|
||||
in := msg(
|
||||
"tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1",
|
||||
"Changed",
|
||||
"",
|
||||
map[string]string{"Source": "device1Scene1"},
|
||||
map[string]string{
|
||||
"active": "1",
|
||||
"classType": "Human",
|
||||
"confidence": "92",
|
||||
},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindObjectDetected, ev.Kind)
|
||||
assert.Equal(t, StateActive, ev.State)
|
||||
assert.Equal(t, "Human", ev.Data["classType"])
|
||||
assert.Equal(t, "92", ev.Data["confidence"])
|
||||
assert.Equal(t, "1", ev.Data["active"])
|
||||
}
|
||||
|
||||
func TestDecode_LineDetectorCrossedHasNoState(t *testing.T) {
|
||||
// Edge-triggered topic — Data carries ObjectId, not a boolean. State
|
||||
// must remain Unknown so consumers do not misread it as level-Active.
|
||||
in := msg(
|
||||
"tns1:RuleEngine/LineDetector/Crossed",
|
||||
"Changed",
|
||||
"",
|
||||
map[string]string{"VideoSourceConfigurationToken": "vsct0", "Rule": "LineRule"},
|
||||
map[string]string{"ObjectId": "42"},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindObjectDetected, ev.Kind)
|
||||
assert.Equal(t, StateUnknown, ev.State)
|
||||
assert.Equal(t, "42", ev.Data["ObjectId"])
|
||||
}
|
||||
|
||||
func TestDecode_UnknownTopicStillPreservesWireData(t *testing.T) {
|
||||
// Kind unknown does not mean discard: consumers may want to log or
|
||||
// route on the raw topic when classification misses.
|
||||
in := msg(
|
||||
"tns1:UserAlarm/IVA",
|
||||
"",
|
||||
"",
|
||||
nil,
|
||||
map[string]string{"Custom": "true"},
|
||||
)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, KindUnknown, ev.Kind)
|
||||
assert.Equal(t, "tns1:UserAlarm/IVA", ev.Topic)
|
||||
assert.Equal(t, "true", ev.Data["Custom"])
|
||||
}
|
||||
|
||||
func TestDecode_PropertyOperationVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want PropertyOperation
|
||||
}{
|
||||
{"initialized", "Initialized", PropertyInitialized},
|
||||
{"changed", "Changed", PropertyChanged},
|
||||
{"deleted", "Deleted", PropertyDeleted},
|
||||
{"absent", "", PropertyUnknown},
|
||||
{"unrecognised", "Bogus", PropertyUnknown},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", tc.in, "", nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, tc.want, ev.Operation)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_DeviceTimeParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Time
|
||||
}{
|
||||
{"rfc3339_utc", "2026-05-21T10:30:00Z", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
|
||||
{"rfc3339_with_offset", "2026-05-21T12:30:00+02:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
|
||||
{"rfc3339_subsecond", "2026-05-21T10:30:00.500Z", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)},
|
||||
{"absent", "", time.Time{}},
|
||||
{"unparseable", "not-a-date", time.Time{}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
if tc.want.IsZero() {
|
||||
assert.True(t, ev.DeviceTime.IsZero(), "DeviceTime=%v", ev.DeviceTime)
|
||||
} else {
|
||||
assert.True(t, ev.DeviceTime.Equal(tc.want), "got=%v want=%v", ev.DeviceTime, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_EmptySourceAndDataYieldNilMaps(t *testing.T) {
|
||||
// Matches the zero-value contract in types_test.go: callers can
|
||||
// safely len() and index into Source/Data without nil-checking, but
|
||||
// we do not allocate an empty map for empty notifications.
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Nil(t, ev.Source)
|
||||
assert.Nil(t, ev.Data)
|
||||
}
|
||||
|
||||
func TestDecode_StateValueIsCaseInsensitive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want State
|
||||
}{
|
||||
{"true_lower", "true", StateActive},
|
||||
{"true_upper", "TRUE", StateActive},
|
||||
{"true_mixed", "True", StateActive},
|
||||
{"false_lower", "false", StateInactive},
|
||||
{"false_mixed", "False", StateInactive},
|
||||
{"active_mixed", "Active", StateActive},
|
||||
{"inactive_mixed", "Inactive", StateInactive},
|
||||
{"one", "1", StateActive},
|
||||
{"zero", "0", StateInactive},
|
||||
{"empty", "", StateUnknown},
|
||||
{"nonsense", "maybe", StateUnknown},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
|
||||
nil, map[string]string{"State": tc.value})
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, tc.want, ev.State)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edge cases for state extraction and time parsing ----------------
|
||||
|
||||
func TestDecode_PropertyOperationIsCaseSensitive(t *testing.T) {
|
||||
// Per WS-Notification §3.3 PropertyOperation values are
|
||||
// 'Initialized' / 'Changed' / 'Deleted'. Lowercased forms are
|
||||
// malformed and should fall through to PropertyUnknown.
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "changed", "", nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, PropertyUnknown, ev.Operation)
|
||||
}
|
||||
|
||||
func TestDecode_StateValueTrimsWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want State
|
||||
}{
|
||||
{"leading_trailing", " true ", StateActive},
|
||||
{"tab_newline", "\ttrue\n", StateActive},
|
||||
{"only_spaces", " ", StateUnknown},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
|
||||
nil, map[string]string{"State": tc.value})
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, tc.want, ev.State)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_SimpleItemEmptyValueIsUnknownState(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
|
||||
nil, map[string]string{"State": ""})
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, StateUnknown, ev.State)
|
||||
v, ok := ev.Data["State"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "", v)
|
||||
}
|
||||
|
||||
func TestDecode_DeviceTimeAdditionalLayouts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Time
|
||||
}{
|
||||
{"compact_offset", "2026-05-21T12:30:00+0200", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
|
||||
{"compact_offset_subsec", "2026-05-21T12:30:00.500+0200", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)},
|
||||
{"naked_no_tz", "2026-05-21T10:30:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.True(t, ev.DeviceTime.Equal(tc.want),
|
||||
"input=%q got=%v want=%v", tc.in, ev.DeviceTime, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecode_DeviceTimeStillRejectsNonsense(t *testing.T) {
|
||||
for _, s := range []string{"hello", "2026-13-45T99:99:99", strings.Repeat("9", 50)} {
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", s, nil, nil)
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.True(t, ev.DeviceTime.IsZero(), "input=%q should yield zero, got %v", s, ev.DeviceTime)
|
||||
}
|
||||
}
|
||||
|
||||
// --- First-boolean-wins with explicit slice order --------------------
|
||||
|
||||
type pair struct{ k, v string }
|
||||
|
||||
func simpleItemsFromPairs(pairs []pair) []event.SimpleItem {
|
||||
out := make([]event.SimpleItem, len(pairs))
|
||||
for i, p := range pairs {
|
||||
out[i] = event.SimpleItem{
|
||||
Name: xsd.AnyType(p.k),
|
||||
Value: xsd.AnyType(p.v),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExtractState_FirstBooleanLikeWins(t *testing.T) {
|
||||
// Documented behaviour: when multiple Data items have boolean-like
|
||||
// values, the first by slice order wins. Use explicit slice
|
||||
// construction so the assertion does not depend on map iteration
|
||||
// order.
|
||||
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil)
|
||||
in.Message.Message.Data.SimpleItem = simpleItemsFromPairs([]pair{
|
||||
{"ObjectId", "42"},
|
||||
{"State", "true"},
|
||||
{"Trailer", "false"},
|
||||
})
|
||||
ev := decode(in, "dev", time.Now())
|
||||
assert.Equal(t, StateActive, ev.State,
|
||||
"first boolean-like value (State=true) must win, not Trailer=false")
|
||||
}
|
||||
29
event/stream/doc.go
Normal file
29
event/stream/doc.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package stream is a typed, channel-based consumer for ONVIF device
|
||||
// events. It hides the SOAP/XML, pull-point subscription lifecycle,
|
||||
// subscription renewal and vendor-specific topic conventions behind a
|
||||
// single Event stream.
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// dev, _ := onvif.NewDevice(onvif.DeviceParams{Xaddr: "...", Username: "...", Password: "..."})
|
||||
// s, err := stream.NewStream(ctx, dev, stream.Options{DeviceID: "front-door"})
|
||||
// if err != nil { /* construction failed: auth, network, or no event support */ }
|
||||
// defer s.Close()
|
||||
//
|
||||
// for ev := range s.Events() {
|
||||
// switch ev.Kind {
|
||||
// case stream.KindMotion:
|
||||
// if ev.State == stream.StateActive { /* start recording */ }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// NewStream performs network I/O so auth and reachability failures
|
||||
// surface synchronously, and rejects a client timeout that cannot
|
||||
// outlast PullTimeout with ErrInvalidOptions. Events and Errors close when the Stream stops;
|
||||
// Errors sends are non-blocking so a stalled consumer drops older
|
||||
// errors rather than blocking the pull loop. After a silent reconnect,
|
||||
// the next batch's events carry Event.AfterReconnect=true.
|
||||
//
|
||||
// See topics.go for the verified topic→Kind mapping across AXIS,
|
||||
// Hikvision, Avigilon, Hanwha, Bosch and Dahua.
|
||||
package stream
|
||||
21
event/stream/helpers_test.go
Normal file
21
event/stream/helpers_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// waitFor polls cond at 10ms intervals up to d. Fails the test with msg
|
||||
// if cond never returns true. Centralises the pattern that appears in
|
||||
// renew/reconnect/stream tests so retries are uniform.
|
||||
func waitFor(t *testing.T, d time.Duration, msg string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("waitFor timed out after %s: %s", d, msg)
|
||||
}
|
||||
14
event/stream/main_test.go
Normal file
14
event/stream/main_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
// Catches any pull, renew, or recreate goroutine that outlives its
|
||||
// Stream — a regression class that's silent in production until
|
||||
// goroutine count drifts up and triggers OOM.
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
51
event/stream/pulltimeout.go
Normal file
51
event/stream/pulltimeout.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
)
|
||||
|
||||
// ErrInvalidOptions marks a configuration that cannot succeed. Callers
|
||||
// retry the pull/renew/recreate errors; retrying this one never helps,
|
||||
// so it is a distinct sentinel they can short-circuit on.
|
||||
var ErrInvalidOptions = errors.New("stream: invalid options")
|
||||
|
||||
// minClientHeadroom is how far http.Client.Timeout must exceed
|
||||
// PullTimeout. The client ceiling covers dial, TLS and the response
|
||||
// transfer on top of the poll it has to outlast, and starts before the
|
||||
// camera has parsed the request; on a cellular bearer that overhead
|
||||
// runs to hundreds of milliseconds.
|
||||
const minClientHeadroom = 5 * time.Second
|
||||
|
||||
// validateClientTimeout rejects a client ceiling that cannot outlast
|
||||
// the PullMessages long-poll plus minClientHeadroom.
|
||||
//
|
||||
// Zero means unbounded and is accepted: it is the SDK's default when a
|
||||
// caller passes no client, so rejecting it would break every default
|
||||
// consumer. Note it is not risk-free — the caller interface documents
|
||||
// that ctx cannot interrupt an in-flight SOAP call, so only the client
|
||||
// timeout can unwedge a stalled camera.
|
||||
func validateClientTimeout(clientTimeout, pullTimeout time.Duration) error {
|
||||
if clientTimeout == 0 || clientTimeout >= pullTimeout+minClientHeadroom {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%w: http.Client.Timeout (%s) must exceed PullTimeout (%s) by at least %s; PullMessages is a long-poll and the client would abort every quiet pull",
|
||||
ErrInvalidOptions, clientTimeout, pullTimeout, minClientHeadroom)
|
||||
}
|
||||
|
||||
// clientTimeoutOf reports the device's HTTP client ceiling, or 0 when
|
||||
// the SDK is using its own default (unbounded) client.
|
||||
func clientTimeoutOf(dev *onvif.Device) time.Duration {
|
||||
if dev == nil {
|
||||
return 0
|
||||
}
|
||||
c := dev.GetDeviceParams().HttpClient
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.Timeout
|
||||
}
|
||||
68
event/stream/pulltimeout_test.go
Normal file
68
event/stream/pulltimeout_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestValidateClientTimeout — PullMessages is a long-poll: the camera
|
||||
// holds the connection open for PullTimeout waiting for an event. An
|
||||
// http.Client.Timeout covers the whole exchange and starts before the
|
||||
// camera has parsed the request, so a client ceiling at or below
|
||||
// PullTimeout loses the race on every quiet interval and the pull can
|
||||
// only ever fail. This shipped once (both were 5s) and presented as a
|
||||
// slow camera rather than a misconfiguration.
|
||||
//
|
||||
// Strict inequality is not enough: the client also has to cover dial,
|
||||
// TLS and the response transfer, which on a cellular bearer runs to
|
||||
// hundreds of milliseconds. Hence a real headroom floor.
|
||||
func TestValidateClientTimeout(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
client time.Duration
|
||||
pull time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"unbounded client is the caller's risk, not an error", 0, 30 * time.Second, false},
|
||||
{"comfortable headroom", 40 * time.Second, 30 * time.Second, false},
|
||||
{"exactly the minimum headroom", 30*time.Second + minClientHeadroom, 30 * time.Second, false},
|
||||
{"a hair under the minimum headroom", 30*time.Second + minClientHeadroom - time.Millisecond, 30 * time.Second, true},
|
||||
{"strictly greater but no headroom", 30*time.Second + time.Millisecond, 30 * time.Second, true},
|
||||
{"equal timeouts always lose", 5 * time.Second, 5 * time.Second, true},
|
||||
{"client below pull", 4 * time.Second, 30 * time.Second, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateClientTimeout(tt.client, tt.pull)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, "client=%s pull=%s must be rejected", tt.client, tt.pull)
|
||||
assert.ErrorIs(t, err, ErrInvalidOptions,
|
||||
"callers need a sentinel to tell a permanent misconfiguration from a transient failure")
|
||||
assert.Contains(t, err.Error(), "PullTimeout",
|
||||
"the error must name the option the caller has to change")
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err, "client=%s pull=%s must be accepted", tt.client, tt.pull)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrInvalidOptions_IsDistinctFromStreamErrors — the pull/renew/
|
||||
// recreate errors are transient and callers retry them. A bad Options
|
||||
// never becomes valid by retrying, so it must not be mistaken for one.
|
||||
func TestErrInvalidOptions_IsDistinctFromStreamErrors(t *testing.T) {
|
||||
err := validateClientTimeout(5*time.Second, 5*time.Second)
|
||||
require.Error(t, err)
|
||||
|
||||
var pull ErrPullFailed
|
||||
var renew ErrRenewFailed
|
||||
var recreate ErrRecreateFailed
|
||||
assert.False(t, errors.As(err, &pull))
|
||||
assert.False(t, errors.As(err, &renew))
|
||||
assert.False(t, errors.As(err, &recreate))
|
||||
}
|
||||
108
event/stream/reconnect.go
Normal file
108
event/stream/reconnect.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxRecreateBackoff caps exponential backoff between recreate
|
||||
// attempts. Sized for fleet deployments: at 30s a 1000-camera setup
|
||||
// recovering from a switch reboot would generate sustained
|
||||
// reconnect traffic; 5 minutes lets the network settle.
|
||||
const maxRecreateBackoff = 5 * time.Minute
|
||||
|
||||
// jitterFraction prevents thundering-herd reconnects when many
|
||||
// cameras drop together (switch reboot, NAT timeout).
|
||||
const jitterFraction = 0.25
|
||||
|
||||
// pullLoop runs PullMessages → decode → Events. After
|
||||
// ReconnectAfterFailures consecutive errors it asks attemptRecreate
|
||||
// to rebuild the subscription. The next batch's events carry
|
||||
// AfterReconnect=true so consumers can suppress the Initialized
|
||||
// replay ONVIF emits on a new subscription.
|
||||
func (s *Stream) pullLoop(ctx context.Context) {
|
||||
var failures int
|
||||
recreateBackoff := s.opts.RetryBackoff
|
||||
var afterReconnect bool
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts)
|
||||
if err != nil {
|
||||
s.surfaceError(ErrPullFailed{Err: err})
|
||||
failures++
|
||||
if !s.opts.DisableReconnect && failures >= s.opts.ReconnectAfterFailures {
|
||||
justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff)
|
||||
if !cont {
|
||||
return
|
||||
}
|
||||
if justRecreated {
|
||||
afterReconnect = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !sleepCtx(ctx, s.opts.RetryBackoff) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
recreateBackoff = s.opts.RetryBackoff
|
||||
observedAt := s.now()
|
||||
for _, m := range msgs {
|
||||
ev := decode(m, s.opts.DeviceID, observedAt)
|
||||
if afterReconnect {
|
||||
ev.AfterReconnect = true
|
||||
// Clear once the camera transitions past the
|
||||
// Initialized replay to live events.
|
||||
if ev.Operation != PropertyInitialized {
|
||||
afterReconnect = false
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case s.events <- ev:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attemptRecreate returns (justRecreated, cont). cont is false only
|
||||
// when ctx cancelled during backoff so the caller exits the loop.
|
||||
func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) {
|
||||
ref, err := createPullPoint(s.caller, s.opts)
|
||||
if err != nil {
|
||||
s.surfaceError(ErrRecreateFailed{Err: err})
|
||||
if !sleepCtx(ctx, jitter(*backoff)) {
|
||||
return false, false
|
||||
}
|
||||
*backoff *= 2
|
||||
if *backoff > maxRecreateBackoff {
|
||||
*backoff = maxRecreateBackoff
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
s.setPullPoint(ref)
|
||||
*failures = 0
|
||||
*backoff = s.opts.RetryBackoff
|
||||
return true, true
|
||||
}
|
||||
|
||||
// jitter perturbs d by ±jitterFraction so synchronised drops do not
|
||||
// produce a synchronised reconnect surge.
|
||||
func jitter(d time.Duration) time.Duration {
|
||||
if d <= 0 {
|
||||
return time.Nanosecond
|
||||
}
|
||||
spread := float64(d) * jitterFraction
|
||||
delta := (rand.Float64()*2 - 1) * spread
|
||||
out := time.Duration(float64(d) + delta)
|
||||
if out <= 0 {
|
||||
out = time.Nanosecond
|
||||
}
|
||||
return out
|
||||
}
|
||||
315
event/stream/reconnect_test.go
Normal file
315
event/stream/reconnect_test.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createPullPointRespAlt mirrors the first fixture but returns a
|
||||
// different SubscriptionReference Address so a test can prove that
|
||||
// subsequent pulls hit the recreated endpoint. Like createPullPointResp,
|
||||
// it intentionally omits <TerminationTime> so renew scheduling stays
|
||||
// driven by opts.
|
||||
const createPullPointRespAlt = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body>
|
||||
<tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa:Address>http://camera.local/onvif/Events/PullSub_2</wsa:Address>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
|
||||
// --- Recreate after pull failures ------------------------------------
|
||||
|
||||
func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueCallMethod(createPullPointRespAlt, nil)
|
||||
|
||||
fc.queueSendSoap("", errors.New("transient failure"))
|
||||
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
DeviceID: "cam-1",
|
||||
PullTimeout: 50 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
ev := receive(t, s.Events(), 2*time.Second)
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
require.Len(t, fc.callMethodCalls, 2,
|
||||
"expected exactly 2 CallMethod calls (initial + recreate)")
|
||||
var newEndpointPulls int
|
||||
for _, c := range fc.sendSoapCalls {
|
||||
if c[0] == "http://camera.local/onvif/Events/PullSub_2" {
|
||||
newEndpointPulls++
|
||||
}
|
||||
}
|
||||
assert.GreaterOrEqual(t, newEndpointPulls, 1,
|
||||
"expected pulls against the recreated subscription endpoint")
|
||||
}
|
||||
|
||||
func TestStream_BackoffWhenRecreateFails(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.mu.Lock()
|
||||
fc.defaultCall = fakeResp{err: errors.New("recreate fail")}
|
||||
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 10 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var calls atomic.Int32
|
||||
for time.Now().Before(deadline) {
|
||||
fc.mu.Lock()
|
||||
calls.Store(int32(len(fc.callMethodCalls)))
|
||||
fc.mu.Unlock()
|
||||
if calls.Load() >= 4 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
assert.GreaterOrEqual(t, calls.Load(), int32(4),
|
||||
"expected stream to retry recreate (>=3 retries on top of the initial create)")
|
||||
}
|
||||
|
||||
func TestStream_ReconnectAfterFailuresDefault(t *testing.T) {
|
||||
o := defaultOptions()
|
||||
assert.Equal(t, 3, o.ReconnectAfterFailures)
|
||||
}
|
||||
|
||||
func TestStream_RetryBackoffDefault(t *testing.T) {
|
||||
o := defaultOptions()
|
||||
assert.Equal(t, time.Second, o.RetryBackoff)
|
||||
}
|
||||
|
||||
func TestStream_DisableReconnectKeepsRetryingOriginalEndpoint(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.mu.Lock()
|
||||
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 10 * time.Millisecond,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
DisableReconnect: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fc.mu.Lock()
|
||||
calls := len(fc.callMethodCalls)
|
||||
fc.mu.Unlock()
|
||||
assert.Equal(t, 1, calls, "DisableReconnect must prevent recreate; got %d CallMethod calls", calls)
|
||||
}
|
||||
|
||||
func TestStream_RecreateResetsFailuresAndBackoffOnSuccess(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueCallMethod(createPullPointRespAlt, nil)
|
||||
fc.queueSendSoap("", errors.New("first failure"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 10 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fc.mu.Lock()
|
||||
calls := len(fc.callMethodCalls)
|
||||
fc.mu.Unlock()
|
||||
assert.Equal(t, 2, calls,
|
||||
"after one failure + successful recreate, no further recreates expected; got %d", calls)
|
||||
}
|
||||
|
||||
func TestStream_PullPointMutationVisibleToRenewLoopUnderRace(t *testing.T) {
|
||||
// Drives the pullPoint write-by-pullLoop / read-by-renewLoop race
|
||||
// so -race actually exercises the mutex critical sections.
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
for i := 0; i < 50; i++ {
|
||||
fc.queueCallMethod(createPullPointRespAlt, nil)
|
||||
}
|
||||
fc.mu.Lock()
|
||||
fc.defaultSendSoap = fakeResp{err: errors.New("recurring pull fail")}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 5 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 1 * time.Millisecond,
|
||||
InitialTermination: 20 * time.Millisecond,
|
||||
RenewMargin: 2 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// --- Typed errors from the reconnect path ----------------------------
|
||||
|
||||
func TestStream_PullErrorIsTypedErrPullFailed(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueSendSoap("", errors.New("transient"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 50 * time.Millisecond,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
select {
|
||||
case e := <-s.Errors():
|
||||
var pullErr ErrPullFailed
|
||||
require.True(t, errors.As(e, &pullErr), "expected ErrPullFailed, got %T: %v", e, e)
|
||||
assert.Contains(t, pullErr.Err.Error(), "transient")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected ErrPullFailed on Errors channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStream_RecreateErrorIsTypedErrRecreateFailed(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.mu.Lock()
|
||||
fc.defaultCall = fakeResp{err: errors.New("recreate fail")}
|
||||
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 10 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
var sawRecreate bool
|
||||
for time.Now().Before(deadline) && !sawRecreate {
|
||||
select {
|
||||
case e := <-s.Errors():
|
||||
var rec ErrRecreateFailed
|
||||
if errors.As(e, &rec) {
|
||||
sawRecreate = true
|
||||
assert.Contains(t, rec.Err.Error(), "recreate fail")
|
||||
}
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
assert.True(t, sawRecreate, "expected at least one ErrRecreateFailed on Errors")
|
||||
}
|
||||
|
||||
func TestStream_AfterReconnectFlagSetOnFirstPostRecreateBatch(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueCallMethod(createPullPointRespAlt, nil)
|
||||
|
||||
fc.queueSendSoap("", errors.New("transient"))
|
||||
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
|
||||
fc.queueSendSoap(pullMessagesResp(motionMsg("false")), nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 50 * time.Millisecond,
|
||||
ReconnectAfterFailures: 1,
|
||||
RetryBackoff: 10 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
ev1 := receive(t, s.Events(), 2*time.Second)
|
||||
assert.True(t, ev1.AfterReconnect, "first event after recreate must carry AfterReconnect=true")
|
||||
assert.Equal(t, StateActive, ev1.State)
|
||||
|
||||
ev2 := receive(t, s.Events(), 2*time.Second)
|
||||
assert.False(t, ev2.AfterReconnect, "subsequent events should not carry AfterReconnect")
|
||||
assert.Equal(t, StateInactive, ev2.State)
|
||||
}
|
||||
|
||||
// --- Jitter ----------------------------------------------------------
|
||||
|
||||
func TestJitter_StaysWithinFraction(t *testing.T) {
|
||||
const base = time.Second
|
||||
low := time.Duration(float64(base) * (1 - jitterFraction))
|
||||
high := time.Duration(float64(base) * (1 + jitterFraction))
|
||||
for i := 0; i < 200; i++ {
|
||||
got := jitter(base)
|
||||
assert.GreaterOrEqual(t, got, low, "iteration %d", i)
|
||||
assert.LessOrEqual(t, got, high, "iteration %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitter_ZeroAndNegativeReturnPositive(t *testing.T) {
|
||||
assert.Greater(t, jitter(0), time.Duration(0))
|
||||
assert.Greater(t, jitter(-time.Second), time.Duration(0))
|
||||
}
|
||||
|
||||
func TestJitter_VariesAcrossCalls(t *testing.T) {
|
||||
first := jitter(time.Second)
|
||||
allEqual := true
|
||||
for i := 0; i < 10; i++ {
|
||||
if jitter(time.Second) != first {
|
||||
allEqual = false
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, allEqual, "jitter is producing a constant; rand seed not working")
|
||||
}
|
||||
|
||||
func TestMaxRecreateBackoff_Is5Minutes(t *testing.T) {
|
||||
assert.Equal(t, 5*time.Minute, maxRecreateBackoff)
|
||||
}
|
||||
97
event/stream/renew.go
Normal file
97
event/stream/renew.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif/event"
|
||||
"github.com/kerberos-io/onvif/xsd"
|
||||
)
|
||||
|
||||
// renewLoop sleeps until the next deadline (camera-granted termination
|
||||
// minus RenewMargin), renews, and repeats. On failure it backs off via
|
||||
// nextRenewIntervalAfterError so the loop doesn't busy-loop against
|
||||
// the 1s floor when the previous grant has just expired. A permanently
|
||||
// failing renew lets the subscription die at the camera; the pull
|
||||
// loop's reconnect path then recreates it — recreate is the only
|
||||
// reliable recovery once a subscription is GC'd.
|
||||
func (s *Stream) renewLoop(ctx context.Context) {
|
||||
for {
|
||||
ref, gen := s.snapshotPullPoint()
|
||||
if !sleepCtx(ctx, nextRenewInterval(ref.GrantedTermination, s.opts, s.now())) {
|
||||
return
|
||||
}
|
||||
granted, err := renewPullPoint(s.caller, s.getPullPoint(), s.opts)
|
||||
if err != nil {
|
||||
s.surfaceError(ErrRenewFailed{Err: err})
|
||||
if !sleepCtx(ctx, nextRenewIntervalAfterError(s.opts)) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !granted.IsZero() {
|
||||
s.updateGrantedTerminationIfGen(gen, granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextRenewInterval prefers the camera-granted termination so we never
|
||||
// schedule a renew past the actual expiry, with opts.InitialTermination
|
||||
// as the fallback when the camera didn't supply one.
|
||||
func nextRenewInterval(granted time.Time, opts Options, now time.Time) time.Duration {
|
||||
var base time.Duration
|
||||
if !granted.IsZero() {
|
||||
base = granted.Sub(now)
|
||||
} else {
|
||||
base = opts.InitialTermination
|
||||
}
|
||||
d := base - opts.RenewMargin
|
||||
if d <= 0 {
|
||||
d = base / 2
|
||||
}
|
||||
if d <= 0 {
|
||||
d = time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// nextRenewIntervalAfterError returns the post-failure sleep. The
|
||||
// grant is typically already in the past by the time renew has failed
|
||||
// once, so nextRenewInterval would floor to 1s and hammer the camera.
|
||||
// Recovery is the pull loop's reconnect path; we just need to not
|
||||
// accelerate retries past the configured RetryBackoff.
|
||||
func nextRenewIntervalAfterError(opts Options) time.Duration {
|
||||
if opts.RetryBackoff > 0 {
|
||||
return opts.RetryBackoff
|
||||
}
|
||||
return time.Second
|
||||
}
|
||||
|
||||
// renewPullPoint sends Renew with an absolute UTC TerminationTime.
|
||||
// WS-BaseNotification §6.1.1 also allows xsd:duration but older
|
||||
// Hikvision, some Dahua and some Bosch firmwares reject the
|
||||
// relative form. Returns the camera-granted TerminationTime parsed
|
||||
// from the response (zero on absence) so the caller can reschedule.
|
||||
func renewPullPoint(c caller, ref subscriptionRef, opts Options) (time.Time, error) {
|
||||
absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z")
|
||||
req := event.Renew{TerminationTime: xsd.String(absoluteEnd)}
|
||||
body, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("marshal Renew: %w", err)
|
||||
}
|
||||
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("build ref params header: %w", err)
|
||||
}
|
||||
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
|
||||
if err != nil {
|
||||
return time.Time{}, enrichSOAPErr(resp, err)
|
||||
}
|
||||
respBody, err := readClose(resp)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return extractTerminationTime(respBody), nil
|
||||
}
|
||||
277
event/stream/renew_test.go
Normal file
277
event/stream/renew_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// countSendSoapMatching counts how many recorded SendSoap calls have a
|
||||
// body containing needle. Safe to call concurrently with the run loop.
|
||||
func countSendSoapMatching(fc *fakeCaller, needle string) int {
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range fc.sendSoapCalls {
|
||||
if strings.Contains(c[1], needle) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestStream_RenewsSubscriptionBeforeExpiry(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 100 ms termination with 10 ms margin -> renew every ~90 ms.
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
DeviceID: "cam-1",
|
||||
InitialTermination: 100 * time.Millisecond,
|
||||
RenewMargin: 10 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
var renewCount int
|
||||
for time.Now().Before(deadline) {
|
||||
renewCount = countSendSoapMatching(fc, "Renew")
|
||||
if renewCount >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
assert.GreaterOrEqual(t, renewCount, 1, "expected at least one Renew SendSoap call within 500ms")
|
||||
}
|
||||
|
||||
func TestStream_RenewSendsToSubscriptionEndpoint(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
InitialTermination: 80 * time.Millisecond,
|
||||
RenewMargin: 10 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countSendSoapMatching(fc, "Renew") >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
var renewEndpoint string
|
||||
for _, c := range fc.sendSoapCalls {
|
||||
if strings.Contains(c[1], "Renew") {
|
||||
renewEndpoint = c[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, renewEndpoint, "no Renew call found")
|
||||
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", renewEndpoint,
|
||||
"Renew must target the SubscriptionReference Address")
|
||||
}
|
||||
|
||||
func TestStream_RenewMarginAppliesDefault(t *testing.T) {
|
||||
o := defaultOptions()
|
||||
assert.Equal(t, 10*time.Second, o.RenewMargin)
|
||||
}
|
||||
|
||||
func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
// Defaults return empty pulls indefinitely so the pull loop is clean.
|
||||
// Override defaultSendSoap on the fly to return a Renew error for
|
||||
// any body that looks like a Renew. We do that by tagging the
|
||||
// default response with an err, then resetting after capturing one.
|
||||
// Simpler: just queue several explicit Renew-error responses; the
|
||||
// fake's queue is consumed in FIFO and the pull body never matches
|
||||
// 'Renew', so queued errors will land on the renew call only if
|
||||
// queued before any pulls. To bias the order we drain via a custom
|
||||
// default.
|
||||
fc.mu.Lock()
|
||||
fc.defaultSendSoap = fakeResp{err: errInjected{}}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
InitialTermination: 80 * time.Millisecond,
|
||||
RenewMargin: 10 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
select {
|
||||
case e := <-s.Errors():
|
||||
assert.Contains(t, e.Error(), "injected")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected an error on Errors channel from failing Renew/pull")
|
||||
}
|
||||
}
|
||||
|
||||
// errInjected is a sentinel error type so the test message has a stable
|
||||
// substring without depending on a wrapped string match.
|
||||
type errInjected struct{}
|
||||
|
||||
func (errInjected) Error() string { return "injected fake error" }
|
||||
|
||||
func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
InitialTermination: 30 * time.Millisecond,
|
||||
RenewMargin: 5 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if countSendSoapMatching(fc, "Renew") >= 1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
var renewBody string
|
||||
for _, c := range fc.sendSoapCalls {
|
||||
if strings.Contains(c[1], "Renew") {
|
||||
renewBody = c[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, renewBody, "no Renew call observed")
|
||||
// Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS".
|
||||
assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it")
|
||||
assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC")
|
||||
}
|
||||
|
||||
// --- Wiring: renew surfaces SOAP fault detail -------------------------
|
||||
|
||||
func TestRenewPullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueSendSoap(renewFaultBody, errors.New("400 Bad Request"))
|
||||
_, err := renewPullPoint(fc, subscriptionRef{Address: "http://camera/sub"}, defaultOptions())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "renew-specific complaint",
|
||||
"renewPullPoint must enrich transport errors with the camera's SOAP fault")
|
||||
}
|
||||
|
||||
const renewFaultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Body><env:Fault>
|
||||
<env:Code><env:Value>env:Sender</env:Value></env:Code>
|
||||
<env:Reason><env:Text xml:lang="en">renew-specific complaint</env:Text></env:Reason>
|
||||
</env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
|
||||
func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := renewPullPoint(fc, ref, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fc.sendSoapHeaders, 1)
|
||||
hdr := fc.sendSoapHeaders[0]
|
||||
assert.Contains(t, hdr, "SubscriptionId")
|
||||
assert.Contains(t, hdr, "297")
|
||||
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
|
||||
}
|
||||
|
||||
// Regression: when GrantedTermination has just passed (renew failed
|
||||
// at or after the deadline), nextRenewInterval floors to one second
|
||||
// and the loop hammers the camera at 1 Hz until reconnect. Original
|
||||
// ticker design retried at the configured cadence regardless. After
|
||||
// a failure the loop must use a backoff decoupled from the stale
|
||||
// grant.
|
||||
func TestNextRenewIntervalAfterError_BacksOffAtLeastRetryBackoff(t *testing.T) {
|
||||
opts := Options{RetryBackoff: time.Second}
|
||||
got := nextRenewIntervalAfterError(opts)
|
||||
assert.GreaterOrEqual(t, got, opts.RetryBackoff,
|
||||
"failure path must back off at least RetryBackoff, not 1s floor on stale grant")
|
||||
}
|
||||
|
||||
func TestNextRenewIntervalAfterError_DefaultsToOneSecondWhenRetryBackoffZero(t *testing.T) {
|
||||
got := nextRenewIntervalAfterError(Options{})
|
||||
assert.Equal(t, time.Second, got,
|
||||
"zero RetryBackoff must yield the safety floor, not a tight 0-duration sleep")
|
||||
}
|
||||
|
||||
// Lost-update race: renew snapshots the ref, the SOAP call returns,
|
||||
// and meanwhile attemptRecreate replaced pullPoint with a fresh
|
||||
// subscription. If renew blindly writes the OLD subscription's
|
||||
// granted time onto the NEW subscription, the new schedule is wrong.
|
||||
// Update must be conditioned on "same subscription as when I read."
|
||||
func TestUpdateGrantedTermination_DropsWriteWhenSubscriptionRotated(t *testing.T) {
|
||||
// s.now is intentionally nil — this test does not exercise any
|
||||
// time-dependent path; only the gen-counter accessors.
|
||||
s := &Stream{}
|
||||
original := subscriptionRef{Address: "http://camera/sub-A"}
|
||||
s.setPullPoint(original)
|
||||
gen := s.pullPointGen()
|
||||
|
||||
// Simulate recreate happening between snapshot and write.
|
||||
s.setPullPoint(subscriptionRef{Address: "http://camera/sub-B"})
|
||||
|
||||
// Old generation's renew result must NOT overwrite sub-B's grant.
|
||||
bogus := time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
s.updateGrantedTerminationIfGen(gen, bogus)
|
||||
|
||||
assert.True(t, s.getPullPoint().GrantedTermination.IsZero(),
|
||||
"stale renew result must be discarded after a subscription rotation")
|
||||
}
|
||||
|
||||
func TestUpdateGrantedTermination_AppliesWhenGenMatches(t *testing.T) {
|
||||
// s.now is intentionally nil — gen-counter path only.
|
||||
s := &Stream{}
|
||||
s.setPullPoint(subscriptionRef{Address: "http://camera/sub"})
|
||||
gen := s.pullPointGen()
|
||||
t1 := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
s.updateGrantedTerminationIfGen(gen, t1)
|
||||
assert.Equal(t, t1, s.getPullPoint().GrantedTermination)
|
||||
}
|
||||
|
||||
// renewLoop must capture (ref, gen) atomically. Two separate
|
||||
// getPullPoint() + pullPointGen() reads leave a window in which a
|
||||
// concurrent setPullPoint advances gen between the two reads — the
|
||||
// renew then runs against ref-N but believes its captured gen is N+1,
|
||||
// and updateGrantedTerminationIfGen("succeeds") writing the old
|
||||
// subscription's grant onto the new one. Single snapshot closes it.
|
||||
func TestSnapshotPullPoint_AtomicReadOfRefAndGen(t *testing.T) {
|
||||
// s.now is intentionally nil — gen-counter path only.
|
||||
s := &Stream{}
|
||||
s.setPullPoint(subscriptionRef{Address: "A"}) // gen=1
|
||||
ref, gen := s.snapshotPullPoint()
|
||||
assert.Equal(t, "A", ref.Address)
|
||||
assert.Equal(t, uint64(1), gen)
|
||||
|
||||
s.setPullPoint(subscriptionRef{Address: "B"}) // gen=2
|
||||
ref, gen = s.snapshotPullPoint()
|
||||
assert.Equal(t, "B", ref.Address)
|
||||
assert.Equal(t, uint64(2), gen)
|
||||
}
|
||||
362
event/stream/soap.go
Normal file
362
event/stream/soap.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/kerberos-io/onvif/event"
|
||||
"github.com/kerberos-io/onvif/xsd"
|
||||
)
|
||||
|
||||
// maxResponseBytes caps SOAP response buffering on success paths.
|
||||
// ONVIF PullMessages bodies are normally <100KB even with dense
|
||||
// analytics payloads; 10 MiB is well above legitimate traffic while
|
||||
// keeping a hostile or buggy camera from OOMing the process.
|
||||
const maxResponseBytes = 10 << 20
|
||||
|
||||
// maxErrorBodyBytes caps the body read by enrichSOAPErr. The pull
|
||||
// retry loop runs every RetryBackoff (~1s) so an unbounded read on
|
||||
// the error path would churn 10 MiB/s per wedged camera. Fault bodies
|
||||
// are always small.
|
||||
const maxErrorBodyBytes = 64 << 10
|
||||
|
||||
func createPullPoint(c caller, opts Options) (subscriptionRef, error) {
|
||||
term := xsd.String(durationToXSD(opts.InitialTermination))
|
||||
req := event.CreatePullPointSubscription{InitialTerminationTime: &term}
|
||||
if opts.RawTopicFilter != "" {
|
||||
req.Filter = &event.FilterType{
|
||||
TopicExpression: &event.TopicExpressionType{
|
||||
Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"),
|
||||
TopicKinds: xsd.String(opts.RawTopicFilter),
|
||||
},
|
||||
}
|
||||
}
|
||||
resp, err := c.CallMethod(req)
|
||||
if err != nil {
|
||||
return subscriptionRef{}, enrichSOAPErr(resp, err)
|
||||
}
|
||||
body, err := readClose(resp)
|
||||
if err != nil {
|
||||
return subscriptionRef{}, err
|
||||
}
|
||||
var decoded event.CreatePullPointSubscriptionResponse
|
||||
if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil {
|
||||
return subscriptionRef{}, err
|
||||
}
|
||||
addr := string(decoded.SubscriptionReference.Address)
|
||||
if addr == "" {
|
||||
return subscriptionRef{}, errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address")
|
||||
}
|
||||
return subscriptionRef{
|
||||
Address: addr,
|
||||
RefParamsXML: extractReferenceParameters(body),
|
||||
GrantedTermination: extractTerminationTime(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractTerminationTime parses the absolute UTC instant the camera
|
||||
// granted as the subscription expiry. Returns zero on absence or parse
|
||||
// failure — callers fall back to opts.InitialTermination.
|
||||
func extractTerminationTime(body string) time.Time {
|
||||
m := terminationTimeRE.FindStringSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, strings.TrimSpace(m[1]))
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// terminationTimeRE matches the first <*:TerminationTime> in the body.
|
||||
// Only safe on responses that contain exactly one — currently
|
||||
// CreatePullPointSubscriptionResponse and RenewResponse via
|
||||
// extractTerminationTime. PullMessagesResponse also has a
|
||||
// TerminationTime element; do not call extractTerminationTime on pull
|
||||
// bodies.
|
||||
var terminationTimeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?TerminationTime\b[^>]*>(.*?)</(?:[^:>\s]+:)?TerminationTime>`)
|
||||
|
||||
// pullMessages returns an empty slice (no error) when the camera had
|
||||
// nothing within PullTimeout.
|
||||
func pullMessages(c caller, ref subscriptionRef, opts Options) ([]event.NotificationMessage, error) {
|
||||
req := event.PullMessages{
|
||||
Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)),
|
||||
MessageLimit: xsd.Int(opts.MessageLimit),
|
||||
}
|
||||
body, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal PullMessages: %w", err)
|
||||
}
|
||||
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build ref params header: %w", err)
|
||||
}
|
||||
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
|
||||
if err != nil {
|
||||
return nil, enrichSOAPErr(resp, err)
|
||||
}
|
||||
respBody, err := readClose(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var decoded event.PullMessagesResponse
|
||||
if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decoded.NotificationMessage, nil
|
||||
}
|
||||
|
||||
// unsubscribePullPoint is best-effort. Empty Address is a no-op
|
||||
// (construction failed before installing a subscription).
|
||||
func unsubscribePullPoint(c caller, ref subscriptionRef) error {
|
||||
if ref.Address == "" {
|
||||
return nil
|
||||
}
|
||||
body, err := xml.Marshal(event.Unsubscribe{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal Unsubscribe: %w", err)
|
||||
}
|
||||
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build ref params header: %w", err)
|
||||
}
|
||||
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
|
||||
if err != nil {
|
||||
return enrichSOAPErr(resp, err)
|
||||
}
|
||||
_, err = readClose(resp)
|
||||
return err
|
||||
}
|
||||
|
||||
func readClose(resp *http.Response) (string, error) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return "", errors.New("nil HTTP response")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// unmarshalNode finds the first XML start element with the given local
|
||||
// name and decodes it into out. ONVIF SOAP responses are wrapped in an
|
||||
// envelope with many namespace prefixes; keying on local name only
|
||||
// sidesteps namespace matching.
|
||||
//
|
||||
// When the camera returns a SOAP Fault, the fault reason is returned
|
||||
// as the error so callers can distinguish auth / expired-subscription
|
||||
// from "unparseable response".
|
||||
func unmarshalNode(body, localName string, out any) error {
|
||||
if reason := extractSOAPFault(body); reason != "" {
|
||||
return fmt.Errorf("ONVIF SOAP fault: %s", reason)
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewBufferString(body))
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("ONVIF response missing %s element", localName)
|
||||
}
|
||||
return fmt.Errorf("scan ONVIF response: %w", err)
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != localName {
|
||||
continue
|
||||
}
|
||||
if err := dec.DecodeElement(out, &start); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", localName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// SOAP 1.1: <faultstring>reason</faultstring>
|
||||
soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)</(?:[^:>\s]+:)?faultstring>`)
|
||||
// SOAP 1.2: <Fault>...<Reason><Text>reason</Text></Reason>...</Fault>
|
||||
soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)</(?:[^:>\s]+:)?Text>`)
|
||||
// SOAP 1.2 Subcode: <Code>...<Subcode><Value>ter:InvalidArgs</Value></Subcode>...
|
||||
soap12SubcodeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Subcode\b[^>]*>.*?<(?:[^:>\s]+:)?Value[^>]*>(.*?)</(?:[^:>\s]+:)?Value>`)
|
||||
|
||||
// WS-Security blocks may carry our Username/Password if the camera
|
||||
// echoes the request in a fault; scrub before logging. The
|
||||
// alternation handles the truncated case where the read cap fell
|
||||
// between <Security> and </Security>: in that case nothing past
|
||||
// the opening tag is safe to retain — the replacement re-emits a
|
||||
// synthetic close tag, dropping the remainder of the body excerpt
|
||||
// (max-redact preferred to max-context for log lines).
|
||||
// wssePasswordRE is the belt-and-braces fallback for
|
||||
// non-conformant cameras emitting Password / UsernameToken outside
|
||||
// a Security wrapper; same truncation handling.
|
||||
wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Security>|.*)`)
|
||||
wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Password>|.*)`)
|
||||
)
|
||||
|
||||
// extractSOAPFault returns the reason text from a SOAP fault, falling
|
||||
// back to the Subcode value when Reason/Text is empty (AXIS pattern).
|
||||
// Returns "" when the body is not a fault.
|
||||
func extractSOAPFault(body string) string {
|
||||
if !strings.Contains(body, "Fault") {
|
||||
return ""
|
||||
}
|
||||
if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 {
|
||||
if r := strings.TrimSpace(m[1]); r != "" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 {
|
||||
if r := strings.TrimSpace(m[1]); r != "" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return extractSOAPSubcode(body)
|
||||
}
|
||||
|
||||
// Anchored to SubscriptionReference because other WS-Addressing
|
||||
// endpoint references in the same envelope (wsa:ReplyTo, wsa:FaultTo,
|
||||
// wsa:From) may also carry ReferenceParameters that are not ours.
|
||||
var (
|
||||
subscriptionRefRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?SubscriptionReference\b[^>]*>(.*?)</(?:[^:>\s]+:)?SubscriptionReference>`)
|
||||
refParamsRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?ReferenceParameters\b[^>]*>(.*?)</(?:[^:>\s]+:)?ReferenceParameters>`)
|
||||
)
|
||||
|
||||
// buildRefParamsHeader produces the SOAP <Header> inner XML from the
|
||||
// full <*:ReferenceParameters> element returned by
|
||||
// extractReferenceParameters. Each child element is re-emitted with
|
||||
// wsa:IsReferenceParameter="true" added and any xmlns:* declared on
|
||||
// the parent inherited onto it (so the standalone child stays valid).
|
||||
// Empty input yields empty output.
|
||||
func buildRefParamsHeader(refParamsXML string) (string, error) {
|
||||
if strings.TrimSpace(refParamsXML) == "" {
|
||||
return "", nil
|
||||
}
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString(refParamsXML); err != nil {
|
||||
return "", fmt.Errorf("parse ref params: %w", err)
|
||||
}
|
||||
wrapper := doc.Root()
|
||||
if wrapper == nil {
|
||||
return "", errors.New("ref params has no root element")
|
||||
}
|
||||
if wrapper.Tag != "ReferenceParameters" {
|
||||
return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", wrapper.Tag)
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, child := range wrapper.ChildElements() {
|
||||
c := child.Copy()
|
||||
inheritXmlns(c, wrapper)
|
||||
c.CreateAttr("wsa:IsReferenceParameter", "true")
|
||||
d := etree.NewDocument()
|
||||
d.SetRoot(c)
|
||||
s, err := d.WriteToString()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("serialise ref param child: %w", err)
|
||||
}
|
||||
out.WriteString(strings.TrimRight(s, "\n"))
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// inheritXmlns copies xmlns / xmlns:* declarations from src onto dst
|
||||
// when dst doesn't already declare them, so a child whose namespace
|
||||
// prefix was declared on an ancestor stays valid in isolation.
|
||||
func inheritXmlns(dst, src *etree.Element) {
|
||||
for _, attr := range src.Attr {
|
||||
isDefault := attr.Space == "" && attr.Key == "xmlns"
|
||||
isPrefixed := attr.Space == "xmlns"
|
||||
if !isDefault && !isPrefixed {
|
||||
continue
|
||||
}
|
||||
key := attr.Key
|
||||
if isPrefixed {
|
||||
key = "xmlns:" + attr.Key
|
||||
}
|
||||
if dst.SelectAttr(key) != nil {
|
||||
continue
|
||||
}
|
||||
dst.CreateAttr(key, attr.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// extractReferenceParameters returns the verbatim inner XML so callers
|
||||
// can echo it (with wsa:IsReferenceParameter="true") into the SOAP
|
||||
// Header of subscription-scoped requests per WS-Addressing 1.0 SOAP Binding §3.4.
|
||||
// Without that echo, AXIS rejects PullMessages with ter:InvalidArgs.
|
||||
func extractReferenceParameters(body string) string {
|
||||
sub := subscriptionRefRE.FindStringSubmatch(body)
|
||||
if len(sub) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(refParamsRE.FindString(sub[1]))
|
||||
}
|
||||
|
||||
// extractSOAPSubcode is the fallback when Reason/Text is empty — AXIS
|
||||
// routinely sends an empty <Text/> alongside a populated Subcode
|
||||
// (e.g. "ter:InvalidArgs"), and that subcode is the only actionable
|
||||
// signal the operator gets.
|
||||
func extractSOAPSubcode(body string) string {
|
||||
m := soap12SubcodeRE.FindStringSubmatch(body)
|
||||
if len(m) > 1 {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maxErrExcerpt caps the body excerpt appended to an enriched error so
|
||||
// a wedged camera streaming a multi-megabyte HTML error page can not
|
||||
// flood logs with every retry.
|
||||
const maxErrExcerpt = 512
|
||||
|
||||
// enrichSOAPErr appends the camera's actual complaint (SOAP Fault
|
||||
// reason, then Subcode, then raw body excerpt) to a transport error so
|
||||
// operators see *why* the camera said 400 instead of just "400 Bad
|
||||
// Request". The original err is preserved via %w for errors.Is/As.
|
||||
func enrichSOAPErr(resp *http.Response, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
|
||||
if readErr != nil || len(b) == 0 {
|
||||
return err
|
||||
}
|
||||
body := wsseSecurityRE.ReplaceAllString(string(b), "<Security>[REDACTED]</Security>")
|
||||
body = wssePasswordRE.ReplaceAllString(body, "<Password>[REDACTED]</Password>")
|
||||
if reason := extractSOAPFault(body); reason != "" {
|
||||
return fmt.Errorf("SOAP fault: %s: %w", reason, err)
|
||||
}
|
||||
excerpt := strings.TrimSpace(body)
|
||||
if len(excerpt) > maxErrExcerpt {
|
||||
excerpt = excerpt[:maxErrExcerpt] + "...(truncated)"
|
||||
}
|
||||
return fmt.Errorf("response body: %s: %w", excerpt, err)
|
||||
}
|
||||
|
||||
// durationToXSD formats a duration as xsd:duration PTnS. Second
|
||||
// precision is sufficient — ONVIF cameras do not honour sub-second
|
||||
// pull timeouts.
|
||||
func durationToXSD(d time.Duration) string {
|
||||
secs := int(d.Round(time.Second).Seconds())
|
||||
if secs <= 0 {
|
||||
secs = 1
|
||||
}
|
||||
return "PT" + strconv.Itoa(secs) + "S"
|
||||
}
|
||||
721
event/stream/soap_test.go
Normal file
721
event/stream/soap_test.go
Normal file
@@ -0,0 +1,721 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- SOAP fault detection ---------------------------------------------
|
||||
|
||||
func TestExtractSOAPFault_SOAP11(t *testing.T) {
|
||||
body := `<?xml version="1.0"?>
|
||||
<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<env:Body>
|
||||
<env:Fault>
|
||||
<faultcode>env:Client</faultcode>
|
||||
<faultstring>The action requested requires authorization and the sender is not authorized</faultstring>
|
||||
</env:Fault>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
got := extractSOAPFault(body)
|
||||
assert.Contains(t, got, "not authorized")
|
||||
}
|
||||
|
||||
func TestExtractSOAPFault_SOAP12(t *testing.T) {
|
||||
body := `<?xml version="1.0"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Body>
|
||||
<env:Fault>
|
||||
<env:Code><env:Value>env:Sender</env:Value></env:Code>
|
||||
<env:Reason><env:Text xml:lang="en">Subscription has expired</env:Text></env:Reason>
|
||||
</env:Fault>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
got := extractSOAPFault(body)
|
||||
assert.Contains(t, got, "Subscription has expired")
|
||||
}
|
||||
|
||||
func TestExtractSOAPFault_NotAFault(t *testing.T) {
|
||||
assert.Empty(t, extractSOAPFault(createPullPointResp))
|
||||
}
|
||||
|
||||
func TestExtractSOAPFault_EmptyBody(t *testing.T) {
|
||||
assert.Empty(t, extractSOAPFault(""))
|
||||
}
|
||||
|
||||
func TestUnmarshalNode_ReturnsFaultReasonInsteadOfMissingElement(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<env:Body><env:Fault><faultstring>not authorized</faultstring></env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
var out struct{}
|
||||
err := unmarshalNode(body, "PullMessagesResponse", &out)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not authorized")
|
||||
assert.NotContains(t, err.Error(), "missing PullMessagesResponse")
|
||||
}
|
||||
|
||||
// --- Bounded body read -----------------------------------------------
|
||||
|
||||
func TestReadClose_LimitsBodySize(t *testing.T) {
|
||||
if maxResponseBytes < 1024 {
|
||||
t.Skip("limit too small for this test")
|
||||
}
|
||||
big := strings.Repeat("A", maxResponseBytes+1024)
|
||||
body := "<env:Envelope><env:Body>" + big + "</env:Body></env:Envelope>"
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(body, nil)
|
||||
// Construction will fail because the truncated body has no
|
||||
// CreatePullPointSubscriptionResponse — that's fine; what matters is
|
||||
// the read completes without OOM.
|
||||
_, err := newStream(testContext(t), fc, Options{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// testContext returns a Background context already wired to cancel via
|
||||
// t.Cleanup so the test does not need to manage the cancellation
|
||||
// goroutine inline.
|
||||
func testContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// --- Error enrichment from SOAP response bodies ----------------------
|
||||
|
||||
func fakeResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_NilErrReturnsNil(t *testing.T) {
|
||||
assert.NoError(t, enrichSOAPErr(fakeResponse("anything"), nil))
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_NilRespPreservesOriginal(t *testing.T) {
|
||||
orig := errors.New("transport boom")
|
||||
got := enrichSOAPErr(nil, orig)
|
||||
assert.ErrorIs(t, got, orig)
|
||||
assert.Equal(t, orig.Error(), got.Error(), "no body, no extra context to add")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_SOAP11FaultStringAppearsInError(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<env:Body><env:Fault><faultstring>not authorized</faultstring></env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("400 Bad Request"))
|
||||
require.Error(t, got)
|
||||
assert.Contains(t, got.Error(), "400 Bad Request")
|
||||
assert.Contains(t, got.Error(), "not authorized")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_SOAP12ReasonAppearsInError(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Body><env:Fault>
|
||||
<env:Code><env:Value>env:Sender</env:Value></env:Code>
|
||||
<env:Reason><env:Text xml:lang="en">Subscription has expired</env:Text></env:Reason>
|
||||
</env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("400 Bad Request"))
|
||||
require.Error(t, got)
|
||||
assert.Contains(t, got.Error(), "Subscription has expired")
|
||||
}
|
||||
|
||||
// Pins the AXIS case: a Fault with populated Subcode but an empty
|
||||
// <Reason><Text/></Reason>. Without subcode fallback, the only signal
|
||||
// the operator sees is "400 Bad Request".
|
||||
func TestEnrichSOAPErr_EmptyReasonFallsBackToSubcode(t *testing.T) {
|
||||
body := `<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:ter="http://www.onvif.org/ver10/error">
|
||||
<SOAP-ENV:Body><SOAP-ENV:Fault>
|
||||
<SOAP-ENV:Code>
|
||||
<SOAP-ENV:Value>SOAP-ENV:Sender</SOAP-ENV:Value>
|
||||
<SOAP-ENV:Subcode><SOAP-ENV:Value>ter:InvalidArgs</SOAP-ENV:Value></SOAP-ENV:Subcode>
|
||||
</SOAP-ENV:Code>
|
||||
<SOAP-ENV:Reason><SOAP-ENV:Text xml:lang="en"/></SOAP-ENV:Reason>
|
||||
</SOAP-ENV:Fault></SOAP-ENV:Body>
|
||||
</SOAP-ENV:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("Post with digest error: 400: 400 Bad Request"))
|
||||
require.Error(t, got)
|
||||
assert.Contains(t, got.Error(), "ter:InvalidArgs",
|
||||
"AXIS-style empty-Reason Faults must surface their Subcode")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_NonFaultBodyIncludesExcerpt(t *testing.T) {
|
||||
body := `<html><body>404 Not Found — /onvif/services missing</body></html>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("404 Not Found"))
|
||||
require.Error(t, got)
|
||||
assert.Contains(t, got.Error(), "/onvif/services missing")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_LargeNonFaultBodyTruncated(t *testing.T) {
|
||||
// A misbehaving camera could stream a multi-megabyte body. The
|
||||
// helper must cap the excerpt so a wedged camera does not flood
|
||||
// logs.
|
||||
body := strings.Repeat("X", 8192)
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
|
||||
require.Error(t, got)
|
||||
assert.Less(t, len(got.Error()), 2048,
|
||||
"enriched error must stay log-line sized even on huge bodies")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_PreservesOriginalForErrorsIs(t *testing.T) {
|
||||
// Callers wrap pull/renew/recreate errors with errors.As in
|
||||
// logStreamError; enrichment must keep the original wrappable.
|
||||
orig := errors.New("sentinel")
|
||||
got := enrichSOAPErr(fakeResponse(`<env:Fault><faultstring>x</faultstring></env:Fault>`), orig)
|
||||
assert.ErrorIs(t, got, orig)
|
||||
}
|
||||
|
||||
// --- Subcode extraction ----------------------------------------------
|
||||
|
||||
func TestExtractSOAPSubcode_Present(t *testing.T) {
|
||||
body := `<SOAP-ENV:Code>
|
||||
<SOAP-ENV:Value>SOAP-ENV:Sender</SOAP-ENV:Value>
|
||||
<SOAP-ENV:Subcode><SOAP-ENV:Value>ter:InvalidArgs</SOAP-ENV:Value></SOAP-ENV:Subcode>
|
||||
</SOAP-ENV:Code>`
|
||||
assert.Equal(t, "ter:InvalidArgs", extractSOAPSubcode(body))
|
||||
}
|
||||
|
||||
func TestExtractSOAPSubcode_Absent(t *testing.T) {
|
||||
assert.Empty(t, extractSOAPSubcode(`<env:Code><env:Value>env:Sender</env:Value></env:Code>`))
|
||||
}
|
||||
|
||||
// --- Wiring: each SOAP call site routes errors through enrichSOAPErr -
|
||||
|
||||
const faultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Body><env:Fault>
|
||||
<env:Code><env:Value>env:Sender</env:Value></env:Code>
|
||||
<env:Reason><env:Text xml:lang="en">camera-specific complaint</env:Text></env:Reason>
|
||||
</env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
|
||||
func TestCreatePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(faultBody, errors.New("400 Bad Request"))
|
||||
_, err := createPullPoint(fc, defaultOptions())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "camera-specific complaint",
|
||||
"createPullPoint must enrich transport errors with the camera's SOAP fault")
|
||||
}
|
||||
|
||||
func TestPullMessages_EnrichesTransportErrWithFaultReason(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
|
||||
_, err := pullMessages(fc, subscriptionRef{Address: "http://camera/sub"}, defaultOptions())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "camera-specific complaint",
|
||||
"pullMessages must enrich transport errors with the camera's SOAP fault")
|
||||
}
|
||||
|
||||
func TestUnsubscribePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
|
||||
err := unsubscribePullPoint(fc, subscriptionRef{Address: "http://camera/sub"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "camera-specific complaint",
|
||||
"unsubscribePullPoint must enrich transport errors with the camera's SOAP fault")
|
||||
}
|
||||
|
||||
// --- ReferenceParameters extraction (WS-Addressing 1.0 SOAP Binding §3.4) ---------
|
||||
//
|
||||
// AXIS encodes the subscription identity in <wsa:ReferenceParameters>
|
||||
// inside CreatePullPointSubscriptionResponse rather than in the URL
|
||||
// itself. Subsequent PullMessages/Renew/Unsubscribe MUST echo those
|
||||
// elements verbatim into the SOAP Header, or the camera responds with
|
||||
// ter:InvalidArgs. The auto-generated event.ReferenceParametersType is
|
||||
// an empty struct (drops children), so we extract the raw inner XML.
|
||||
|
||||
func TestExtractReferenceParameters_AXISShape(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa5="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa5:Address>http://192.168.1.10/onvif/services</wsa5:Address>
|
||||
<wsa5:ReferenceParameters>
|
||||
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
|
||||
</wsa5:ReferenceParameters>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
got := extractReferenceParameters(body)
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
assert.Contains(t, got, "297")
|
||||
assert.Contains(t, got, `xmlns:dom0="http://www.axis.com/2009/event"`,
|
||||
"namespace declaration on the SubscriptionId child must survive extraction")
|
||||
}
|
||||
|
||||
func TestExtractReferenceParameters_AbsentReturnsEmpty(t *testing.T) {
|
||||
// Geovision/Hikvision-style: Address only, no ReferenceParameters.
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<tev:SubscriptionReference>
|
||||
<wsa:Address>http://camera/onvif/Events/Sub_1</wsa:Address>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
assert.Empty(t, extractReferenceParameters(body))
|
||||
}
|
||||
|
||||
func TestExtractReferenceParameters_EmptyBodyReturnsEmpty(t *testing.T) {
|
||||
assert.Empty(t, extractReferenceParameters(""))
|
||||
}
|
||||
|
||||
// --- createPullPoint returns both address and ref params -------------
|
||||
|
||||
func TestCreatePullPoint_ReturnsRefParamsAlongsideAddress(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa5="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa5:Address>http://192.168.1.10/onvif/services</wsa5:Address>
|
||||
<wsa5:ReferenceParameters>
|
||||
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
|
||||
</wsa5:ReferenceParameters>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(body, nil)
|
||||
ref, err := createPullPoint(fc, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://192.168.1.10/onvif/services", ref.Address)
|
||||
assert.Contains(t, ref.RefParamsXML, "SubscriptionId")
|
||||
assert.Contains(t, ref.RefParamsXML, "297")
|
||||
}
|
||||
|
||||
func TestCreatePullPoint_VendorWithoutRefParams_RefParamsEmpty(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
ref, err := createPullPoint(fc, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, ref.Address)
|
||||
assert.Empty(t, ref.RefParamsXML)
|
||||
}
|
||||
|
||||
// --- Reference-parameter echoing in subscription-scoped calls --------
|
||||
//
|
||||
// WS-Addressing 1.0 SOAP Binding §3.4 requires each <wsa:ReferenceParameters> child
|
||||
// to be echoed as a SOAP Header block carrying wsa:IsReferenceParameter
|
||||
// ="true". AXIS rejects PullMessages with ter:InvalidArgs when this is
|
||||
// absent.
|
||||
|
||||
func TestPullMessages_EchoesRefParamsWithIsReferenceParameterAttribute(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fc.sendSoapHeaders, 1)
|
||||
hdr := fc.sendSoapHeaders[0]
|
||||
assert.Contains(t, hdr, "SubscriptionId", "ref param element must be echoed")
|
||||
assert.Contains(t, hdr, "297", "ref param value must be echoed")
|
||||
assert.Contains(t, hdr, `IsReferenceParameter="true"`,
|
||||
"WS-Addressing 1.0 SOAP Binding §3.4 requires the attribute on each echoed element")
|
||||
}
|
||||
|
||||
func TestPullMessages_NoRefParams_HeaderEmpty(t *testing.T) {
|
||||
ref := subscriptionRef{Address: "http://camera/sub", RefParamsXML: ""}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fc.sendSoapHeaders, 1)
|
||||
assert.Empty(t, fc.sendSoapHeaders[0], "vendors without ref params get no extra header")
|
||||
}
|
||||
|
||||
func TestPullMessages_PostsToAddressFromRef(t *testing.T) {
|
||||
ref := subscriptionRef{Address: "http://camera/specific-sub-endpoint", RefParamsXML: ""}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, fc.sendSoapCalls)
|
||||
assert.Equal(t, "http://camera/specific-sub-endpoint", fc.sendSoapCalls[0][0])
|
||||
}
|
||||
|
||||
// --- Building the header XML from raw ref params ----------------------
|
||||
|
||||
func TestBuildRefParamsHeader_AddsIsReferenceParameter(t *testing.T) {
|
||||
raw := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>` +
|
||||
`</wsa:ReferenceParameters>`
|
||||
got, err := buildRefParamsHeader(raw)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
assert.Contains(t, got, "297")
|
||||
assert.Contains(t, got, `xmlns:dom0="http://www.axis.com/2009/event"`,
|
||||
"original namespace declaration must survive")
|
||||
assert.Contains(t, got, `IsReferenceParameter="true"`)
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_MultipleTopLevelChildren(t *testing.T) {
|
||||
raw := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>` +
|
||||
`</wsa:ReferenceParameters>`
|
||||
got, err := buildRefParamsHeader(raw)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, strings.Count(got, `IsReferenceParameter="true"`),
|
||||
"attribute must be added to every top-level child, not just the first")
|
||||
assert.Contains(t, got, "Foo")
|
||||
assert.Contains(t, got, "Bar")
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_EmptyInputReturnsEmpty(t *testing.T) {
|
||||
got, err := buildRefParamsHeader("")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestUnsubscribePullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://192.168.1.10/onvif/services",
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
require.NoError(t, unsubscribePullPoint(fc, ref))
|
||||
require.Len(t, fc.sendSoapHeaders, 1)
|
||||
hdr := fc.sendSoapHeaders[0]
|
||||
assert.Contains(t, hdr, "SubscriptionId")
|
||||
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
|
||||
}
|
||||
|
||||
func TestUnsubscribePullPoint_EmptyAddressIsNoOp(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
require.NoError(t, unsubscribePullPoint(fc, subscriptionRef{}))
|
||||
assert.Empty(t, fc.sendSoapCalls, "no SOAP call should happen when there is no subscription endpoint")
|
||||
}
|
||||
|
||||
// End-to-end multi-child wiring through the production caller, not
|
||||
// just the unit-tested builder. Without the fix to addHeaderChildren
|
||||
// in Device.SendSoapWithHeader, the second child would silently
|
||||
// vanish from the wire envelope.
|
||||
func TestPullMessages_TwoRefParamsEachLandsOnTheWire(t *testing.T) {
|
||||
ref := subscriptionRef{
|
||||
Address: "http://camera/sub",
|
||||
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
|
||||
`<a:Foo xmlns:a="ns/a">1</a:Foo>` +
|
||||
`<b:Bar xmlns:b="ns/b">2</b:Bar>` +
|
||||
`</wsa:ReferenceParameters>`,
|
||||
}
|
||||
fc := newFakeCaller()
|
||||
_, err := pullMessages(fc, ref, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fc.sendSoapHeaders, 1)
|
||||
hdr := fc.sendSoapHeaders[0]
|
||||
assert.Equal(t, 2, strings.Count(hdr, `IsReferenceParameter="true"`))
|
||||
assert.Contains(t, hdr, "Foo")
|
||||
assert.Contains(t, hdr, "Bar")
|
||||
}
|
||||
|
||||
// A camera echoing our request in a fault response (some debug-mode
|
||||
// firmwares do) or a fault that includes the Security header verbatim
|
||||
// would otherwise leak the WS-Security Username/Password into operator
|
||||
// logs. The body excerpt must scrub the Security block before the
|
||||
// fault extractor and the excerpt fallback see it.
|
||||
func TestEnrichSOAPErr_RedactsWSSESecurityBlock(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Header><wsse:Security xmlns:wsse="x"><wsse:UsernameToken>
|
||||
<wsse:Username>admin</wsse:Username>
|
||||
<wsse:Password>hunter2</wsse:Password>
|
||||
</wsse:UsernameToken></wsse:Security></env:Header>
|
||||
<env:Body>plain text excerpt</env:Body>
|
||||
</env:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("400"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2", "Password must never reach logs")
|
||||
assert.NotContains(t, got.Error(), "admin", "Username must never reach logs")
|
||||
assert.Contains(t, got.Error(), "REDACTED", "redaction marker must remain visible")
|
||||
}
|
||||
|
||||
// Same vendor pattern as the enrichSOAPErr case but reached via
|
||||
// unmarshalNode → extractSOAPFault on a 200 OK response carrying a
|
||||
// Fault. Diverging from enrichSOAPErr's fallback chain would mean
|
||||
// PullMessages reports "missing PullMessagesResponse element" instead
|
||||
// of the actionable ter:InvalidArgs.
|
||||
func TestExtractSOAPFault_FallsBackToSubcodeWhenReasonEmpty(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
|
||||
<env:Body><env:Fault>
|
||||
<env:Code>
|
||||
<env:Value>env:Sender</env:Value>
|
||||
<env:Subcode><env:Value>ter:InvalidArgs</env:Value></env:Subcode>
|
||||
</env:Code>
|
||||
<env:Reason><env:Text xml:lang="en"/></env:Reason>
|
||||
</env:Fault></env:Body>
|
||||
</env:Envelope>`
|
||||
assert.Equal(t, "ter:InvalidArgs", extractSOAPFault(body))
|
||||
}
|
||||
|
||||
// WS-Addressing 1.0 Core §2.1 allows ReferenceParameters in any endpoint
|
||||
// reference (wsa:From, wsa:ReplyTo, wsa:FaultTo, ...). An unanchored
|
||||
// search would silently pick up the wrong one.
|
||||
func TestExtractReferenceParameters_AnchoredToSubscriptionReference(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Header>
|
||||
<wsa:ReplyTo>
|
||||
<wsa:Address>http://anon</wsa:Address>
|
||||
<wsa:ReferenceParameters>
|
||||
<decoy:NotTheRealOne xmlns:decoy="urn:decoy">DO-NOT-PICK</decoy:NotTheRealOne>
|
||||
</wsa:ReferenceParameters>
|
||||
</wsa:ReplyTo>
|
||||
</env:Header>
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa:Address>http://camera/sub</wsa:Address>
|
||||
<wsa:ReferenceParameters>
|
||||
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
|
||||
</wsa:ReferenceParameters>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
got := extractReferenceParameters(body)
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
assert.Contains(t, got, "297")
|
||||
assert.NotContains(t, got, "DO-NOT-PICK",
|
||||
"ref params from wsa:ReplyTo must not leak through — only SubscriptionReference's children belong on PullMessages")
|
||||
}
|
||||
|
||||
// When a vendor declares the namespace prefix on the parent
|
||||
// <ReferenceParameters> element rather than the child (legal XML, just
|
||||
// different from AXIS's shape), naïve inner-only extraction strips the
|
||||
// declaration and produces children with orphaned prefixes that fail
|
||||
// to round-trip. Inheritance must propagate ancestor xmlns onto each
|
||||
// child before serialisation.
|
||||
func TestBuildRefParamsHeader_InheritsParentXmlns(t *testing.T) {
|
||||
parentScopedXmlns := `<wsa:ReferenceParameters xmlns:dom0="urn:vendor:axis">` +
|
||||
`<dom0:SubscriptionId>297</dom0:SubscriptionId>` +
|
||||
`</wsa:ReferenceParameters>`
|
||||
got, err := buildRefParamsHeader(parentScopedXmlns)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, got, "ReferenceParameters",
|
||||
"the wrapping element must not appear in output — each param child is its own header block")
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
assert.Contains(t, got, "297")
|
||||
assert.Contains(t, got, `xmlns:dom0="urn:vendor:axis"`,
|
||||
"the dom0 prefix is undeclared on the child itself — it must be inherited from the parent so the standalone child stays valid XML")
|
||||
assert.Contains(t, got, `IsReferenceParameter="true"`)
|
||||
}
|
||||
|
||||
// Pins the contract change: extractReferenceParameters returns the
|
||||
// full <ReferenceParameters> element (including its own attributes),
|
||||
// not just the inner content, so parent-scoped xmlns survives into
|
||||
// buildRefParamsHeader.
|
||||
func TestExtractReferenceParameters_IncludesParentElementForXmlnsPreservation(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa:Address>http://camera</wsa:Address>
|
||||
<wsa:ReferenceParameters xmlns:dom0="urn:vendor:axis">
|
||||
<dom0:SubscriptionId>297</dom0:SubscriptionId>
|
||||
</wsa:ReferenceParameters>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
got := extractReferenceParameters(body)
|
||||
assert.Contains(t, got, "ReferenceParameters",
|
||||
"extractor must include the wrapping element so parent-scoped xmlns survives")
|
||||
assert.Contains(t, got, `xmlns:dom0="urn:vendor:axis"`)
|
||||
assert.Contains(t, got, "SubscriptionId")
|
||||
}
|
||||
|
||||
// --- Camera-granted TerminationTime -----------------------------------
|
||||
//
|
||||
// Cameras may grant a shorter subscription than we ask for. Scheduling
|
||||
// the next renew from opts.InitialTermination instead of what the
|
||||
// camera actually granted leads to expired subscriptions and the
|
||||
// recreate-recovery path firing unnecessarily.
|
||||
|
||||
func TestCreatePullPoint_CapturesGrantedTermination(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference><wsa:Address>http://camera/sub</wsa:Address></tev:SubscriptionReference>
|
||||
<wsnt:CurrentTime>2026-05-27T13:19:11Z</wsnt:CurrentTime>
|
||||
<wsnt:TerminationTime>2026-05-27T13:21:11Z</wsnt:TerminationTime>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(body, nil)
|
||||
ref, err := createPullPoint(fc, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
expected, _ := time.Parse(time.RFC3339, "2026-05-27T13:21:11Z")
|
||||
assert.Equal(t, expected, ref.GrantedTermination)
|
||||
}
|
||||
|
||||
func TestCreatePullPoint_NoTerminationTimeYieldsZeroTime(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference><wsa:Address>http://camera/sub</wsa:Address></tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(body, nil)
|
||||
ref, err := createPullPoint(fc, defaultOptions())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ref.GrantedTermination.IsZero(),
|
||||
"absent TerminationTime must yield zero so renew falls back to opts")
|
||||
}
|
||||
|
||||
func TestNextRenewInterval_UsesGrantedTerminationMinusMargin(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
granted := now.Add(60 * time.Second)
|
||||
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
|
||||
assert.Equal(t, 50*time.Second, nextRenewInterval(granted, opts, now))
|
||||
}
|
||||
|
||||
func TestNextRenewInterval_FallsBackToInitialTerminationWhenGrantedZero(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
|
||||
assert.Equal(t, 50*time.Second, nextRenewInterval(time.Time{}, opts, now))
|
||||
}
|
||||
|
||||
func TestNextRenewInterval_FloorsAtOneSecondIfAlreadyExpired(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
granted := now.Add(-1 * time.Second) // camera says we're already expired
|
||||
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
|
||||
assert.Equal(t, time.Second, nextRenewInterval(granted, opts, now),
|
||||
"never sleep zero or negative — recreate-recovery handles the truly-dead case")
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_MalformedXMLReturnsError(t *testing.T) {
|
||||
_, err := buildRefParamsHeader("<not-closed")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_WhitespaceOnlyReturnsEmpty(t *testing.T) {
|
||||
got, err := buildRefParamsHeader(" \n\t ")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
// Worst case: the response body's <Security> block starts within the
|
||||
// 64 KiB error cap but its </Security> is past it. The non-greedy
|
||||
// regex needs a close tag — without one the redaction misses and
|
||||
// raw Username/Password reaches the excerpt. Verify the helper
|
||||
// strips from <Security to EOF when no close tag is present.
|
||||
func TestEnrichSOAPErr_RedactsSecurityBlockMissingCloseTag(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x"><env:Header>` +
|
||||
`<wsse:Security xmlns:wsse="y">` +
|
||||
`<wsse:Username>admin</wsse:Username>` +
|
||||
`<wsse:Password>hunter2</wsse:Password>` +
|
||||
// no </wsse:Security> — simulates a Security block truncated
|
||||
// at the 64 KiB read cap.
|
||||
strings.Repeat("padding ", 1000)
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2",
|
||||
"truncated Security block must not leak Password to the excerpt")
|
||||
assert.NotContains(t, got.Error(), "admin",
|
||||
"truncated Security block must not leak Username to the excerpt")
|
||||
}
|
||||
|
||||
// The wrapper-only contract means an element whose local name merely
|
||||
// ends in "ReferenceParameters" cannot be mistaken for the wrapper —
|
||||
// the root must be exactly <*:ReferenceParameters>. Anything else is
|
||||
// a contract violation by the caller and surfaces as an error.
|
||||
func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot(t *testing.T) {
|
||||
raw := `<my:MyReferenceParameters xmlns:my="urn:vendor:my">X</my:MyReferenceParameters>`
|
||||
_, err := buildRefParamsHeader(raw)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ReferenceParameters")
|
||||
}
|
||||
|
||||
// A non-conformant camera echoing UsernameToken/Password outside a
|
||||
// <Security> wrapper would still leak credentials through the body
|
||||
// excerpt. Belt-and-braces: redact Password elements directly too.
|
||||
func TestEnrichSOAPErr_RedactsBarePasswordElement(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x"><env:Body>` +
|
||||
`<wsse:UsernameToken xmlns:wsse="y">` +
|
||||
`<wsse:Username>admin</wsse:Username>` +
|
||||
`<wsse:Password>hunter2</wsse:Password>` +
|
||||
`</wsse:UsernameToken>` +
|
||||
`</env:Body></env:Envelope>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("400"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2",
|
||||
"Password must be redacted regardless of whether it's wrapped in Security")
|
||||
}
|
||||
|
||||
// --- Edge-case coverage flagged in review -----------------------------
|
||||
|
||||
func TestExtractTerminationTime_MalformedDateYieldsZero(t *testing.T) {
|
||||
body := `<env:Body><wsnt:TerminationTime>not-a-date</wsnt:TerminationTime></env:Body>`
|
||||
assert.True(t, extractTerminationTime(body).IsZero(),
|
||||
"unparseable datetime must not panic and must not return a garbage time — fall back to opts")
|
||||
}
|
||||
|
||||
func TestNextRenewInterval_MarginEqualsBaseFallsToHalf(t *testing.T) {
|
||||
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
|
||||
opts := Options{InitialTermination: 30 * time.Second, RenewMargin: 30 * time.Second}
|
||||
got := nextRenewInterval(time.Time{}, opts, now)
|
||||
assert.Equal(t, 15*time.Second, got,
|
||||
"when margin == base, the helper must fall through to base/2 rather than the 1s floor")
|
||||
}
|
||||
|
||||
func TestExtractReferenceParameters_EmptySubscriptionReferenceReturnsEmpty(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x" xmlns:tev="y">
|
||||
<env:Body><tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference/>
|
||||
</tev:CreatePullPointSubscriptionResponse></env:Body>
|
||||
</env:Envelope>`
|
||||
assert.Empty(t, extractReferenceParameters(body))
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_RedactsTruncatedPasswordOutsideSecurity(t *testing.T) {
|
||||
body := `<env:Envelope xmlns:env="x"><env:Body>` +
|
||||
`<wsse:UsernameToken xmlns:wsse="y">` +
|
||||
`<wsse:Username>admin</wsse:Username>` +
|
||||
`<wsse:Password>hunter2` // truncated — no </Password>, no </UsernameToken>, no </Envelope>
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "hunter2",
|
||||
"Password without a closing tag (truncated at cap) must still be redacted")
|
||||
}
|
||||
|
||||
func TestEnrichSOAPErr_RedactsMultipleSecurityBlocks(t *testing.T) {
|
||||
body := `<E>` +
|
||||
`<wsse:Security xmlns:wsse="y"><wsse:Password>secret1</wsse:Password></wsse:Security>` +
|
||||
`<wsse:Security xmlns:wsse="y"><wsse:Password>secret2</wsse:Password></wsse:Security>` +
|
||||
`</E>`
|
||||
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
|
||||
require.Error(t, got)
|
||||
assert.NotContains(t, got.Error(), "secret1")
|
||||
assert.NotContains(t, got.Error(), "secret2",
|
||||
"ReplaceAllString must catch every Security block, not just the first")
|
||||
}
|
||||
|
||||
func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot_Table(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, raw string
|
||||
}{
|
||||
{"vendor suffix", `<my:MyReferenceParameters xmlns:my="urn:x">X</my:MyReferenceParameters>`},
|
||||
{"multi-root", `<a:Foo xmlns:a="ns/a"/><b:Bar xmlns:b="ns/b"/>`},
|
||||
{"unrelated element", `<not-the-wrapper>content</not-the-wrapper>`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := buildRefParamsHeader(c.raw)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
348
event/stream/stream.go
Normal file
348
event/stream/stream.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
)
|
||||
|
||||
// closeDrainTimeout bounds Close's wait for the pull and renew
|
||||
// goroutines to exit. The loops block in caller.SendSoap which is not
|
||||
// ctx-aware (the underlying http.Client is the only thing that can
|
||||
// unblock them — see caller below). On a hung HTTP transport Close
|
||||
// would otherwise wait forever; instead it returns an error and lets
|
||||
// the calling agent move on.
|
||||
const closeDrainTimeout = 5 * time.Second
|
||||
|
||||
// closeUnsubscribeTimeout bounds the Unsubscribe SOAP call issued by
|
||||
// Close. A subscription expires at the camera once InitialTermination
|
||||
// elapses without a renew, so a missed unsubscribe is at worst
|
||||
// cosmetic.
|
||||
const closeUnsubscribeTimeout = 5 * time.Second
|
||||
|
||||
// Options configures a Stream.
|
||||
//
|
||||
// Zero-value policy: every duration / int field treats zero as "use
|
||||
// the default". To opt out of reconnect set DisableReconnect=true
|
||||
// (ReconnectAfterFailures=0 would otherwise collide with the default
|
||||
// injection). For unbuffered Events / Errors channels set
|
||||
// BufferSize=-1.
|
||||
type Options struct {
|
||||
DeviceID string
|
||||
// RawTopicFilter is the ONVIF ConcreteSet TopicExpression filter
|
||||
// passed verbatim to CreatePullPointSubscription. Callers should
|
||||
// normally leave this empty and rely on Classify for routing —
|
||||
// server-side filtering is fragile across vendors and empty is
|
||||
// required for AXIS.
|
||||
RawTopicFilter string
|
||||
// PullTimeout — zero means default (5s). The device's
|
||||
// http.Client.Timeout must exceed this by minClientHeadroom or
|
||||
// NewStream returns ErrInvalidOptions.
|
||||
PullTimeout time.Duration
|
||||
// MessageLimit — zero means default (32). Busy AXIS cameras with
|
||||
// many configured rules can burst beyond 10 per pull.
|
||||
MessageLimit int
|
||||
// InitialTermination — zero means default (60s).
|
||||
InitialTermination time.Duration
|
||||
// RenewMargin — larger margins tolerate slower networks at the
|
||||
// cost of more renew calls. Zero means default (10s).
|
||||
RenewMargin time.Duration
|
||||
// ReconnectAfterFailures — pull-points die for many reasons
|
||||
// (camera reboot, subscription GC after a renew miss, NAT
|
||||
// timeout); rebuilding the subscription is the only reliable
|
||||
// recovery. Zero means default (3). Set DisableReconnect=true
|
||||
// to disable.
|
||||
ReconnectAfterFailures int
|
||||
// DisableReconnect makes the pull loop retry against the
|
||||
// original endpoint until ctx is cancelled.
|
||||
DisableReconnect bool
|
||||
// RetryBackoff is the base sleep between pull/recreate failures.
|
||||
// Recreate failures double this up to maxRecreateBackoff. Zero
|
||||
// means default (1s).
|
||||
RetryBackoff time.Duration
|
||||
// BufferSize — zero means default (16); use -1 for unbuffered.
|
||||
BufferSize int
|
||||
}
|
||||
|
||||
func defaultOptions() Options {
|
||||
return Options{
|
||||
PullTimeout: 5 * time.Second,
|
||||
MessageLimit: 32,
|
||||
InitialTermination: 60 * time.Second,
|
||||
RenewMargin: 10 * time.Second,
|
||||
ReconnectAfterFailures: 3,
|
||||
RetryBackoff: time.Second,
|
||||
BufferSize: 16,
|
||||
}
|
||||
}
|
||||
|
||||
func (o Options) withDefaults() Options {
|
||||
d := defaultOptions()
|
||||
if o.PullTimeout > 0 {
|
||||
d.PullTimeout = o.PullTimeout
|
||||
}
|
||||
if o.MessageLimit > 0 {
|
||||
d.MessageLimit = o.MessageLimit
|
||||
}
|
||||
if o.InitialTermination > 0 {
|
||||
d.InitialTermination = o.InitialTermination
|
||||
}
|
||||
if o.RenewMargin > 0 {
|
||||
d.RenewMargin = o.RenewMargin
|
||||
}
|
||||
if o.ReconnectAfterFailures > 0 {
|
||||
d.ReconnectAfterFailures = o.ReconnectAfterFailures
|
||||
}
|
||||
if o.RetryBackoff > 0 {
|
||||
d.RetryBackoff = o.RetryBackoff
|
||||
}
|
||||
switch {
|
||||
case o.BufferSize > 0:
|
||||
d.BufferSize = o.BufferSize
|
||||
case o.BufferSize < 0:
|
||||
d.BufferSize = 0
|
||||
}
|
||||
d.DeviceID = o.DeviceID
|
||||
d.RawTopicFilter = o.RawTopicFilter
|
||||
d.DisableReconnect = o.DisableReconnect
|
||||
return d
|
||||
}
|
||||
|
||||
// subscriptionRef holds the result of CreatePullPointSubscription.
|
||||
// AXIS encodes the subscription identity in RefParamsXML (a generic
|
||||
// /onvif/services Address plus a <wsa:ReferenceParameters> child);
|
||||
// other vendors put the identity in the Address itself, leaving
|
||||
// RefParamsXML empty. Subscription-scoped requests must echo a
|
||||
// non-empty RefParamsXML — see extractReferenceParameters.
|
||||
//
|
||||
// GrantedTermination is the absolute time the camera says the
|
||||
// subscription will expire if not renewed. May be less than
|
||||
// requested; renewLoop schedules from this rather than opts.
|
||||
type subscriptionRef struct {
|
||||
Address string
|
||||
RefParamsXML string
|
||||
GrantedTermination time.Time
|
||||
}
|
||||
|
||||
// caller is the *onvif.Device subset Stream depends on. Implementations
|
||||
// must:
|
||||
//
|
||||
// - Be safe for concurrent use — pull and renew goroutines call in
|
||||
// from separate goroutines. *onvif.Device satisfies this via
|
||||
// http.Client.
|
||||
// - Enforce a per-request timeout via the underlying HTTP client.
|
||||
// The methods do not take a ctx, so ctx-cancel cannot interrupt a
|
||||
// hung request; only the HTTP client's own timeout can. Close
|
||||
// bounds its drain wait at closeDrainTimeout to survive a misbehaving
|
||||
// caller, but a leaking goroutine remains until the HTTP call
|
||||
// eventually returns.
|
||||
type caller interface {
|
||||
CallMethod(method any) (*http.Response, error)
|
||||
SendSoap(endpoint, body string) (*http.Response, error)
|
||||
SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error)
|
||||
}
|
||||
|
||||
type deviceCaller struct{ dev *onvif.Device }
|
||||
|
||||
func (d deviceCaller) CallMethod(m any) (*http.Response, error) {
|
||||
return d.dev.CallMethod(m)
|
||||
}
|
||||
|
||||
func (d deviceCaller) SendSoap(endpoint, body string) (*http.Response, error) {
|
||||
return d.dev.SendSoap(endpoint, body)
|
||||
}
|
||||
|
||||
func (d deviceCaller) SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error) {
|
||||
return d.dev.SendSoapWithHeader(endpoint, body, headerXML)
|
||||
}
|
||||
|
||||
// Stream owns a single ONVIF pull-point subscription. Safe for Close
|
||||
// from any goroutine while readers consume Events / Errors. Close is
|
||||
// idempotent.
|
||||
type Stream struct {
|
||||
caller caller
|
||||
opts Options
|
||||
|
||||
pullPointMu sync.Mutex // guards pullPoint and gen
|
||||
pullPoint subscriptionRef
|
||||
gen uint64 // bumped on every setPullPoint so renews detect mid-flight recreate
|
||||
|
||||
events chan Event
|
||||
errors chan error
|
||||
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
|
||||
// now is overridable so tests can make timestamps deterministic.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Stream) getPullPoint() subscriptionRef {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
return s.pullPoint
|
||||
}
|
||||
|
||||
func (s *Stream) setPullPoint(ref subscriptionRef) {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
s.pullPoint = ref
|
||||
s.gen++
|
||||
}
|
||||
|
||||
// pullPointGen returns the current generation. Pair with
|
||||
// updateGrantedTerminationIfGen so a renew result issued against a
|
||||
// subscription that was rotated mid-flight (recreate path) is
|
||||
// discarded instead of overwriting the new subscription's grant.
|
||||
func (s *Stream) pullPointGen() uint64 {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
return s.gen
|
||||
}
|
||||
|
||||
// snapshotPullPoint reads ref + gen under one lock so a concurrent
|
||||
// setPullPoint can't slip in between two separate accessor calls and
|
||||
// leave the caller with mismatched halves.
|
||||
func (s *Stream) snapshotPullPoint() (subscriptionRef, uint64) {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
return s.pullPoint, s.gen
|
||||
}
|
||||
|
||||
// updateGrantedTerminationIfGen writes the granted time only when the
|
||||
// caller's snapshot is still current. Use setPullPoint to replace the
|
||||
// full ref; this updates GrantedTermination in place after a renew
|
||||
// response. Intentionally does not bump gen — that would defeat the
|
||||
// rotation-detection it implements.
|
||||
func (s *Stream) updateGrantedTerminationIfGen(gen uint64, t time.Time) {
|
||||
s.pullPointMu.Lock()
|
||||
defer s.pullPointMu.Unlock()
|
||||
if s.gen != gen {
|
||||
return
|
||||
}
|
||||
s.pullPoint.GrantedTermination = t
|
||||
}
|
||||
|
||||
// NewStream creates a Stream and performs CreatePullPointSubscription
|
||||
// synchronously so connectivity and authentication failures surface
|
||||
// from NewStream rather than landing on Errors later.
|
||||
//
|
||||
// The returned Stream stops when ctx is cancelled or Close is called.
|
||||
func NewStream(ctx context.Context, dev *onvif.Device, opts Options) (*Stream, error) {
|
||||
// Checked before the subscription call: this config can only fail,
|
||||
// so surfacing it here beats a stream that appears to work and
|
||||
// silently survives on reconnects alone.
|
||||
if err := validateClientTimeout(clientTimeoutOf(dev), opts.withDefaults().PullTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStream(ctx, deviceCaller{dev: dev}, opts)
|
||||
}
|
||||
|
||||
func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) {
|
||||
opts = opts.withDefaults()
|
||||
ref, err := createPullPoint(c, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pull point subscription: %w", err)
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
s := &Stream{
|
||||
caller: c,
|
||||
opts: opts,
|
||||
pullPoint: ref,
|
||||
events: make(chan Event, opts.BufferSize),
|
||||
errors: make(chan error, opts.BufferSize),
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
now: time.Now,
|
||||
}
|
||||
go s.run(runCtx)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Events returns the channel of decoded notifications. Closed when
|
||||
// the Stream stops.
|
||||
func (s *Stream) Events() <-chan Event { return s.events }
|
||||
|
||||
// Errors returns the channel of non-fatal errors. Sends are
|
||||
// non-blocking; consumers that fall behind drop older errors. Closed
|
||||
// when the Stream stops.
|
||||
func (s *Stream) Errors() <-chan error { return s.errors }
|
||||
|
||||
// Close stops the background goroutines, waits up to closeDrainTimeout
|
||||
// for them to exit, and then Unsubscribes from the camera (also bounded,
|
||||
// by closeUnsubscribeTimeout). Subsequent calls are no-ops.
|
||||
//
|
||||
// If the drain times out the goroutines are likely wedged inside a
|
||||
// non-ctx-aware caller.SendSoap; they will exit on their own once the
|
||||
// HTTP call returns. Unsubscribe is skipped in that case — the
|
||||
// subscription expires at the camera anyway.
|
||||
func (s *Stream) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.cancel()
|
||||
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(closeDrainTimeout):
|
||||
s.closeErr = fmt.Errorf("close: pull/renew loops did not drain within %s (likely stuck in caller HTTP)", closeDrainTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- unsubscribePullPoint(s.caller, s.getPullPoint())
|
||||
}()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err)
|
||||
}
|
||||
case <-time.After(closeUnsubscribeTimeout):
|
||||
s.closeErr = fmt.Errorf("unsubscribe pull point: timeout after %s", closeUnsubscribeTimeout)
|
||||
}
|
||||
})
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
func (s *Stream) run(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.renewLoop(ctx)
|
||||
}()
|
||||
s.pullLoop(ctx)
|
||||
wg.Wait()
|
||||
|
||||
// Explicit close order after both goroutines have exited so a
|
||||
// future maintainer extending this function does not rely on
|
||||
// defer-ordering for channel-close safety.
|
||||
close(s.errors)
|
||||
close(s.events)
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
func (s *Stream) surfaceError(err error) {
|
||||
select {
|
||||
case s.errors <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// sleepCtx returns false if ctx was cancelled, true if d elapsed.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
512
event/stream/stream_test.go
Normal file
512
event/stream/stream_test.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- fakeCaller --------------------------------------------------------
|
||||
|
||||
// fakeCaller is a test double for the caller interface. Each method
|
||||
// returns the next queued response; when the queue is exhausted it falls
|
||||
// back to a default response so the indefinite pull loop does not
|
||||
// require tests to enumerate every call.
|
||||
//
|
||||
// blockUnsubscribe, when non-nil, causes SendSoap calls whose body
|
||||
// contains "Unsubscribe" to block until the channel is closed.
|
||||
// blockAllSendSoap, when non-nil, blocks every SendSoap call until
|
||||
// closed (simulates a hung HTTP transport).
|
||||
type fakeCaller struct {
|
||||
mu sync.Mutex
|
||||
callMethodResps []fakeResp
|
||||
sendSoapResps []fakeResp
|
||||
defaultSendSoap fakeResp
|
||||
defaultCall fakeResp
|
||||
callMethodCalls []any
|
||||
sendSoapCalls [][2]string
|
||||
sendSoapHeaders []string
|
||||
blockUnsubscribe chan struct{}
|
||||
blockAllSendSoap chan struct{}
|
||||
}
|
||||
|
||||
type fakeResp struct {
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
func newFakeCaller() *fakeCaller {
|
||||
return &fakeCaller{
|
||||
// Default: indefinite empty pulls, indefinite OK unsubscribes.
|
||||
defaultSendSoap: fakeResp{body: pullMessagesResp()},
|
||||
defaultCall: fakeResp{err: errors.New("fakeCaller: no default CallMethod response")},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeCaller) queueCallMethod(body string, err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.callMethodResps = append(f.callMethodResps, fakeResp{body: body, err: err})
|
||||
}
|
||||
|
||||
func (f *fakeCaller) queueSendSoap(body string, err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.sendSoapResps = append(f.sendSoapResps, fakeResp{body: body, err: err})
|
||||
}
|
||||
|
||||
func (f *fakeCaller) CallMethod(m any) (*http.Response, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.callMethodCalls = append(f.callMethodCalls, m)
|
||||
r := f.defaultCall
|
||||
if len(f.callMethodResps) > 0 {
|
||||
r = f.callMethodResps[0]
|
||||
f.callMethodResps = f.callMethodResps[1:]
|
||||
}
|
||||
// Mirror networking.SendSoap*: a 4xx/5xx returns body alongside
|
||||
// err. Tests opt into that shape by queueing body + err together.
|
||||
if r.err != nil && r.body == "" {
|
||||
return nil, r.err
|
||||
}
|
||||
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
|
||||
}
|
||||
|
||||
func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) {
|
||||
f.mu.Lock()
|
||||
f.sendSoapCalls = append(f.sendSoapCalls, [2]string{endpoint, body})
|
||||
r := f.defaultSendSoap
|
||||
if len(f.sendSoapResps) > 0 {
|
||||
r = f.sendSoapResps[0]
|
||||
f.sendSoapResps = f.sendSoapResps[1:]
|
||||
}
|
||||
block := f.blockUnsubscribe
|
||||
blockAll := f.blockAllSendSoap
|
||||
f.mu.Unlock()
|
||||
|
||||
if blockAll != nil {
|
||||
<-blockAll
|
||||
}
|
||||
if block != nil && strings.Contains(body, "Unsubscribe") {
|
||||
<-block
|
||||
}
|
||||
|
||||
if r.err != nil && r.body == "" {
|
||||
return nil, r.err
|
||||
}
|
||||
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
|
||||
}
|
||||
|
||||
// SendSoapWithHeader delegates body+endpoint recording to SendSoap so
|
||||
// existing assertions on sendSoapCalls keep working, and records the
|
||||
// header XML in a parallel slice for ref-params wiring tests.
|
||||
func (f *fakeCaller) SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error) {
|
||||
f.mu.Lock()
|
||||
f.sendSoapHeaders = append(f.sendSoapHeaders, headerXML)
|
||||
f.mu.Unlock()
|
||||
return f.SendSoap(endpoint, body)
|
||||
}
|
||||
|
||||
func (f *fakeCaller) sendSoapCallCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.sendSoapCalls)
|
||||
}
|
||||
|
||||
// --- fixture SOAP envelopes -------------------------------------------
|
||||
|
||||
// createPullPointResp is the minimal SOAP envelope the lib's existing
|
||||
// xml.Decoder + getXMLNode path can extract a pull-point address from.
|
||||
// Intentionally omits <TerminationTime> so renewLoop falls back to
|
||||
// opts.InitialTermination — tests that drive renew timing depend on
|
||||
// that path. Tests that need the camera-granted termination capture
|
||||
// path use a dedicated fixture instead.
|
||||
const createPullPointResp = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsa="http://www.w3.org/2005/08/addressing"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
|
||||
<env:Body>
|
||||
<tev:CreatePullPointSubscriptionResponse>
|
||||
<tev:SubscriptionReference>
|
||||
<wsa:Address>http://camera.local/onvif/Events/PullSub_1</wsa:Address>
|
||||
</tev:SubscriptionReference>
|
||||
</tev:CreatePullPointSubscriptionResponse>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
|
||||
func pullMessagesResp(messages ...string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:tev="http://www.onvif.org/ver10/events/wsdl"
|
||||
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"
|
||||
xmlns:tt="http://www.onvif.org/ver10/schema">
|
||||
<env:Body>
|
||||
<tev:PullMessagesResponse>
|
||||
<tev:CurrentTime>2026-05-21T10:30:05Z</tev:CurrentTime>
|
||||
<tev:TerminationTime>2026-05-21T10:31:05Z</tev:TerminationTime>
|
||||
` + strings.Join(messages, "\n") + `
|
||||
</tev:PullMessagesResponse>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
}
|
||||
|
||||
func motionMsg(value string) string {
|
||||
return `<wsnt:NotificationMessage>
|
||||
<wsnt:Topic Dialect="http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet">tns1:RuleEngine/CellMotionDetector/Motion</wsnt:Topic>
|
||||
<wsnt:Message>
|
||||
<tt:Message PropertyOperation="Changed" UtcTime="2026-05-21T10:30:00Z">
|
||||
<tt:Source>
|
||||
<tt:SimpleItem Name="VideoSourceConfigurationToken" Value="VSC0"/>
|
||||
</tt:Source>
|
||||
<tt:Data>
|
||||
<tt:SimpleItem Name="IsMotion" Value="` + value + `"/>
|
||||
</tt:Data>
|
||||
</tt:Message>
|
||||
</wsnt:Message>
|
||||
</wsnt:NotificationMessage>`
|
||||
}
|
||||
|
||||
const unsubscribeResp = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2">
|
||||
<env:Body>
|
||||
<wsnt:UnsubscribeResponse/>
|
||||
</env:Body>
|
||||
</env:Envelope>`
|
||||
|
||||
// --- helpers -----------------------------------------------------------
|
||||
|
||||
// receive waits up to d for an event on ch, failing the test if none
|
||||
// arrives.
|
||||
func receive(t *testing.T, ch <-chan Event, d time.Duration) Event {
|
||||
t.Helper()
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatalf("event channel closed before receiving")
|
||||
}
|
||||
return ev
|
||||
case <-time.After(d):
|
||||
t.Fatalf("timed out waiting for event after %s", d)
|
||||
}
|
||||
return Event{} // unreachable
|
||||
}
|
||||
|
||||
// --- tests -------------------------------------------------------------
|
||||
|
||||
func TestNewStream_CreatesPullPointAtConstruction(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
// Queue an empty pull so the run loop can spin without exploding.
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
// CreatePullPointSubscription was called exactly once.
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
require.Len(t, fc.callMethodCalls, 1, "expected one CallMethod call (CreatePullPointSubscription)")
|
||||
}
|
||||
|
||||
func TestStream_DeliversDecodedEvents(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
|
||||
// Provide subsequent empty pulls so the loop doesn't starve before Close.
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
ev := receive(t, s.Events(), 2*time.Second)
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
assert.Equal(t, StateActive, ev.State)
|
||||
assert.Equal(t, "cam-1", ev.DeviceID)
|
||||
assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic)
|
||||
assert.Equal(t, "VSC0", ev.Source["VideoSourceConfigurationToken"])
|
||||
assert.Equal(t, "true", ev.Data["IsMotion"])
|
||||
}
|
||||
|
||||
func TestStream_PullsAgainstSubscriptionAddress(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait until at least one pull happened, then close.
|
||||
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
fc.mu.Lock()
|
||||
defer fc.mu.Unlock()
|
||||
require.NotEmpty(t, fc.sendSoapCalls, "expected at least one PullMessages SendSoap call")
|
||||
endpoint := fc.sendSoapCalls[0][0]
|
||||
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", endpoint,
|
||||
"PullMessages must target the SubscriptionReference Address returned by CreatePullPoint")
|
||||
// Last call (Close) should target the same endpoint with an Unsubscribe body.
|
||||
last := fc.sendSoapCalls[len(fc.sendSoapCalls)-1]
|
||||
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", last[0])
|
||||
assert.Contains(t, last[1], "Unsubscribe")
|
||||
}
|
||||
|
||||
func TestNewStream_ReturnsErrorWhenCreatePullPointFails(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod("", errors.New("network down"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, s)
|
||||
}
|
||||
|
||||
func TestStream_ClosedContextStopsRunLoop(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
// Many empty pulls so the loop is hot when we cancel.
|
||||
for i := 0; i < 20; i++ {
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
}
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for at least one pull.
|
||||
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
|
||||
// Close should still complete cleanly; the goroutine must drain.
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
// Events channel must close so consumers can range-loop safely.
|
||||
select {
|
||||
case _, ok := <-s.Events():
|
||||
assert.False(t, ok, "Events channel should be closed after Close()")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Events channel was not closed within 1s")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStream_PullErrorSurfacedOnErrorsChannel(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.queueSendSoap("", errors.New("transient pull failure"))
|
||||
// Then a clean pull so the loop keeps running.
|
||||
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
select {
|
||||
case e := <-s.Errors():
|
||||
assert.Contains(t, e.Error(), "transient pull failure")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected an error on the Errors channel")
|
||||
}
|
||||
// After the transient failure the loop continued and decoded.
|
||||
ev := receive(t, s.Events(), 2*time.Second)
|
||||
assert.Equal(t, KindMotion, ev.Kind)
|
||||
}
|
||||
|
||||
func TestStream_OptionsApplyDefaults(t *testing.T) {
|
||||
o := defaultOptions()
|
||||
assert.Equal(t, 5*time.Second, o.PullTimeout)
|
||||
assert.Equal(t, 32, o.MessageLimit)
|
||||
assert.Equal(t, 60*time.Second, o.InitialTermination)
|
||||
assert.Equal(t, 16, o.BufferSize)
|
||||
}
|
||||
|
||||
func TestStream_DoesNotPanicOnPullExitingDuringClose(t *testing.T) {
|
||||
// Regression guard: Close should not race with the run goroutine
|
||||
// in a way that double-closes the events/errors channels.
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
for i := 0; i < 5; i++ {
|
||||
fc.queueSendSoap(pullMessagesResp(), nil)
|
||||
}
|
||||
fc.queueSendSoap(unsubscribeResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
|
||||
require.NoError(t, err)
|
||||
assert.NotPanics(t, func() {
|
||||
require.NoError(t, s.Close())
|
||||
// Double-close should be a no-op, not a panic.
|
||||
_ = s.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// --- Close error / timeout paths -------------------------------------
|
||||
|
||||
func TestClose_ReturnsUnsubscribeError(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
fc.mu.Lock()
|
||||
fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")}
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = s.Close()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsubscribe pull point")
|
||||
assert.Contains(t, err.Error(), "simulated transport failure")
|
||||
}
|
||||
|
||||
func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
block := make(chan struct{})
|
||||
defer close(block) // release the hung Unsubscribe so the fake's goroutine exits
|
||||
fc.mu.Lock()
|
||||
fc.blockUnsubscribe = block
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
|
||||
require.NoError(t, err)
|
||||
|
||||
start := time.Now()
|
||||
err = s.Close()
|
||||
elapsed := time.Since(start)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "timeout")
|
||||
assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second,
|
||||
"Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout)
|
||||
}
|
||||
|
||||
// --- NewStream edge cases --------------------------------------------
|
||||
|
||||
func TestNewStream_CtxAlreadyCancelled(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel before NewStream
|
||||
|
||||
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, s)
|
||||
|
||||
select {
|
||||
case _, ok := <-s.Events():
|
||||
assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("events channel was not closed within 1s")
|
||||
}
|
||||
_ = s.Close()
|
||||
}
|
||||
|
||||
// --- fakeCaller self-test --------------------------------------------
|
||||
|
||||
func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) {
|
||||
fc := newFakeCaller()
|
||||
fc.queueSendSoap("first", nil)
|
||||
fc.queueSendSoap("second", nil)
|
||||
|
||||
r1, err := fc.SendSoap("ep", "body")
|
||||
require.NoError(t, err)
|
||||
b1 := make([]byte, 10)
|
||||
n, _ := r1.Body.Read(b1)
|
||||
assert.Equal(t, "first", string(b1[:n]))
|
||||
|
||||
r2, _ := fc.SendSoap("ep", "body")
|
||||
b2 := make([]byte, 10)
|
||||
n, _ = r2.Body.Read(b2)
|
||||
assert.Equal(t, "second", string(b2[:n]))
|
||||
|
||||
// Queue is exhausted; default kicks in.
|
||||
r3, err := fc.SendSoap("ep", "body")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, r3)
|
||||
b3 := make([]byte, 2048)
|
||||
n, _ = r3.Body.Read(b3)
|
||||
assert.Contains(t, string(b3[:n]), "PullMessagesResponse",
|
||||
"default SendSoap should be an empty PullMessagesResponse envelope")
|
||||
}
|
||||
|
||||
func TestClose_BoundedWhenLoopsStuckOnHungHTTP(t *testing.T) {
|
||||
// Simulates a hung HTTP transport: every SendSoap blocks
|
||||
// indefinitely. The pull and renew loops are wedged inside
|
||||
// SendSoap and ctx-cancel cannot unblock them. Close must still
|
||||
// return within its bounded budget so the agent's shutdown does
|
||||
// not hang.
|
||||
fc := newFakeCaller()
|
||||
fc.queueCallMethod(createPullPointResp, nil)
|
||||
blockAll := make(chan struct{})
|
||||
defer close(blockAll)
|
||||
fc.mu.Lock()
|
||||
fc.blockAllSendSoap = blockAll
|
||||
fc.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
s, err := newStream(ctx, fc, Options{
|
||||
PullTimeout: 100 * time.Millisecond,
|
||||
InitialTermination: 30 * time.Second,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait until pullLoop is actually parked inside the blocked
|
||||
// SendSoap. Without this, Close races with the loop's first
|
||||
// iteration and exits via the ctx pre-check instead of
|
||||
// exercising the drain-timeout path.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) && fc.sendSoapCallCount() == 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
require.GreaterOrEqual(t, fc.sendSoapCallCount(), 1, "pullLoop never reached SendSoap")
|
||||
|
||||
start := time.Now()
|
||||
err = s.Close()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "drain", "expected a drain-timeout error")
|
||||
// Total budget is closeDrainTimeout for the wait + ~0 for unsubscribe
|
||||
// (which is skipped when drain times out). Give plenty of slack for
|
||||
// scheduling on a loaded CI machine.
|
||||
assert.Less(t, elapsed, closeDrainTimeout+2*time.Second,
|
||||
"Close exceeded bound (%s); expected ~%s", elapsed, closeDrainTimeout)
|
||||
}
|
||||
152
event/stream/topics.go
Normal file
152
event/stream/topics.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package stream
|
||||
|
||||
import "strings"
|
||||
|
||||
// Classify maps an ONVIF topic string to the normalized Kind. Returns
|
||||
// KindUnknown when no rule matches.
|
||||
//
|
||||
// The classifier strips XML-namespace prefixes from each "/"-separated
|
||||
// segment so it is robust to vendor namespaces (tns1:, tnsaxis:,
|
||||
// tnssamsung:, ...). Matching is case-sensitive — ONVIF topics are
|
||||
// case-sensitive per the spec.
|
||||
//
|
||||
// Sources cross-checked when building the rule set below:
|
||||
// - ONVIF Topic Namespace XML
|
||||
// https://www.onvif.org/onvif/ver10/topics/topicns.xml
|
||||
// - ONVIF Analytics Service Spec
|
||||
// https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf
|
||||
// - ONVIF Device IO Service Spec
|
||||
// https://www.onvif.org/specs/srv/io/ONVIF-DeviceIo-Service-Spec.pdf
|
||||
// - openvideolibs/onvif-parsers (Apache-2.0) — empirical topic table
|
||||
// extracted from Home Assistant
|
||||
// https://github.com/openvideolibs/onvif-parsers
|
||||
func Classify(topic string) Kind {
|
||||
if topic == "" {
|
||||
return KindUnknown
|
||||
}
|
||||
canonical := canonicalizeTopic(topic)
|
||||
for _, rule := range topicRules {
|
||||
if strings.Contains(canonical, rule.needle) {
|
||||
return rule.kind
|
||||
}
|
||||
}
|
||||
return KindUnknown
|
||||
}
|
||||
|
||||
// canonicalizeTopic strips the XML-namespace prefix from each
|
||||
// "/"-separated segment, collapsing Avigilon's per-segment-prefixed
|
||||
// form ("tns1:Device/tns1:Trigger/tns1:Relay") and the plain form
|
||||
// ("tns1:Device/Trigger/Relay") to the same matchable path.
|
||||
func canonicalizeTopic(topic string) string {
|
||||
segments := strings.Split(topic, "/")
|
||||
for i, seg := range segments {
|
||||
if idx := strings.Index(seg, ":"); idx >= 0 {
|
||||
segments[i] = seg[idx+1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
// topicRules is evaluated in order — first match wins. Keep more
|
||||
// specific rules ahead of broader ones. LineDetector/Crossed is
|
||||
// edge-triggered (no boolean State); the decoder leaves State as
|
||||
// StateUnknown for it.
|
||||
var topicRules = []struct {
|
||||
needle string
|
||||
kind Kind
|
||||
}{
|
||||
// tns1:VideoSource/MotionAlarm — Profile S basic motion.
|
||||
// https://www.onvif.org/ver10/topics/topicns.xml
|
||||
// https://developer.axis.com/vapix/network-video/event-and-action-services/
|
||||
{"VideoSource/MotionAlarm", KindMotion},
|
||||
|
||||
// tns1:VideoAnalytics/MotionAlarm — Bosch publishes motion under
|
||||
// VideoAnalytics rather than VideoSource.
|
||||
// https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf
|
||||
{"VideoAnalytics/MotionAlarm", KindMotion},
|
||||
|
||||
// tns1:VideoAnalytics/tnssamsung:MotionDetection — Hanwha vendor.
|
||||
// https://github.com/home-assistant/core/issues/66493
|
||||
{"VideoAnalytics/MotionDetection", KindMotion},
|
||||
|
||||
// tns1:RuleEngine/CellMotionDetector/Motion — ONVIF Analytics
|
||||
// standard cell-motion rule.
|
||||
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.3
|
||||
// https://www.hikvisioneurope.com/eu/portal/portal/Technical%20Materials/24%20How%20To/CCTV/How%20to%20solve%20third%20party%20camera%20motion%20detection%20issue.pdf
|
||||
{"CellMotionDetector/Motion", KindMotion},
|
||||
|
||||
// tns1:RuleEngine/MotionRegionDetector/Motion — AXIS region rule.
|
||||
// https://developer.axis.com/vapix/network-video/event-and-action-services/
|
||||
{"MotionRegionDetector/Motion", KindMotion},
|
||||
|
||||
// tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_<N> — AXIS VMD 3, the
|
||||
// firmware-builtin predecessor of the VMD 4 ACAP. It lives under
|
||||
// RuleEngine, not CameraApplicationPlatform, so the VMD rule below
|
||||
// does not cover it. Still shipping on deployed cameras.
|
||||
// https://developer.axis.com/vapix/network-video/event-and-action-services/
|
||||
{"RuleEngine/VMD3/", KindMotion},
|
||||
|
||||
// AXIS ACAP motion apps with Camera<N>Profile<ID> suffixes. VMD 4 is
|
||||
// the stock app shipped on the camera; the Guard suite are the
|
||||
// paid analytics products. Prefix-match because of the suffix.
|
||||
// https://developer.axis.com/vapix/applications/vmd4
|
||||
// https://developer.axis.com/vapix/applications/motion-guard
|
||||
{"CameraApplicationPlatform/VMD/", KindMotion},
|
||||
{"CameraApplicationPlatform/MotionGuard/", KindMotion},
|
||||
{"CameraApplicationPlatform/FenceGuard/", KindMotion},
|
||||
{"CameraApplicationPlatform/LoiteringGuard/", KindMotion},
|
||||
|
||||
// tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper rule.
|
||||
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.5
|
||||
{"TamperDetector/Tamper", KindTampering},
|
||||
|
||||
// tns1:VideoSource/GlobalSceneChange/ImagingService — the proper
|
||||
// lens-cover signal on firmwares without TamperDetector.
|
||||
// https://www.onvif.org/ver10/topics/topicns.xml
|
||||
{"GlobalSceneChange", KindTampering},
|
||||
|
||||
// tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha.
|
||||
// https://github.com/home-assistant/core/issues/66493
|
||||
{"VideoAnalytics/TamperingDetection", KindTampering},
|
||||
|
||||
// VideoSource/ImageToo* — imaging-quality alarms. See KindImageQuality
|
||||
// for the rationale on splitting these out from KindTampering.
|
||||
// https://www.onvif.org/ver10/topics/topicns.xml
|
||||
{"VideoSource/ImageTooDark", KindImageQuality},
|
||||
{"VideoSource/ImageTooBright", KindImageQuality},
|
||||
{"VideoSource/ImageTooBlurry", KindImageQuality},
|
||||
|
||||
// tns1:Device/Trigger/DigitalInput — standard. Avigilon's per-segment-
|
||||
// prefixed serialisation ("tns1:Device/tns1:Trigger/tns1:DigitalInput")
|
||||
// folds to the same canonical path.
|
||||
// ONVIF-DeviceIo-Service-Spec.pdf §5.2
|
||||
{"Trigger/DigitalInput", KindDigitalInput},
|
||||
// ONVIF-DeviceIo-Service-Spec.pdf §5.3
|
||||
{"Trigger/Relay", KindDigitalOutput},
|
||||
|
||||
// tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario<N>
|
||||
// — Scenario suffixes are numeric per AOA configuration. Prefix-match
|
||||
// because of the dynamic suffix.
|
||||
// https://developer.axis.com/analytics/axis-object-analytics/how-to-guides/axis-object-analytics-counting-data/
|
||||
{"ObjectAnalytics/", KindObjectDetected},
|
||||
|
||||
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4
|
||||
{"LineDetector/Crossed", KindObjectDetected},
|
||||
{"FieldDetector/ObjectsInside", KindObjectDetected},
|
||||
|
||||
// tns1:RuleEngine/MyRuleDetector/<RuleName> — vendor rules under the
|
||||
// ONVIF MyRuleDetector container. Explicitly whitelisted because the
|
||||
// same container also carries non-object rules (Bosch Counter,
|
||||
// Occupancy) that must not classify as ObjectDetected.
|
||||
// https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf
|
||||
{"MyRuleDetector/HumanDetect", KindObjectDetected},
|
||||
{"MyRuleDetector/VehicleDetect", KindObjectDetected},
|
||||
{"MyRuleDetector/PeopleDetect", KindObjectDetected},
|
||||
{"MyRuleDetector/ObjectsInside", KindObjectDetected},
|
||||
{"MyRuleDetector/FaceDetect", KindObjectDetected},
|
||||
|
||||
{"Audio/DetectedSound", KindAudioAlarm},
|
||||
// https://developer.axis.com/vapix/network-video/event-and-action-services/
|
||||
{"AudioSource/TriggerLevel", KindAudioAlarm},
|
||||
{"AudioAnalytics/SoundDetection", KindAudioAlarm},
|
||||
}
|
||||
160
event/stream/topics_test.go
Normal file
160
event/stream/topics_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestClassifyTopic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
want Kind
|
||||
}{
|
||||
// --- Motion -----------------------------------------------------
|
||||
|
||||
{"video_source_motion_alarm", "tns1:VideoSource/MotionAlarm", KindMotion},
|
||||
{"cell_motion_detector", "tns1:RuleEngine/CellMotionDetector/Motion", KindMotion},
|
||||
{"motion_region_detector", "tns1:RuleEngine/MotionRegionDetector/Motion", KindMotion},
|
||||
{"bosch_video_analytics_motion", "tns1:VideoAnalytics/MotionAlarm", KindMotion},
|
||||
{"hanwha_samsung_motion", "tns1:VideoAnalytics/tnssamsung:MotionDetection", KindMotion},
|
||||
|
||||
// AXIS Guard suite — vendor analytics apps.
|
||||
{"axis_motion_guard", "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", KindMotion},
|
||||
{"axis_fence_guard", "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1ProfileANY", KindMotion},
|
||||
{"axis_loitering_guard", "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1ProfileANY", KindMotion},
|
||||
|
||||
// AXIS VMD 4 — the stock motion app, and the one an installer
|
||||
// reaches for before any Guard product. The profile suffix
|
||||
// varies with the configured VMD profile.
|
||||
{"axis_vmd4_profile_any", "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", KindMotion},
|
||||
{"axis_vmd4_profile_numbered", "tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1", KindMotion},
|
||||
|
||||
// AXIS VMD 3 — the firmware-builtin predecessor, published under
|
||||
// RuleEngine rather than CameraApplicationPlatform. Still
|
||||
// shipping on deployed cameras.
|
||||
{"axis_vmd3_video_1", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", KindMotion},
|
||||
{"axis_vmd3_video_2", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_2", KindMotion},
|
||||
|
||||
// --- Tampering --------------------------------------------------
|
||||
|
||||
{"tamper_detector", "tns1:RuleEngine/TamperDetector/Tamper", KindTampering},
|
||||
{"global_scene_change", "tns1:VideoSource/GlobalSceneChange/ImagingService", KindTampering},
|
||||
{"hanwha_tampering", "tns1:VideoAnalytics/tnssamsung:TamperingDetection", KindTampering},
|
||||
|
||||
// --- Image quality (separated from Tampering) ------------------
|
||||
|
||||
{"image_too_dark", "tns1:VideoSource/ImageTooDark/ImagingService", KindImageQuality},
|
||||
{"image_too_bright", "tns1:VideoSource/ImageTooBright/ImagingService", KindImageQuality},
|
||||
{"image_too_blurry", "tns1:VideoSource/ImageTooBlurry/ImagingService", KindImageQuality},
|
||||
|
||||
// --- Digital input ---------------------------------------------
|
||||
|
||||
{"digital_input", "tns1:Device/Trigger/DigitalInput", KindDigitalInput},
|
||||
{"digital_input_avigilon", "tns1:Device/tns1:Trigger/tns1:DigitalInput", KindDigitalInput},
|
||||
|
||||
// --- Digital output --------------------------------------------
|
||||
|
||||
{"relay", "tns1:Device/Trigger/Relay", KindDigitalOutput},
|
||||
{"relay_avigilon", "tns1:Device/tns1:Trigger/tns1:Relay", KindDigitalOutput},
|
||||
|
||||
// --- Object analytics ------------------------------------------
|
||||
|
||||
// AXIS Object Analytics uses numeric scenario suffixes.
|
||||
{"axis_object_analytics_scenario_1", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", KindObjectDetected},
|
||||
{"axis_object_analytics_scenario_2", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario2", KindObjectDetected},
|
||||
|
||||
// Standard rule-engine analytics topics.
|
||||
{"line_detector_crossed", "tns1:RuleEngine/LineDetector/Crossed", KindObjectDetected},
|
||||
{"field_detector_objects_inside", "tns1:RuleEngine/FieldDetector/ObjectsInside", KindObjectDetected},
|
||||
|
||||
// Whitelisted MyRuleDetector sub-rules.
|
||||
{"my_rule_detector_human", "tns1:RuleEngine/MyRuleDetector/HumanDetect", KindObjectDetected},
|
||||
{"my_rule_detector_vehicle", "tns1:RuleEngine/MyRuleDetector/VehicleDetect", KindObjectDetected},
|
||||
{"my_rule_detector_people", "tns1:RuleEngine/MyRuleDetector/PeopleDetect", KindObjectDetected},
|
||||
{"my_rule_detector_face", "tns1:RuleEngine/MyRuleDetector/FaceDetect", KindObjectDetected},
|
||||
{"my_rule_detector_objects_inside", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected},
|
||||
|
||||
// --- Audio -----------------------------------------------------
|
||||
|
||||
{"audio_detected_sound", "tns1:AudioAnalytics/Audio/DetectedSound", KindAudioAlarm},
|
||||
{"axis_audio_trigger_level", "tns1:AudioSource/tnsaxis:TriggerLevel", KindAudioAlarm},
|
||||
{"hanwha_sound_detection", "tns1:AudioAnalytics/tnssamsung:SoundDetection", KindAudioAlarm},
|
||||
|
||||
// --- Negative cases --------------------------------------------
|
||||
|
||||
{"empty", "", KindUnknown},
|
||||
{"unknown_topic", "tns1:UserAlarm/IVA", KindUnknown},
|
||||
{"unrelated_recording_config", "tns1:RecordingConfig/JobState", KindUnknown},
|
||||
|
||||
// MyRuleDetector overmatch guard — Bosch publishes counter and
|
||||
// occupancy under the same container and these must not be
|
||||
// classified as object detection.
|
||||
{"my_rule_detector_counter_not_object", "tns1:RuleEngine/MyRuleDetector/Counter", KindUnknown},
|
||||
{"my_rule_detector_occupancy_not_object", "tns1:RuleEngine/MyRuleDetector/Occupancy", KindUnknown},
|
||||
|
||||
// Substring guards.
|
||||
{"motion_recording_not_motion", "tns1:Recording/MotionRecording/Started", KindUnknown},
|
||||
{"audio_encoder_config_not_audio_alarm", "tns1:Configuration/AudioEncoderConfiguration", KindUnknown},
|
||||
{"relay_failure_not_digital_output", "tns1:Device/HardwareFailure/RelayFailure", KindUnknown},
|
||||
{"digital_input_config_not_digital_input", "tns1:Device/IO/DigitalInputConfiguration", KindUnknown},
|
||||
{"tamper_detector_log_not_tampering", "tns1:Device/Diagnostics/TamperDetectorLog", KindUnknown},
|
||||
|
||||
// The two AXIS VMD needles carry a trailing slash so they match a
|
||||
// whole path segment. Without it, any sibling app or rule whose
|
||||
// name merely starts with VMD / VMD3 would classify as motion and
|
||||
// drive recording.
|
||||
{"vmd_statistics_app_not_motion", "tnsaxis:CameraApplicationPlatform/VMDStatistics/Camera1", KindUnknown},
|
||||
{"vmd3_config_rule_not_motion", "tns1:RuleEngine/tnsaxis:VMD3Config/Changed", KindUnknown},
|
||||
// VMD3 is scoped to RuleEngine; the same name under another
|
||||
// container is a different thing.
|
||||
{"vmd3_outside_rule_engine_not_motion", "tnsaxis:Storage/VMD3/Status", KindUnknown},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, Classify(tc.topic), "topic=%q", tc.topic)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyIsCaseSensitive(t *testing.T) {
|
||||
// ONVIF topic identifiers are case-sensitive per the spec; a
|
||||
// lowercased topic must not match a capitalised pattern.
|
||||
assert.Equal(t, KindUnknown, Classify("tns1:videosource/motionalarm"))
|
||||
}
|
||||
|
||||
func TestCanonicalizeTopicStripsNamespaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"single_namespace", "tns1:VideoSource/MotionAlarm", "VideoSource/MotionAlarm"},
|
||||
{"per_segment_namespace", "tns1:Device/tns1:Trigger/tns1:Relay", "Device/Trigger/Relay"},
|
||||
{"vendor_namespace_inner", "tns1:VideoAnalytics/tnssamsung:MotionDetection", "VideoAnalytics/MotionDetection"},
|
||||
{"axis_outer_namespace", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"},
|
||||
{"empty", "", ""},
|
||||
{"no_colon_passthrough", "Foo/Bar", "Foo/Bar"},
|
||||
{"double_slash_keeps_empty_segment", "tns1://Foo", "//Foo"},
|
||||
{"colon_only_segment_collapses_to_empty", "tns1:/Foo", "/Foo"},
|
||||
{"trailing_colon_segment", "tns1:", ""},
|
||||
{"multi_colon_takes_first", "tns1:Foo:Bar/Baz", "Foo:Bar/Baz"},
|
||||
{"leading_slash_kept", "/tns1:Foo", "/Foo"},
|
||||
{"trailing_slash_kept", "tns1:Foo/", "Foo/"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, canonicalizeTopic(tc.in), "input=%q", tc.in)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyRuleOrder_ObjectAnalyticsBeforeGenericObjects(t *testing.T) {
|
||||
// Locks the invariant that the prefix rule "ObjectAnalytics/" is
|
||||
// matched before the broader "ObjectsInside" rule. Without this
|
||||
// ordering, AXIS AOA topics that contain neither would still classify
|
||||
// correctly via the ObjectAnalytics/ rule; we encode the dependency.
|
||||
topic := "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"
|
||||
assert.Equal(t, KindObjectDetected, Classify(topic))
|
||||
}
|
||||
159
event/stream/types.go
Normal file
159
event/stream/types.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind is the normalized category of an ONVIF event, independent of the
|
||||
// camera vendor's topic naming.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
KindUnknown Kind = iota
|
||||
KindMotion
|
||||
KindTampering
|
||||
// KindImageQuality covers VideoSource imaging alarms. Kept separate
|
||||
// from KindTampering because they fire on legitimate sunset / dawn /
|
||||
// condensation transitions, not on interference.
|
||||
KindImageQuality
|
||||
KindDigitalInput
|
||||
KindDigitalOutput
|
||||
KindObjectDetected
|
||||
KindAudioAlarm
|
||||
)
|
||||
|
||||
func (k Kind) String() string {
|
||||
switch k {
|
||||
case KindUnknown:
|
||||
return "Unknown"
|
||||
case KindMotion:
|
||||
return "Motion"
|
||||
case KindTampering:
|
||||
return "Tampering"
|
||||
case KindImageQuality:
|
||||
return "ImageQuality"
|
||||
case KindDigitalInput:
|
||||
return "DigitalInput"
|
||||
case KindDigitalOutput:
|
||||
return "DigitalOutput"
|
||||
case KindObjectDetected:
|
||||
return "ObjectDetected"
|
||||
case KindAudioAlarm:
|
||||
return "AudioAlarm"
|
||||
default:
|
||||
return fmt.Sprintf("Kind(%d)", uint8(k))
|
||||
}
|
||||
}
|
||||
|
||||
// State is the active/inactive level carried by a boolean ONVIF property
|
||||
// event. StateUnknown is used both when the value cannot be parsed and
|
||||
// when the topic is edge-triggered and carries no boolean state.
|
||||
type State uint8
|
||||
|
||||
const (
|
||||
StateUnknown State = iota
|
||||
StateActive
|
||||
StateInactive
|
||||
)
|
||||
|
||||
func (s State) String() string {
|
||||
switch s {
|
||||
case StateUnknown:
|
||||
return "Unknown"
|
||||
case StateActive:
|
||||
return "Active"
|
||||
case StateInactive:
|
||||
return "Inactive"
|
||||
default:
|
||||
return fmt.Sprintf("State(%d)", uint8(s))
|
||||
}
|
||||
}
|
||||
|
||||
// PropertyOperation mirrors the wsnt:PropertyOperation attribute.
|
||||
// PropertyUnknown covers both "absent on the wire" (the attribute is
|
||||
// optional) and "unrecognised value".
|
||||
type PropertyOperation uint8
|
||||
|
||||
const (
|
||||
PropertyUnknown PropertyOperation = iota
|
||||
PropertyInitialized
|
||||
PropertyChanged
|
||||
PropertyDeleted
|
||||
)
|
||||
|
||||
func (p PropertyOperation) String() string {
|
||||
switch p {
|
||||
case PropertyUnknown:
|
||||
return "Unknown"
|
||||
case PropertyInitialized:
|
||||
return "Initialized"
|
||||
case PropertyChanged:
|
||||
return "Changed"
|
||||
case PropertyDeleted:
|
||||
return "Deleted"
|
||||
default:
|
||||
return fmt.Sprintf("PropertyOperation(%d)", uint8(p))
|
||||
}
|
||||
}
|
||||
|
||||
// Event is a single normalized notification from an ONVIF device.
|
||||
//
|
||||
// Source and Data are maps because ONVIF notifications can carry
|
||||
// multiple SimpleItems — AXIS Object Analytics emits active+classType+
|
||||
// confidence in one Data list, DigitalInput carries InputToken in Source
|
||||
// and LogicalState in Data.
|
||||
type Event struct {
|
||||
Kind Kind
|
||||
State State
|
||||
Operation PropertyOperation
|
||||
DeviceID string
|
||||
Source map[string]string
|
||||
Data map[string]string
|
||||
Topic string
|
||||
Timestamp time.Time
|
||||
// DeviceTime is the camera-reported wsnt:UtcTime. Cameras drift —
|
||||
// prefer Timestamp for ordering and DeviceTime only for forensics or
|
||||
// cross-camera correlation when the caller manages NTP.
|
||||
DeviceTime time.Time
|
||||
// AfterReconnect is true for events delivered after the Stream
|
||||
// silently recreated its subscription. Cameras replay current state
|
||||
// with PropertyInitialized on a new subscription; watch this flag to
|
||||
// suppress duplicate edge-detection. Cleared on the first non-
|
||||
// Initialized event.
|
||||
AfterReconnect bool
|
||||
}
|
||||
|
||||
// Op identifies which Stream operation failed.
|
||||
type Op string
|
||||
|
||||
const (
|
||||
OpPull Op = "pull"
|
||||
OpRenew Op = "renew"
|
||||
OpRecreate Op = "recreate"
|
||||
)
|
||||
|
||||
// ErrPullFailed wraps a transient PullMessages failure. The pull loop
|
||||
// surfaces it and continues.
|
||||
type ErrPullFailed struct{ Err error }
|
||||
|
||||
func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) }
|
||||
func (e ErrPullFailed) Unwrap() error { return e.Err }
|
||||
func (ErrPullFailed) Op() Op { return OpPull }
|
||||
|
||||
// ErrRenewFailed wraps a Renew SOAP failure. Recovered implicitly: a
|
||||
// permanently failing renew lets the subscription die, pull starts
|
||||
// failing, and the reconnect path recreates it.
|
||||
type ErrRenewFailed struct{ Err error }
|
||||
|
||||
func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) }
|
||||
func (e ErrRenewFailed) Unwrap() error { return e.Err }
|
||||
func (ErrRenewFailed) Op() Op { return OpRenew }
|
||||
|
||||
// ErrRecreateFailed wraps a failed CreatePullPointSubscription. Consumers
|
||||
// seeing this repeatedly should consider the camera offline.
|
||||
type ErrRecreateFailed struct{ Err error }
|
||||
|
||||
func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) }
|
||||
func (e ErrRecreateFailed) Unwrap() error { return e.Err }
|
||||
func (ErrRecreateFailed) Op() Op { return OpRecreate }
|
||||
143
event/stream/types_test.go
Normal file
143
event/stream/types_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKindString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind Kind
|
||||
want string
|
||||
}{
|
||||
{"unknown", KindUnknown, "Unknown"},
|
||||
{"motion", KindMotion, "Motion"},
|
||||
{"tampering", KindTampering, "Tampering"},
|
||||
{"image_quality", KindImageQuality, "ImageQuality"},
|
||||
{"digital_input", KindDigitalInput, "DigitalInput"},
|
||||
{"digital_output", KindDigitalOutput, "DigitalOutput"},
|
||||
{"object_detected", KindObjectDetected, "ObjectDetected"},
|
||||
{"audio_alarm", KindAudioAlarm, "AudioAlarm"},
|
||||
{"out_of_range", Kind(255), "Kind(255)"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, tc.kind.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindStringsAreUnique(t *testing.T) {
|
||||
seen := map[string]Kind{}
|
||||
for k := KindUnknown; k <= KindAudioAlarm; k++ {
|
||||
s := k.String()
|
||||
prev, dup := seen[s]
|
||||
assert.False(t, dup, "duplicate String %q for Kind(%d) and Kind(%d)", s, prev, k)
|
||||
seen[s] = k
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state State
|
||||
want string
|
||||
}{
|
||||
{"unknown", StateUnknown, "Unknown"},
|
||||
{"active", StateActive, "Active"},
|
||||
{"inactive", StateInactive, "Inactive"},
|
||||
{"out_of_range", State(255), "State(255)"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, tc.state.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertyOperationString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op PropertyOperation
|
||||
want string
|
||||
}{
|
||||
{"unknown", PropertyUnknown, "Unknown"},
|
||||
{"initialized", PropertyInitialized, "Initialized"},
|
||||
{"changed", PropertyChanged, "Changed"},
|
||||
{"deleted", PropertyDeleted, "Deleted"},
|
||||
{"out_of_range", PropertyOperation(255), "PropertyOperation(255)"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, tc.op.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventZeroValue(t *testing.T) {
|
||||
var e Event
|
||||
assert.Equal(t, KindUnknown, e.Kind)
|
||||
assert.Equal(t, StateUnknown, e.State)
|
||||
assert.Equal(t, PropertyUnknown, e.Operation)
|
||||
assert.Empty(t, e.DeviceID)
|
||||
assert.Nil(t, e.Source)
|
||||
assert.Nil(t, e.Data)
|
||||
assert.Empty(t, e.Topic)
|
||||
assert.True(t, e.Timestamp.IsZero())
|
||||
assert.True(t, e.DeviceTime.IsZero())
|
||||
}
|
||||
|
||||
func TestEventFieldAssignmentRoundTrip(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
deviceTime := now.Add(-2 * time.Second)
|
||||
e := Event{
|
||||
Kind: KindMotion,
|
||||
State: StateActive,
|
||||
Operation: PropertyChanged,
|
||||
DeviceID: "axis-camera-01",
|
||||
Source: map[string]string{"InputToken": "DI1"},
|
||||
Data: map[string]string{"LogicalState": "true"},
|
||||
Topic: "tns1:Device/Trigger/DigitalInput",
|
||||
Timestamp: now,
|
||||
DeviceTime: deviceTime,
|
||||
}
|
||||
assert.Equal(t, KindMotion, e.Kind)
|
||||
assert.Equal(t, StateActive, e.State)
|
||||
assert.Equal(t, PropertyChanged, e.Operation)
|
||||
assert.Equal(t, "axis-camera-01", e.DeviceID)
|
||||
assert.Equal(t, "DI1", e.Source["InputToken"])
|
||||
assert.Equal(t, "true", e.Data["LogicalState"])
|
||||
assert.Equal(t, "tns1:Device/Trigger/DigitalInput", e.Topic)
|
||||
assert.True(t, e.Timestamp.Equal(now))
|
||||
assert.True(t, e.DeviceTime.Equal(deviceTime))
|
||||
}
|
||||
|
||||
// --- Typed errors -----------------------------------------------------
|
||||
|
||||
func TestTypedErrors_UnwrapAndOp(t *testing.T) {
|
||||
inner := errors.New("boom")
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
op Op
|
||||
}{
|
||||
{"pull", ErrPullFailed{Err: inner}, OpPull},
|
||||
{"renew", ErrRenewFailed{Err: inner}, OpRenew},
|
||||
{"recreate", ErrRecreateFailed{Err: inner}, OpRecreate},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.True(t, errors.Is(tc.err, inner), "errors.Is should unwrap to inner")
|
||||
assert.Contains(t, tc.err.Error(), "boom")
|
||||
if e, ok := tc.err.(interface{ Op() Op }); ok {
|
||||
assert.Equal(t, tc.op, e.Op())
|
||||
} else {
|
||||
t.Fatalf("%T does not expose Op()", tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ type MessageBody struct {
|
||||
|
||||
type MessageDescription struct {
|
||||
PropertyOperation xsd.AnyType `xml:"PropertyOperation,attr"`
|
||||
UtcTime xsd.AnyType `xml:"UtcTime,attr"`
|
||||
Source Source `json:",omitempty" xml:",omitempty"`
|
||||
Data Data `json:",omitempty" xml:",omitempty"`
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
176
examples/event/stream/main.go
Normal file
176
examples/event/stream/main.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// Command streamtest opens an event stream against an ONVIF camera and
|
||||
// prints decoded events as they arrive. Useful for verifying the
|
||||
// classifier against real-camera topics; not intended as a production
|
||||
// tool.
|
||||
//
|
||||
// # Usage
|
||||
//
|
||||
// go run ./examples/event/stream \
|
||||
// -xaddr 192.168.1.10 \
|
||||
// -username root \
|
||||
// -duration 60s
|
||||
//
|
||||
// # Credentials
|
||||
//
|
||||
// The camera password is read, in order of preference:
|
||||
//
|
||||
// 1. The ONVIF_PASSWORD environment variable.
|
||||
// 2. A file pointed at by -password-file (newline stripped).
|
||||
// 3. Interactive prompt when stdin is a tty.
|
||||
//
|
||||
// -password is also accepted but DISCOURAGED — it leaks the credential
|
||||
// into shell history and the system process listing. Use only for
|
||||
// throwaway dev cameras.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/event/stream"
|
||||
)
|
||||
|
||||
func main() {
|
||||
xaddr := flag.String("xaddr", "", "camera host or host:port (required)")
|
||||
username := flag.String("username", "", "ONVIF user (required)")
|
||||
insecurePassword := flag.String("password", "", "INSECURE — leaks into shell history; prefer ONVIF_PASSWORD env or -password-file")
|
||||
passwordFile := flag.String("password-file", "", "read password from this file (newline trimmed)")
|
||||
deviceID := flag.String("device-id", "", "logical name printed with each event (default: xaddr)")
|
||||
filter := flag.String("filter", "", "raw ONVIF ConcreteSet topic filter (empty = all topics, works on AXIS)")
|
||||
pullTimeout := flag.Duration("pull-timeout", 5*time.Second, "server-side wait per PullMessages call")
|
||||
duration := flag.Duration("duration", 0, "stop after this long (0 = run until Ctrl-C)")
|
||||
flag.Parse()
|
||||
|
||||
if *xaddr == "" || *username == "" {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
if *deviceID == "" {
|
||||
*deviceID = *xaddr
|
||||
}
|
||||
|
||||
password, err := loadPassword(*insecurePassword, *passwordFile)
|
||||
if err != nil {
|
||||
log.Fatalf("password: %v", err)
|
||||
}
|
||||
|
||||
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
||||
Xaddr: *xaddr,
|
||||
Username: *username,
|
||||
Password: password,
|
||||
AuthMode: onvif.UsernameTokenAuth,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("connect: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if *duration > 0 {
|
||||
var done context.CancelFunc
|
||||
ctx, done = context.WithTimeout(ctx, *duration)
|
||||
defer done()
|
||||
}
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigs
|
||||
cancel()
|
||||
}()
|
||||
|
||||
s, err := stream.NewStream(ctx, dev, stream.Options{
|
||||
DeviceID: *deviceID,
|
||||
RawTopicFilter: *filter,
|
||||
PullTimeout: *pullTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("open stream: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil {
|
||||
log.Printf("stream close: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("streaming from %s (device-id=%s, filter=%q)", *xaddr, *deviceID, *filter)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("done (%v)", ctx.Err())
|
||||
return
|
||||
case ev, ok := <-s.Events():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s kind=%-15s state=%-9s op=%-12s topic=%s",
|
||||
ev.Timestamp.Format(time.RFC3339), ev.Kind, ev.State, ev.Operation, ev.Topic)
|
||||
if ev.AfterReconnect {
|
||||
fmt.Print(" [after-reconnect]")
|
||||
}
|
||||
if len(ev.Source) > 0 {
|
||||
fmt.Printf(" source=%v", ev.Source)
|
||||
}
|
||||
if len(ev.Data) > 0 {
|
||||
fmt.Printf(" data=%v", ev.Data)
|
||||
}
|
||||
fmt.Println()
|
||||
case e, ok := <-s.Errors():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var pull stream.ErrPullFailed
|
||||
var recreate stream.ErrRecreateFailed
|
||||
switch {
|
||||
case errors.As(e, &recreate):
|
||||
log.Printf("RECREATE failed: %v (camera may be offline)", recreate.Err)
|
||||
case errors.As(e, &pull):
|
||||
log.Printf("pull error (will retry): %v", pull.Err)
|
||||
default:
|
||||
log.Printf("stream error: %v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadPassword resolves the camera password from the environment first
|
||||
// (ONVIF_PASSWORD), then -password-file, then an interactive prompt as
|
||||
// a last resort. The insecure -password flag is honoured only if
|
||||
// nothing else is set, and a warning is logged.
|
||||
func loadPassword(insecure, file string) (string, error) {
|
||||
if env := os.Getenv("ONVIF_PASSWORD"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
if file != "" {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", file, err)
|
||||
}
|
||||
return strings.TrimRight(string(b), "\r\n"), nil
|
||||
}
|
||||
if insecure != "" {
|
||||
log.Print("WARNING: -password leaks into shell history and process listings; prefer ONVIF_PASSWORD env or -password-file")
|
||||
return insecure, nil
|
||||
}
|
||||
// Interactive prompt — works when stdin is a tty. We use a plain
|
||||
// reader (rather than golang.org/x/term hidden input) to keep
|
||||
// this example dependency-free; in production, callers should
|
||||
// integrate term.ReadPassword.
|
||||
fmt.Fprint(os.Stderr, "ONVIF password (visible): ")
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", errors.New("no password supplied (set ONVIF_PASSWORD, -password-file, or pipe input)")
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
res, err := dev.SendSoap(endPoint, string(requestBody))
|
||||
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
|
||||
if err != nil {
|
||||
log.Fatalln("fail to CallMethod:", err)
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -11,6 +11,7 @@ require (
|
||||
github.com/icholy/digest v0.1.23
|
||||
github.com/juju/errors v1.0.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/net v0.19.0
|
||||
)
|
||||
|
||||
|
||||
2
go.sum
2
go.sum
@@ -74,6 +74,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
|
||||
@@ -2,6 +2,7 @@ package gosoap
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
@@ -128,7 +129,13 @@ func (msg *SoapMessage) AddBodyContents(elements []*etree.Element) {
|
||||
*msg = SoapMessage(res)
|
||||
}
|
||||
|
||||
//AddStringHeaderContent for Envelope body
|
||||
// AddStringHeaderContent appends a single root element to the SOAP
|
||||
// Header. Use AddStringHeaderContents (plural) when the content
|
||||
// contains multiple sibling elements — for example WS-Addressing
|
||||
// reference parameters, which the spec requires as separate header
|
||||
// blocks. The two coexist for backwards compatibility: external
|
||||
// consumers of this library may rely on AddStringHeaderContent's
|
||||
// single-root constraint and the matching error on multi-root input.
|
||||
func (msg *SoapMessage) AddStringHeaderContent(data string) error {
|
||||
doc := etree.NewDocument()
|
||||
|
||||
@@ -156,6 +163,44 @@ func (msg *SoapMessage) AddStringHeaderContent(data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddStringHeaderContents is the multi-root variant of
|
||||
// AddStringHeaderContent: it accepts any number of top-level sibling
|
||||
// elements (zero is an error) and appends each as its own SOAP Header
|
||||
// child. Needed because WS-Addressing 1.0 SOAP Binding §3.4 requires each
|
||||
// reference parameter to be a separate Header block, but a Go XML
|
||||
// document only has one root. Comments and text outside elements are
|
||||
// silently dropped.
|
||||
//
|
||||
// SECURITY: data forwards verbatim into the outbound envelope. Do not
|
||||
// pass content sourced from untrusted clients — see the same caveat
|
||||
// on onvif.Device.SendSoapWithOptions / SendSoapWithHeader.
|
||||
func (msg *SoapMessage) AddStringHeaderContents(data string) error {
|
||||
in := etree.NewDocument()
|
||||
if err := in.ReadFromString("<wrap>" + data + "</wrap>"); err != nil {
|
||||
return err
|
||||
}
|
||||
wrap := in.SelectElement("wrap")
|
||||
if wrap == nil {
|
||||
return errors.New("AddStringHeaderContents: missing wrap root")
|
||||
}
|
||||
children := wrap.ChildElements()
|
||||
if len(children) == 0 {
|
||||
return errors.New("AddStringHeaderContents: no element children in content")
|
||||
}
|
||||
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString(msg.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
header := doc.Root().SelectElement("Header")
|
||||
for _, child := range children {
|
||||
header.AddChild(child.Copy())
|
||||
}
|
||||
res, _ := doc.WriteToString()
|
||||
*msg = SoapMessage(res)
|
||||
return nil
|
||||
}
|
||||
|
||||
//AddHeaderContent for Envelope body
|
||||
func (msg *SoapMessage) AddHeaderContent(element *etree.Element) {
|
||||
doc := etree.NewDocument()
|
||||
|
||||
147
networking/digest_test.go
Normal file
147
networking/digest_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package networking
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendSoapWithDigestReturnsServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := SendSoapWithDigest(server.Client(), server.URL, "<Envelope/>", "user", "password")
|
||||
if response == nil {
|
||||
t.Fatal("SendSoapWithDigest returned a nil response")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if err == nil {
|
||||
t.Fatal("SendSoapWithDigest returned nil error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSoapWithDigestReturnsServerErrorAfterAuthentication(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
writer.Header().Set("WWW-Authenticate", `Digest realm="AXIS", nonce="nonce", qop="auth", algorithm=MD5`)
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(request.Header.Get("Authorization"), "Digest ") {
|
||||
t.Error("authenticated retry is missing Digest Authorization header")
|
||||
}
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
response, err := SendSoapWithDigest(server.Client(), server.URL, "<Envelope/>", "user", "password")
|
||||
if response == nil {
|
||||
t.Fatal("SendSoapWithDigest returned a nil response")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if err == nil {
|
||||
t.Fatal("SendSoapWithDigest returned nil error for authenticated HTTP 500")
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Fatalf("request count = %d, want 2", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDigestChallenge(t *testing.T) {
|
||||
challenge := `Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41", algorithm=MD5`
|
||||
parts := parseDigestChallenge(challenge)
|
||||
|
||||
cases := map[string]string{
|
||||
"realm": "testrealm@host.com",
|
||||
"qop": "auth,auth-int",
|
||||
"nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093",
|
||||
"opaque": "5ccc069c403ebaf9f0171e9517f40e41",
|
||||
"algorithm": "MD5",
|
||||
}
|
||||
for key, want := range cases {
|
||||
if got := parts[key]; got != want {
|
||||
t.Errorf("parseDigestChallenge()[%q] = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMD5DigestVectors validates md5Hex and the response assembly order against
|
||||
// the canonical RFC 2617 section 3.5 worked example.
|
||||
func TestMD5DigestVectors(t *testing.T) {
|
||||
ha1 := md5Hex("Mufasa:testrealm@host.com:Circle Of Life")
|
||||
if want := "939e7578ed9e3c518a452acee763bce9"; ha1 != want {
|
||||
t.Fatalf("HA1 = %q, want %q", ha1, want)
|
||||
}
|
||||
|
||||
ha2 := md5Hex("GET:/dir/index.html")
|
||||
if want := "39aff3a2bab6126f332b942af96d3366"; ha2 != want {
|
||||
t.Fatalf("HA2 = %q, want %q", ha2, want)
|
||||
}
|
||||
|
||||
response := md5Hex(strings.Join([]string{
|
||||
ha1,
|
||||
"dcd98b7102dd2f0e8b11d0f600bfb0c093", // nonce
|
||||
"00000001", // nc
|
||||
"0a4f113b", // cnonce
|
||||
"auth", // qop
|
||||
ha2,
|
||||
}, ":"))
|
||||
if want := "6629fae49393a05397450978507c4ef1"; response != want {
|
||||
t.Fatalf("response = %q, want %q", response, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDigestAuthorizationConsistency builds an Authorization header and then
|
||||
// recomputes the response from the emitted cnonce/nc to confirm the header is
|
||||
// internally consistent (correct field wiring and formula).
|
||||
func TestNewDigestAuthorizationConsistency(t *testing.T) {
|
||||
const (
|
||||
username = "admin"
|
||||
password = "s3cret"
|
||||
realm = "IP Camera"
|
||||
nonce = "0cc175b9c0f1b6a831c399e269772661"
|
||||
)
|
||||
challenge := `Digest realm="` + realm + `", nonce="` + nonce + `", qop="auth", algorithm=MD5`
|
||||
|
||||
header := newDigestAuthorization(challenge, "POST", "http://192.168.1.10/onvif/ptz_service", username, password)
|
||||
if header == "" {
|
||||
t.Fatal("newDigestAuthorization returned empty header")
|
||||
}
|
||||
if !strings.HasPrefix(header, "Digest ") {
|
||||
t.Fatalf("header does not start with Digest scheme: %q", header)
|
||||
}
|
||||
|
||||
fields := parseDigestChallenge(header)
|
||||
if fields["username"] != username {
|
||||
t.Errorf("username = %q, want %q", fields["username"], username)
|
||||
}
|
||||
if fields["uri"] != "/onvif/ptz_service" {
|
||||
t.Errorf("uri = %q, want %q", fields["uri"], "/onvif/ptz_service")
|
||||
}
|
||||
if fields["qop"] != "auth" {
|
||||
t.Errorf("qop = %q, want %q", fields["qop"], "auth")
|
||||
}
|
||||
|
||||
ha1 := md5Hex(username + ":" + realm + ":" + password)
|
||||
ha2 := md5Hex("POST:/onvif/ptz_service")
|
||||
want := md5Hex(strings.Join([]string{ha1, nonce, fields["nc"], fields["cnonce"], "auth", ha2}, ":"))
|
||||
if fields["response"] != want {
|
||||
t.Errorf("response = %q, want %q", fields["response"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDigestAuthorizationRejectsUnsupported ensures an empty header is
|
||||
// returned when required parameters are missing or the qop is unsupported.
|
||||
func TestNewDigestAuthorizationRejectsUnsupported(t *testing.T) {
|
||||
if got := newDigestAuthorization(`Digest realm="r"`, "POST", "http://host/x", "u", "p"); got != "" {
|
||||
t.Errorf("expected empty header when nonce missing, got %q", got)
|
||||
}
|
||||
if got := newDigestAuthorization(`Digest realm="r", nonce="n", qop="auth-int"`, "POST", "http://host/x", "u", "p"); got != "" {
|
||||
t.Errorf("expected empty header for unsupported qop, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,88 +2,212 @@ package networking
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/beevik/etree"
|
||||
"github.com/icholy/digest"
|
||||
"github.com/juju/errors"
|
||||
)
|
||||
|
||||
const soapContentType = "application/soap+xml; charset=utf-8"
|
||||
|
||||
// SendSoap send soap message
|
||||
func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response, error) {
|
||||
resp, err := httpClient.Post(endpoint, "application/soap+xml; charset=utf-8", bytes.NewBufferString(message))
|
||||
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
|
||||
if err != nil {
|
||||
return resp, errors.Annotate(err, "Post")
|
||||
}
|
||||
|
||||
// if resp.StatusCode is 4xx,5xx, return error
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||
return resp, errors.Errorf("Server error: %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
return resp, responseError(resp)
|
||||
}
|
||||
|
||||
func responseError(resp *http.Response) error {
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||
return errors.Errorf("Server error: %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSoapWithDigest sends a soap message and, when the device answers with an
|
||||
// HTTP 401 digest challenge, transparently retries the request with the
|
||||
// computed HTTP digest Authorization header.
|
||||
//
|
||||
// Any wsse:Security header present in the message is stripped before sending:
|
||||
// when a device requires HTTP digest the credentials travel in the
|
||||
// Authorization header, so keeping the WS-Security UsernameToken in the body
|
||||
// would put the credentials on the wire twice. Other SOAP header blocks (for
|
||||
// example WS-Addressing reference parameters) are preserved so vendor-specific
|
||||
// routing keeps working across the retry.
|
||||
func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString(message); err != nil {
|
||||
return nil, err
|
||||
if httpClient == nil {
|
||||
httpClient = new(http.Client)
|
||||
}
|
||||
|
||||
e := doc.FindElement("./Envelope/Header/Security")
|
||||
if e != nil {
|
||||
bodyTag := doc.Root().SelectElement("Header")
|
||||
bodyTag.RemoveChild(e)
|
||||
data, err := doc.WriteToString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
message = data
|
||||
// Avoid sending the credentials twice (WS-Security + digest) on the retry.
|
||||
message = stripWSSecurityHeader(message)
|
||||
|
||||
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
|
||||
if err != nil {
|
||||
return resp, errors.Annotate(err, "Post")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(message))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Only escalate to HTTP digest when the device explicitly asks for it.
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, responseError(resp)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||
resp, err := httpClient.Do(req)
|
||||
|
||||
challenge := resp.Header.Get("WWW-Authenticate")
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "digest") {
|
||||
// Not a digest challenge (e.g. Basic) - nothing more we can do here.
|
||||
return resp, responseError(resp)
|
||||
}
|
||||
|
||||
authorization := newDigestAuthorization(challenge, http.MethodPost, endpoint, username, password)
|
||||
if authorization == "" {
|
||||
return resp, errors.New("unsupported digest challenge")
|
||||
}
|
||||
|
||||
// Release the challenge response before issuing the authenticated retry.
|
||||
resp.Body.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(message))
|
||||
if err != nil {
|
||||
return nil, errors.Annotate(err, "new digest request")
|
||||
}
|
||||
req.Header.Set("Content-Type", soapContentType)
|
||||
req.Header.Set("Authorization", authorization)
|
||||
|
||||
resp, err = httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return resp, errors.Annotate(err, "Post with digest")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
||||
chal, err := digest.ParseChallenge(wwwAuth)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("fail to parse challenge: %w", err)
|
||||
}
|
||||
|
||||
cred, err := digest.Digest(chal, digest.Options{
|
||||
Method: "POST",
|
||||
URI: req.URL.RequestURI(),
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("fail to build digest: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Authorization", cred.String())
|
||||
req.Body = io.NopCloser((bytes.NewBufferString(message)))
|
||||
resp, err = httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Annotate(err, "Post with digest")
|
||||
}
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||
return resp, errors.Errorf("Post with digest error: %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
return resp, responseError(resp)
|
||||
}
|
||||
|
||||
// stripWSSecurityHeader removes the wsse:Security header block from a SOAP
|
||||
// envelope, leaving all other header blocks intact. The message is returned
|
||||
// unchanged if it cannot be parsed as XML or has no such header.
|
||||
func stripWSSecurityHeader(message string) string {
|
||||
doc := etree.NewDocument()
|
||||
if err := doc.ReadFromString(message); err != nil {
|
||||
return message
|
||||
}
|
||||
security := doc.FindElement("./Envelope/Header/Security")
|
||||
if security == nil {
|
||||
return message
|
||||
}
|
||||
header := doc.Root().SelectElement("Header")
|
||||
if header == nil {
|
||||
return message
|
||||
}
|
||||
header.RemoveChild(security)
|
||||
data, err := doc.WriteToString()
|
||||
if err != nil {
|
||||
return message
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`)
|
||||
|
||||
// parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header.
|
||||
func parseDigestChallenge(challenge string) map[string]string {
|
||||
challenge = strings.TrimSpace(challenge)
|
||||
if i := strings.IndexAny(challenge, " \t"); i >= 0 && strings.EqualFold(challenge[:i], "Digest") {
|
||||
challenge = challenge[i+1:]
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
for _, m := range digestParamRe.FindAllStringSubmatch(challenge, -1) {
|
||||
value := m[2]
|
||||
if value == "" {
|
||||
value = m[3]
|
||||
}
|
||||
result[strings.ToLower(m[1])] = strings.TrimSpace(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// newDigestAuthorization builds an RFC 2617 HTTP digest Authorization header
|
||||
// value. It supports the MD5 and MD5-sess algorithms and the "auth" qop, which
|
||||
// covers the vast majority of ONVIF devices. It returns an empty string when the
|
||||
// challenge is missing required parameters or requires an unsupported qop.
|
||||
func newDigestAuthorization(challenge, method, uri, username, password string) string {
|
||||
parts := parseDigestChallenge(challenge)
|
||||
realm := parts["realm"]
|
||||
nonce := parts["nonce"]
|
||||
if realm == "" || nonce == "" {
|
||||
return ""
|
||||
}
|
||||
opaque := parts["opaque"]
|
||||
algorithm := parts["algorithm"]
|
||||
|
||||
qop := ""
|
||||
if rawQop, ok := parts["qop"]; ok {
|
||||
for _, candidate := range strings.Split(rawQop, ",") {
|
||||
if strings.TrimSpace(candidate) == "auth" {
|
||||
qop = "auth"
|
||||
break
|
||||
}
|
||||
}
|
||||
// The server offered qop but none we support (e.g. auth-int only).
|
||||
if qop == "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
digestURI := uri
|
||||
if u, err := url.Parse(uri); err == nil {
|
||||
digestURI = u.RequestURI()
|
||||
}
|
||||
|
||||
cnonce := randomCnonce()
|
||||
const nc = "00000001"
|
||||
|
||||
ha1 := md5Hex(username + ":" + realm + ":" + password)
|
||||
if strings.EqualFold(algorithm, "MD5-sess") {
|
||||
ha1 = md5Hex(ha1 + ":" + nonce + ":" + cnonce)
|
||||
}
|
||||
ha2 := md5Hex(method + ":" + digestURI)
|
||||
|
||||
var response string
|
||||
if qop == "auth" {
|
||||
response = md5Hex(strings.Join([]string{ha1, nonce, nc, cnonce, qop, ha2}, ":"))
|
||||
} else {
|
||||
response = md5Hex(ha1 + ":" + nonce + ":" + ha2)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
|
||||
username, realm, nonce, digestURI, response)
|
||||
if qop == "auth" {
|
||||
fmt.Fprintf(&b, `, qop=auth, nc=%s, cnonce="%s"`, nc, cnonce)
|
||||
}
|
||||
if algorithm != "" {
|
||||
fmt.Fprintf(&b, `, algorithm=%s`, algorithm)
|
||||
}
|
||||
if opaque != "" {
|
||||
fmt.Fprintf(&b, `, opaque="%s"`, opaque)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
sum := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func randomCnonce() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "00000000"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ type GetPresetTours struct {
|
||||
}
|
||||
|
||||
type GetPresetToursResponse struct {
|
||||
PresetTour onvif.PresetTour
|
||||
PresetTour []onvif.PresetTour
|
||||
}
|
||||
|
||||
type GetPresetTour struct {
|
||||
|
||||
25
ptz/types_test.go
Normal file
25
ptz/types_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package ptz
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func TestContinuousMoveIncludesZeroPanTiltCoordinates(t *testing.T) {
|
||||
request := ContinuousMove{
|
||||
Velocity: onvif.PTZSpeedPanTilt{
|
||||
PanTilt: onvif.Vector2D{X: 0.5, Y: 0},
|
||||
},
|
||||
}
|
||||
|
||||
encoded, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("xml.Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `x="0.5" y="0"`) {
|
||||
t.Fatalf("ContinuousMove PanTilt = %s, want explicit x and y attributes", encoded)
|
||||
}
|
||||
}
|
||||
31
sdk/event/PullMessages_auto.go
Normal file
31
sdk/event/PullMessages_auto.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Code generated : DO NOT EDIT.
|
||||
// Copyright (c) 2022 Jean-Francois SMIGIELSKI
|
||||
// Distributed under the MIT License
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/juju/errors"
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/event"
|
||||
"github.com/kerberos-io/onvif/sdk"
|
||||
)
|
||||
|
||||
// Call_PullMessages forwards the call to dev.CallMethod() then parses the payload of the reply as a PullMessagesResponse.
|
||||
func Call_PullMessages(ctx context.Context, dev *onvif.Device, request event.PullMessages) (event.PullMessagesResponse, error) {
|
||||
type Envelope struct {
|
||||
Header struct{}
|
||||
Body struct {
|
||||
PullMessagesResponse event.PullMessagesResponse
|
||||
}
|
||||
}
|
||||
var reply Envelope
|
||||
if httpReply, err := dev.CallMethod(request); err != nil {
|
||||
return reply.Body.PullMessagesResponse, errors.Annotate(err, "call")
|
||||
} else {
|
||||
err = sdk.ReadAndParse(ctx, httpReply, &reply, "PullMessages")
|
||||
return reply.Body.PullMessagesResponse, errors.Annotate(err, "reply")
|
||||
}
|
||||
}
|
||||
8
sdk/event/event.go
Normal file
8
sdk/event/event.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package event
|
||||
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event CreatePullPointSubscription
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetEventProperties
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetServiceCapabilities
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Subscribe
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Unsubscribe
|
||||
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event PullMessages
|
||||
25
sdk/sdk.go
Normal file
25
sdk/sdk.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/juju/errors"
|
||||
)
|
||||
|
||||
// ReadAndParse reads the body of the given HTTP reply and unmarshals it into
|
||||
// reply. The tag identifies the ONVIF action for diagnostic purposes.
|
||||
func ReadAndParse(ctx context.Context, httpReply *http.Response, reply interface{}, tag string) error {
|
||||
// TODO(jfsmig): extract the deadline from ctx.Deadline() and apply it on the reply reading
|
||||
b, err := io.ReadAll(httpReply.Body)
|
||||
if err != nil {
|
||||
return errors.Annotate(err, "read")
|
||||
}
|
||||
|
||||
httpReply.Body.Close()
|
||||
|
||||
err = xml.Unmarshal(b, reply)
|
||||
return errors.Annotate(err, "decode")
|
||||
}
|
||||
@@ -666,13 +666,13 @@ type PTZSpeedPanTilt struct {
|
||||
}
|
||||
|
||||
type Vector2D struct {
|
||||
X float64 `xml:"x,attr,omitempty"`
|
||||
Y float64 `xml:"y,attr,omitempty"`
|
||||
X float64 `xml:"x,attr"`
|
||||
Y float64 `xml:"y,attr"`
|
||||
Space *xsd.AnyURI `xml:"space,attr,omitempty"`
|
||||
}
|
||||
|
||||
type Vector1D struct {
|
||||
X float64 `xml:"x,attr,omitempty"`
|
||||
X float64 `xml:"x,attr"`
|
||||
Space *xsd.AnyURI `xml:"space,attr,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1176,7 +1176,7 @@ type PresetTour struct {
|
||||
Status PTZPresetTourStatus `xml:"Status"`
|
||||
AutoStart xsd.Boolean `xml:"AutoStart"`
|
||||
StartingCondition PTZPresetTourStartingCondition `xml:"StartingCondition"`
|
||||
TourSpot PTZPresetTourSpot `xml:"TourSpot"`
|
||||
TourSpot []PTZPresetTourSpot `xml:"TourSpot"`
|
||||
Extension PTZPresetTourExtension `xml:"Extension"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user