// backend\onvif_resolutions.go
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"crypto/tls"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const onvifResolutionCacheMaxAge = 24 * time.Hour
const onvifResolutionFetchTimeout = 7 * time.Second
type onvifResolutionCacheEntry struct {
Resolutions []string
CheckedAt time.Time
CheckError string
}
type onvifCameraConnection struct {
Host string
Port string
Username string
Password string
SkipTLSVerify bool
}
func (s *Server) getCachedOnvifCameraResolutions(
ctx context.Context,
cameraDevice milestoneCameraPatchDevice,
skipTLSVerify bool,
) ([]string, error) {
host := strings.TrimSpace(cameraDevice.IPAddress)
port := strings.TrimSpace(cameraDevice.MilestonePort)
if host == "" {
return nil, nil
}
if port == "" {
port = "80"
}
hardwareID := strings.TrimSpace(cameraDevice.MilestoneHardwareID)
cacheReady := hardwareID != ""
if cacheReady {
if err := s.ensureOnvifResolutionCacheTable(ctx); err != nil {
cacheReady = false
log.Printf("ONVIF resolution cache table could not be ensured: %v", err)
}
}
var cachedEntry onvifResolutionCacheEntry
var cached bool
if cacheReady {
entry, ok, err := s.getOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
)
if err != nil {
log.Printf("ONVIF resolution cache could not be read: %v", err)
} else {
cachedEntry = entry
cached = ok
}
}
if cached &&
len(cachedEntry.Resolutions) > 0 &&
time.Since(cachedEntry.CheckedAt) <= onvifResolutionCacheMaxAge {
return cachedEntry.Resolutions, nil
}
username, password := s.onvifCredentials(ctx)
fetchCtx, cancel := context.WithTimeout(ctx, onvifResolutionFetchTimeout)
defer cancel()
resolutions, err := fetchOnvifVideoEncoderResolutions(fetchCtx, onvifCameraConnection{
Host: host,
Port: port,
Username: username,
Password: password,
SkipTLSVerify: skipTLSVerify,
})
if err != nil {
if cached && len(cachedEntry.Resolutions) > 0 {
return cachedEntry.Resolutions, nil
}
if cacheReady {
if cacheErr := s.upsertOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
nil,
err.Error(),
); cacheErr != nil {
log.Printf("ONVIF resolution cache error could not be stored: %v", cacheErr)
}
}
return nil, err
}
if cacheReady {
if err := s.upsertOnvifResolutionCache(
ctx,
hardwareID,
host,
port,
resolutions,
"",
); err != nil {
log.Printf("ONVIF resolution cache could not be stored: %v", err)
}
}
return resolutions, nil
}
func (s *Server) onvifCredentials(ctx context.Context) (string, string) {
envUsername := strings.TrimSpace(os.Getenv("ONVIF_USERNAME"))
envPassword := strings.TrimSpace(os.Getenv("ONVIF_PASSWORD"))
if envUsername != "" || envPassword != "" {
return envUsername, envPassword
}
credentials, err := s.getMilestoneCredentials(ctx)
if err != nil {
return "", ""
}
return strings.TrimSpace(credentials.Username), strings.TrimSpace(credentials.Password)
}
func (s *Server) ensureOnvifResolutionCacheTable(ctx context.Context) error {
_, err := s.db.Exec(
ctx,
`
CREATE TABLE IF NOT EXISTS onvif_resolution_cache (
milestone_hardware_id TEXT PRIMARY KEY,
host TEXT NOT NULL DEFAULT '',
port TEXT NOT NULL DEFAULT '',
resolutions JSONB NOT NULL DEFAULT '[]'::JSONB,
check_error TEXT NOT NULL DEFAULT '',
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`,
)
return err
}
func (s *Server) getOnvifResolutionCache(
ctx context.Context,
hardwareID string,
host string,
port string,
) (onvifResolutionCacheEntry, bool, error) {
var entry onvifResolutionCacheEntry
var rawResolutions []byte
err := s.db.QueryRow(
ctx,
`
SELECT resolutions, checked_at, check_error
FROM onvif_resolution_cache
WHERE milestone_hardware_id = $1
AND host = $2
AND port = $3
LIMIT 1
`,
strings.TrimSpace(hardwareID),
strings.TrimSpace(host),
strings.TrimSpace(port),
).Scan(
&rawResolutions,
&entry.CheckedAt,
&entry.CheckError,
)
if errors.Is(err, pgxErrNoRows()) {
return entry, false, nil
}
if err != nil {
return entry, false, err
}
if err := json.Unmarshal(rawResolutions, &entry.Resolutions); err != nil {
return entry, false, err
}
entry.Resolutions = sortMilestoneResolutionValues(entry.Resolutions)
return entry, true, nil
}
func (s *Server) upsertOnvifResolutionCache(
ctx context.Context,
hardwareID string,
host string,
port string,
resolutions []string,
checkError string,
) error {
resolutions = sortMilestoneResolutionValues(resolutions)
rawResolutions, err := json.Marshal(resolutions)
if err != nil {
return err
}
_, err = s.db.Exec(
ctx,
`
INSERT INTO onvif_resolution_cache (
milestone_hardware_id,
host,
port,
resolutions,
check_error,
checked_at,
updated_at
)
VALUES ($1, $2, $3, $4::JSONB, $5, now(), now())
ON CONFLICT (milestone_hardware_id)
DO UPDATE SET
host = EXCLUDED.host,
port = EXCLUDED.port,
resolutions = EXCLUDED.resolutions,
check_error = EXCLUDED.check_error,
checked_at = EXCLUDED.checked_at,
updated_at = now()
`,
strings.TrimSpace(hardwareID),
strings.TrimSpace(host),
strings.TrimSpace(port),
string(rawResolutions),
strings.TrimSpace(checkError),
)
return err
}
func fetchOnvifVideoEncoderResolutions(
ctx context.Context,
connection onvifCameraConnection,
) ([]string, error) {
client := onvifHTTPClient(connection.SkipTLSVerify)
var lastErr error
for _, deviceEndpoint := range onvifDeviceServiceCandidates(connection.Host, connection.Port) {
mediaXAddr, err := onvifGetMediaServiceXAddr(
ctx,
client,
deviceEndpoint,
connection,
)
if err != nil {
lastErr = err
continue
}
if mediaXAddr == "" {
mediaXAddr = deviceEndpoint
}
mediaXAddr = onvifResolveEndpoint(deviceEndpoint, mediaXAddr)
resolutions, err := onvifGetVideoEncoderConfigurationOptions(
ctx,
client,
mediaXAddr,
"",
connection,
)
if err == nil && len(resolutions) > 0 {
return resolutions, nil
}
if err != nil {
lastErr = err
}
profileTokens, profileErr := onvifGetProfileTokens(
ctx,
client,
mediaXAddr,
connection,
)
if profileErr != nil {
lastErr = profileErr
continue
}
merged := []string{}
for _, profileToken := range profileTokens {
profileResolutions, err := onvifGetVideoEncoderConfigurationOptions(
ctx,
client,
mediaXAddr,
profileToken,
connection,
)
if err != nil {
lastErr = err
continue
}
merged = append(merged, profileResolutions...)
}
merged = sortMilestoneResolutionValues(merged)
if len(merged) > 0 {
return merged, nil
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("ONVIF returned no video encoder resolution options")
}
func onvifGetMediaServiceXAddr(
ctx context.Context,
client *http.Client,
deviceEndpoint string,
connection onvifCameraConnection,
) (string, error) {
body := `` +
`Media` +
``
responseBody, err := onvifSOAPRequest(
ctx,
client,
deviceEndpoint,
"http://www.onvif.org/ver10/device/wsdl/GetCapabilities",
body,
connection,
)
if err != nil {
return "", err
}
xaddrs := xmlTextValuesByLocalName(responseBody, "XAddr")
for _, xaddr := range xaddrs {
if strings.Contains(strings.ToLower(xaddr), "media") {
return strings.TrimSpace(xaddr), nil
}
}
if len(xaddrs) > 0 {
return strings.TrimSpace(xaddrs[0]), nil
}
return "", fmt.Errorf("ONVIF media service XAddr is missing")
}
func onvifGetProfileTokens(
ctx context.Context,
client *http.Client,
mediaEndpoint string,
connection onvifCameraConnection,
) ([]string, error) {
body := ``
responseBody, err := onvifSOAPRequest(
ctx,
client,
mediaEndpoint,
"http://www.onvif.org/ver10/media/wsdl/GetProfiles",
body,
connection,
)
if err != nil {
return nil, err
}
return xmlProfileTokens(responseBody), nil
}
func onvifGetVideoEncoderConfigurationOptions(
ctx context.Context,
client *http.Client,
mediaEndpoint string,
profileToken string,
connection onvifCameraConnection,
) ([]string, error) {
body := ``
if strings.TrimSpace(profileToken) != "" {
body += `` + xmlText(profileToken) + ``
}
body += ``
responseBody, err := onvifSOAPRequest(
ctx,
client,
mediaEndpoint,
"http://www.onvif.org/ver10/media/wsdl/GetVideoEncoderConfigurationOptions",
body,
connection,
)
if err != nil {
return nil, err
}
return xmlResolutionValues(responseBody), nil
}
func onvifSOAPRequest(
ctx context.Context,
client *http.Client,
endpoint string,
action string,
body string,
connection onvifCameraConnection,
) ([]byte, error) {
var lastErr error
for _, soap12 := range []bool{true, false} {
responseBody, err := onvifSOAPRequestWithVersion(
ctx,
client,
endpoint,
action,
body,
connection,
soap12,
)
if err == nil {
return responseBody, nil
}
lastErr = err
}
return nil, lastErr
}
func onvifSOAPRequestWithVersion(
ctx context.Context,
client *http.Client,
endpoint string,
action string,
body string,
connection onvifCameraConnection,
soap12 bool,
) ([]byte, error) {
envelopeNS := "http://www.w3.org/2003/05/soap-envelope"
if !soap12 {
envelopeNS = "http://schemas.xmlsoap.org/soap/envelope/"
}
envelope := `` +
`` +
`` + onvifSecurityHeader(connection.Username, connection.Password) + `` +
`` + body + `` +
``
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
strings.NewReader(envelope),
)
if err != nil {
return nil, err
}
if soap12 {
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
} else {
request.Header.Set("Content-Type", "text/xml; charset=utf-8")
request.Header.Set("SOAPAction", `"`+action+`"`)
}
request.Header.Set("Accept", "application/soap+xml, text/xml, application/xml")
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf(
"ONVIF request failed: endpoint=%s status=%d body=%s",
endpoint,
response.StatusCode,
truncateMilestoneLogBody(responseBody),
)
}
if faultText := xmlSOAPFaultText(responseBody); faultText != "" {
return nil, fmt.Errorf("ONVIF SOAP fault: %s", faultText)
}
return responseBody, nil
}
func onvifSecurityHeader(username string, password string) string {
username = strings.TrimSpace(username)
password = strings.TrimSpace(password)
if username == "" && password == "" {
return ""
}
nonce := make([]byte, 20)
if _, err := rand.Read(nonce); err != nil {
return ""
}
created := time.Now().UTC().Format("2006-01-02T15:04:05Z")
hash := sha1.New()
hash.Write(nonce)
hash.Write([]byte(created))
hash.Write([]byte(password))
digest := base64.StdEncoding.EncodeToString(hash.Sum(nil))
encodedNonce := base64.StdEncoding.EncodeToString(nonce)
return `` +
`` +
`` + xmlText(username) + `` +
`` +
xmlText(digest) +
`` +
`` +
xmlText(encodedNonce) +
`` +
`` + xmlText(created) + `` +
`` +
``
}
func onvifDeviceServiceCandidates(host string, port string) []string {
host = strings.TrimSpace(host)
port = strings.TrimSpace(port)
if parsedHost, parsedPort := parseOnvifHostAndPort(host); parsedHost != "" {
host = parsedHost
if port == "" {
port = parsedPort
}
}
if port == "" {
port = "80"
}
ports := []string{port}
if port != "80" {
ports = append(ports, "80")
}
candidates := []string{}
seen := map[string]struct{}{}
for _, candidatePort := range ports {
schemes := []string{"http"}
if candidatePort == "443" {
schemes = []string{"https", "http"}
}
for _, scheme := range schemes {
candidate := scheme + "://" + net.JoinHostPort(host, candidatePort) + "/onvif/device_service"
if _, exists := seen[candidate]; exists {
continue
}
seen[candidate] = struct{}{}
candidates = append(candidates, candidate)
}
}
return candidates
}
func parseOnvifHostAndPort(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return "", ""
}
if strings.Contains(value, "://") {
parsedURL, err := url.Parse(value)
if err == nil && parsedURL.Hostname() != "" {
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
}
}
parsedURL, err := url.Parse("//" + value)
if err == nil && parsedURL.Hostname() != "" {
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port())
}
return value, ""
}
func onvifResolveEndpoint(baseEndpoint string, rawEndpoint string) string {
rawEndpoint = strings.TrimSpace(rawEndpoint)
if rawEndpoint == "" {
return baseEndpoint
}
parsedEndpoint, err := url.Parse(rawEndpoint)
if err == nil && parsedEndpoint.IsAbs() {
return rawEndpoint
}
baseURL, err := url.Parse(baseEndpoint)
if err != nil {
return rawEndpoint
}
relativeURL, err := url.Parse(rawEndpoint)
if err != nil {
return rawEndpoint
}
return baseURL.ResolveReference(relativeURL).String()
}
func onvifHTTPClient(skipTLSVerify bool) *http.Client {
client := &http.Client{
Timeout: onvifResolutionFetchTimeout,
}
if skipTLSVerify {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
}
return client
}
func xmlText(value string) string {
var buffer bytes.Buffer
_ = xml.EscapeText(&buffer, []byte(value))
return buffer.String()
}
func xmlTextValuesByLocalName(body []byte, localName string) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
values := []string{}
for {
token, err := decoder.Token()
if err != nil {
break
}
startElement, ok := token.(xml.StartElement)
if !ok || startElement.Name.Local != localName {
continue
}
var text string
if err := decoder.DecodeElement(&text, &startElement); err == nil {
text = strings.TrimSpace(text)
if text != "" {
values = append(values, text)
}
}
}
return values
}
func xmlProfileTokens(body []byte) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
tokens := []string{}
seen := map[string]struct{}{}
for {
token, err := decoder.Token()
if err != nil {
break
}
startElement, ok := token.(xml.StartElement)
if !ok || startElement.Name.Local != "Profiles" {
continue
}
for _, attr := range startElement.Attr {
if attr.Name.Local != "token" {
continue
}
value := strings.TrimSpace(attr.Value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
tokens = append(tokens, value)
}
}
return tokens
}
func xmlResolutionValues(body []byte) []string {
decoder := xml.NewDecoder(bytes.NewReader(body))
resolutions := []string{}
seen := map[string]struct{}{}
inResolution := false
width := 0
height := 0
addResolution := func() {
if width <= 0 || height <= 0 {
return
}
value := fmt.Sprintf("%dx%d", width, height)
if _, exists := seen[value]; exists {
return
}
seen[value] = struct{}{}
resolutions = append(resolutions, value)
}
for {
token, err := decoder.Token()
if err != nil {
break
}
switch typedToken := token.(type) {
case xml.StartElement:
switch typedToken.Name.Local {
case "ResolutionsAvailable", "Resolution":
inResolution = true
width = 0
height = 0
case "Width":
if !inResolution {
continue
}
var text string
if err := decoder.DecodeElement(&text, &typedToken); err == nil {
width, _ = strconv.Atoi(strings.TrimSpace(text))
}
case "Height":
if !inResolution {
continue
}
var text string
if err := decoder.DecodeElement(&text, &typedToken); err == nil {
height, _ = strconv.Atoi(strings.TrimSpace(text))
}
}
case xml.EndElement:
switch typedToken.Name.Local {
case "ResolutionsAvailable", "Resolution":
addResolution()
inResolution = false
width = 0
height = 0
}
}
}
return sortMilestoneResolutionValues(resolutions)
}
func xmlSOAPFaultText(body []byte) string {
for _, name := range []string{"Text", "Reason", "faultstring"} {
values := xmlTextValuesByLocalName(body, name)
if len(values) == 0 {
continue
}
return values[0]
}
return ""
}
func applyOnvifAvailableResolutionsToMilestoneSettings(
settings map[string]any,
resolutions []string,
) {
if len(resolutions) == 0 {
return
}
rawStreams, ok := settings["stream"].([]any)
if !ok {
return
}
for _, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
streamMap["availableResolutions"] = mergeMilestoneResolutionValues(
streamMap["availableResolutions"],
milestoneStringValue(streamMap["resolution"]),
resolutions,
)
}
}
func applyOnvifAvailableResolutionsToMilestoneActiveStreams(
activeStreams []any,
resolutions []string,
) {
if len(resolutions) == 0 {
return
}
for _, rawGroup := range activeStreams {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
rawSettings, ok := group["stream"].([]any)
if !ok {
continue
}
for _, rawSetting := range rawSettings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
setting["availableResolutions"] = mergeMilestoneResolutionValues(
setting["availableResolutions"],
milestoneStringValue(setting["resolution"]),
resolutions,
)
}
}
}
func mergeMilestoneResolutionValues(existing any, current string, extra []string) []string {
values := []string{}
values = append(values, extractMilestoneResolutionValues(existing, 0)...)
values = append(values, current)
values = append(values, extra...)
return sortMilestoneResolutionValues(values)
}
func sortMilestoneResolutionValues(values []string) []string {
seen := map[string]struct{}{}
resolutions := []string{}
for _, value := range values {
resolution := normalizeMilestoneResolution(value)
if resolution == "" {
continue
}
if _, exists := seen[resolution]; exists {
continue
}
seen[resolution] = struct{}{}
resolutions = append(resolutions, resolution)
}
sort.SliceStable(resolutions, func(i int, j int) bool {
iWidth, iHeight := splitMilestoneResolution(resolutions[i])
jWidth, jHeight := splitMilestoneResolution(resolutions[j])
iPixels := iWidth * iHeight
jPixels := jWidth * jHeight
if iPixels == jPixels {
if iWidth == jWidth {
return iHeight < jHeight
}
return iWidth < jWidth
}
return iPixels < jPixels
})
return resolutions
}
func splitMilestoneResolution(value string) (int, int) {
parts := strings.Split(normalizeMilestoneResolution(value), "x")
if len(parts) != 2 {
return 0, 0
}
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
return width, height
}
func pgxErrNoRows() error {
return pgx.ErrNoRows
}