// 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 }