86a8fb36d5
- Fix critical scanner.go bug: ineffective break in select (SA4011) Use labeled break to properly exit loop on context cancellation - Add error checking for all file.Close() and resp.Body.Close() Prevent resource leaks in loader, onvif_simple, and tester - Add error checking for fmt.Sscanf() calls in tester.go Prevent silent parse failures for FPS and bitrate extraction - Add error checking for all SSE streamWriter calls Explicit ignore with _ = for SendJSON and SendError - Remove unused sync.RWMutex field from SearchEngine - Refactor if/else to switch for CodecType (staticcheck QF1003) More idiomatic Go code in stream tester All 20 linter issues resolved. Code compiles and runs correctly.
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/IOTechSystems/onvif"
|
|
"github.com/IOTechSystems/onvif/media"
|
|
xsdonvif "github.com/IOTechSystems/onvif/xsd/onvif"
|
|
"github.com/eduard256/Strix/internal/models"
|
|
)
|
|
|
|
// ONVIFDiscovery handles ONVIF device discovery and stream detection
|
|
type ONVIFDiscovery struct {
|
|
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
|
}
|
|
|
|
// NewONVIFDiscovery creates a new ONVIF discovery instance
|
|
func NewONVIFDiscovery(logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *ONVIFDiscovery {
|
|
return &ONVIFDiscovery{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// DiscoverStreamsForIP discovers all possible streams for a given IP
|
|
func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username, password string) ([]models.DiscoveredStream, error) {
|
|
o.logger.Debug("=== ONVIF DiscoverStreamsForIP STARTED ===",
|
|
"ip", ip,
|
|
"username", username,
|
|
"password_len", len(password))
|
|
|
|
// Clean IP (remove port if present)
|
|
if idx := strings.IndexByte(ip, ':'); idx > 0 {
|
|
o.logger.Debug("cleaning IP address", "original", ip, "cleaned", ip[:idx])
|
|
ip = ip[:idx]
|
|
}
|
|
|
|
var allStreams []models.DiscoveredStream
|
|
|
|
// Try real ONVIF discovery first
|
|
o.logger.Debug(">>> Starting ONVIF device discovery", "ip", ip)
|
|
onvifStreams := o.discoverViaONVIF(ctx, ip, username, password)
|
|
o.logger.Debug("<<< ONVIF device discovery completed", "streams_found", len(onvifStreams))
|
|
|
|
if len(onvifStreams) > 0 {
|
|
o.logger.Debug("ONVIF streams details:")
|
|
for i, stream := range onvifStreams {
|
|
o.logger.Debug(" ONVIF stream found",
|
|
"index", i,
|
|
"url", stream.URL,
|
|
"protocol", stream.Protocol,
|
|
"port", stream.Port,
|
|
"type", stream.Type)
|
|
}
|
|
}
|
|
allStreams = append(allStreams, onvifStreams...)
|
|
|
|
// Add common RTSP streams
|
|
o.logger.Debug(">>> Adding common RTSP streams", "ip", ip)
|
|
commonStreams := o.getCommonRTSPStreams(ip, username, password)
|
|
o.logger.Debug("<<< Common RTSP streams added", "count", len(commonStreams))
|
|
allStreams = append(allStreams, commonStreams...)
|
|
|
|
o.logger.Debug("=== ONVIF DiscoverStreamsForIP COMPLETED ===",
|
|
"onvif_streams", len(onvifStreams),
|
|
"common_streams", len(commonStreams),
|
|
"total_streams", len(allStreams))
|
|
|
|
return allStreams, nil
|
|
}
|
|
|
|
// discoverViaONVIF performs real ONVIF discovery
|
|
func (o *ONVIFDiscovery) discoverViaONVIF(ctx context.Context, ip, username, password string) []models.DiscoveredStream {
|
|
o.logger.Debug(">>> discoverViaONVIF STARTED", "ip", ip)
|
|
var streams []models.DiscoveredStream
|
|
|
|
// Try standard ONVIF ports
|
|
ports := []int{80, 8080, 8000}
|
|
o.logger.Debug("Will try ONVIF ports", "ports", ports)
|
|
|
|
for portIdx, port := range ports {
|
|
o.logger.Debug("--- Trying ONVIF port ---",
|
|
"port_index", portIdx+1,
|
|
"total_ports", len(ports),
|
|
"port", port)
|
|
|
|
// Create timeout context for ONVIF connection
|
|
onvifCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
xaddr := fmt.Sprintf("%s:%d", ip, port)
|
|
o.logger.Debug("Creating ONVIF device",
|
|
"xaddr", xaddr,
|
|
"username", username,
|
|
"has_password", password != "")
|
|
|
|
// Create ONVIF device
|
|
startTime := time.Now()
|
|
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
|
Xaddr: xaddr,
|
|
Username: username,
|
|
Password: password,
|
|
})
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ ONVIF device creation FAILED",
|
|
"xaddr", xaddr,
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
continue
|
|
}
|
|
|
|
o.logger.Debug("✅ ONVIF device created successfully",
|
|
"xaddr", xaddr,
|
|
"elapsed", elapsed.String())
|
|
|
|
// Try to get profiles with context
|
|
o.logger.Debug("Getting media profiles...", "xaddr", xaddr)
|
|
profileStreams := o.getProfileStreams(onvifCtx, dev, ip)
|
|
|
|
if len(profileStreams) > 0 {
|
|
// Add ONVIF device service endpoint
|
|
deviceServiceURL := fmt.Sprintf("http://%s/onvif/device_service", xaddr)
|
|
|
|
// Embed credentials in URL if provided
|
|
if username != "" && password != "" {
|
|
u, err := url.Parse(deviceServiceURL)
|
|
if err == nil {
|
|
u.User = url.UserPassword(username, password)
|
|
deviceServiceURL = u.String()
|
|
}
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: deviceServiceURL,
|
|
Type: "ONVIF",
|
|
Protocol: "http",
|
|
Port: port,
|
|
Working: true, // Mark as working since ONVIF connection succeeded
|
|
Metadata: map[string]interface{}{
|
|
"source": "onvif",
|
|
"description": "ONVIF Device Service - used for PTZ control and device management",
|
|
},
|
|
})
|
|
|
|
// Add profile streams
|
|
streams = append(streams, profileStreams...)
|
|
|
|
o.logger.Debug("🎉 ONVIF discovery SUCCESSFUL!",
|
|
"xaddr", xaddr,
|
|
"device_service", deviceServiceURL,
|
|
"profiles_found", len(profileStreams))
|
|
|
|
// Log device service
|
|
o.logger.Debug(" Device Service",
|
|
"url", deviceServiceURL)
|
|
|
|
// Log each profile
|
|
for i, stream := range profileStreams {
|
|
o.logger.Debug(" Profile stream",
|
|
"index", i+1,
|
|
"url", stream.URL,
|
|
"metadata", stream.Metadata)
|
|
}
|
|
break // Found working port, stop trying
|
|
} else {
|
|
o.logger.Debug("⚠️ No profiles returned from port", "xaddr", xaddr)
|
|
}
|
|
}
|
|
|
|
o.logger.Debug("<<< discoverViaONVIF COMPLETED",
|
|
"total_streams_found", len(streams))
|
|
|
|
return streams
|
|
}
|
|
|
|
// getProfileStreams gets stream URIs from media profiles
|
|
func (o *ONVIFDiscovery) getProfileStreams(ctx context.Context, dev *onvif.Device, ip string) []models.DiscoveredStream {
|
|
o.logger.Debug(">>> getProfileStreams STARTED", "ip", ip)
|
|
var streams []models.DiscoveredStream
|
|
|
|
// Get media profiles
|
|
o.logger.Debug("Calling GetProfiles ONVIF method...")
|
|
getProfilesReq := media.GetProfiles{}
|
|
startTime := time.Now()
|
|
profilesResp, err := dev.CallMethod(getProfilesReq)
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to call GetProfiles",
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
return streams
|
|
}
|
|
defer func() { _ = profilesResp.Body.Close() }()
|
|
|
|
o.logger.Debug("✅ GetProfiles call successful",
|
|
"elapsed", elapsed.String(),
|
|
"status_code", profilesResp.StatusCode)
|
|
|
|
// Read and parse XML response
|
|
o.logger.Debug("Reading response body...")
|
|
body, err := io.ReadAll(profilesResp.Body)
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to read profiles response",
|
|
"error", err.Error())
|
|
return streams
|
|
}
|
|
|
|
o.logger.Debug("Response body read",
|
|
"body_length", len(body),
|
|
"body_preview", string(body[:min(200, len(body))]))
|
|
|
|
// Parse SOAP envelope
|
|
o.logger.Debug("Parsing SOAP envelope...")
|
|
var envelope struct {
|
|
XMLName xml.Name `xml:"Envelope"`
|
|
Body struct {
|
|
GetProfilesResponse media.GetProfilesResponse `xml:"GetProfilesResponse"`
|
|
} `xml:"Body"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(body, &envelope); err != nil {
|
|
o.logger.Debug("❌ Failed to parse profiles response",
|
|
"error", err.Error())
|
|
return streams
|
|
}
|
|
|
|
profileCount := len(envelope.Body.GetProfilesResponse.Profiles)
|
|
o.logger.Debug("✅ SOAP envelope parsed successfully",
|
|
"profiles_count", profileCount)
|
|
|
|
// Get stream URI for each profile
|
|
for i, profile := range envelope.Body.GetProfilesResponse.Profiles {
|
|
o.logger.Debug("Processing profile",
|
|
"index", i+1,
|
|
"total", profileCount,
|
|
"token", string(profile.Token),
|
|
"name", string(profile.Name))
|
|
|
|
streamURI := o.getStreamURI(dev, string(profile.Token))
|
|
if streamURI != "" {
|
|
o.logger.Debug("✅ Got stream URI for profile",
|
|
"profile_token", string(profile.Token),
|
|
"stream_uri", streamURI)
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURI,
|
|
Type: "FFMPEG",
|
|
Protocol: "rtsp",
|
|
Port: 554,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "onvif",
|
|
"profile_token": string(profile.Token),
|
|
"profile_name": string(profile.Name),
|
|
},
|
|
})
|
|
} else {
|
|
o.logger.Debug("⚠️ Failed to get stream URI for profile",
|
|
"profile_token", string(profile.Token))
|
|
}
|
|
}
|
|
|
|
o.logger.Debug("<<< getProfileStreams COMPLETED",
|
|
"streams_collected", len(streams))
|
|
|
|
return streams
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// getStreamURI retrieves stream URI for a profile
|
|
func (o *ONVIFDiscovery) getStreamURI(dev *onvif.Device, profileToken string) string {
|
|
o.logger.Debug(">>> getStreamURI STARTED", "profile_token", profileToken)
|
|
|
|
stream := xsdonvif.StreamType("RTP-Unicast")
|
|
protocol := xsdonvif.TransportProtocol("RTSP")
|
|
token := xsdonvif.ReferenceToken(profileToken)
|
|
|
|
getStreamURIReq := media.GetStreamUri{
|
|
ProfileToken: &token,
|
|
StreamSetup: &xsdonvif.StreamSetup{
|
|
Stream: &stream,
|
|
Transport: &xsdonvif.Transport{
|
|
Protocol: &protocol,
|
|
},
|
|
},
|
|
}
|
|
|
|
o.logger.Debug("Calling GetStreamUri ONVIF method...", "profile_token", profileToken)
|
|
startTime := time.Now()
|
|
resp, err := dev.CallMethod(getStreamURIReq)
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to get stream URI",
|
|
"profile", profileToken,
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
return ""
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
o.logger.Debug("✅ GetStreamUri call successful",
|
|
"profile", profileToken,
|
|
"elapsed", elapsed.String(),
|
|
"status_code", resp.StatusCode)
|
|
|
|
// Read and parse XML response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to read stream URI response",
|
|
"error", err.Error())
|
|
return ""
|
|
}
|
|
|
|
o.logger.Debug("Response body read",
|
|
"body_length", len(body),
|
|
"body_preview", string(body[:min(200, len(body))]))
|
|
|
|
// Parse SOAP envelope
|
|
var envelope struct {
|
|
XMLName xml.Name `xml:"Envelope"`
|
|
Body struct {
|
|
GetStreamUriResponse media.GetStreamUriResponse `xml:"GetStreamUriResponse"`
|
|
} `xml:"Body"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(body, &envelope); err != nil {
|
|
o.logger.Debug("❌ Failed to parse stream URI response",
|
|
"error", err.Error())
|
|
return ""
|
|
}
|
|
|
|
streamURI := string(envelope.Body.GetStreamUriResponse.MediaUri.Uri)
|
|
o.logger.Debug("<<< getStreamURI COMPLETED",
|
|
"stream_uri", streamURI)
|
|
|
|
return streamURI
|
|
}
|
|
|
|
// getCommonRTSPStreams returns common RTSP stream URLs
|
|
func (o *ONVIFDiscovery) getCommonRTSPStreams(ip, username, password string) []models.DiscoveredStream {
|
|
// Common RTSP paths that work with many cameras
|
|
commonPaths := []struct {
|
|
path string
|
|
notes string
|
|
}{
|
|
{"/stream1", "Common main stream"},
|
|
{"/stream2", "Common sub stream"},
|
|
{"/ch0", "Thingino main"},
|
|
{"/ch1", "Thingino sub"},
|
|
{"/live/main", "ONVIF standard main"},
|
|
{"/live/sub", "ONVIF standard sub"},
|
|
{"/Streaming/Channels/101", "Hikvision main"},
|
|
{"/Streaming/Channels/102", "Hikvision sub"},
|
|
{"/cam/realmonitor?channel=1&subtype=0", "Dahua main"},
|
|
{"/cam/realmonitor?channel=1&subtype=1", "Dahua sub"},
|
|
{"/h264/main", "Generic H264 main"},
|
|
{"/h264/sub", "Generic H264 sub"},
|
|
{"/media/video1", "Axis main"},
|
|
{"/media/video2", "Axis sub"},
|
|
{"/videoMain", "Foscam main"},
|
|
{"/videoSub", "Foscam sub"},
|
|
{"/11", "Simple numeric main"},
|
|
{"/12", "Simple numeric sub"},
|
|
{"/user=admin_password=tlJwpbo6_channel=1_stream=0.sdp", "Dahua alternative"},
|
|
{"/live.sdp", "Generic live"},
|
|
{"/stream", "Generic stream"},
|
|
{"/video.h264", "Generic H264"},
|
|
{"/live/0/MAIN", "Alternative main"},
|
|
{"/live/0/SUB", "Alternative sub"},
|
|
{"/MediaInput/h264", "Alternative H264"},
|
|
{"/0/video0", "Alternative video0"},
|
|
{"/0/video1", "Alternative video1"},
|
|
}
|
|
|
|
var streams []models.DiscoveredStream
|
|
|
|
for _, cp := range commonPaths {
|
|
var streamURL string
|
|
if username != "" && password != "" {
|
|
streamURL = fmt.Sprintf("rtsp://%s:%s@%s:554%s", url.QueryEscape(username), url.QueryEscape(password), ip, cp.path)
|
|
} else {
|
|
streamURL = fmt.Sprintf("rtsp://%s:554%s", ip, cp.path)
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURL,
|
|
Type: "FFMPEG",
|
|
Protocol: "rtsp",
|
|
Port: 554,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "common",
|
|
"notes": cp.notes,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Add some HTTP snapshot URLs too
|
|
httpPaths := []struct {
|
|
path string
|
|
notes string
|
|
}{
|
|
{"/snapshot.jpg", "Common snapshot"},
|
|
{"/snap.jpg", "Alternative snapshot"},
|
|
{"/image/jpeg.cgi", "CGI snapshot"},
|
|
{"/cgi-bin/snapshot.cgi", "CGI bin snapshot"},
|
|
{"/jpg/image.jpg", "JPEG image"},
|
|
{"/tmpfs/auto.jpg", "Tmpfs snapshot"},
|
|
{"/axis-cgi/jpg/image.cgi", "Axis snapshot"},
|
|
{"/cgi-bin/viewer/video.jpg", "Viewer snapshot"},
|
|
{"/Streaming/channels/1/picture", "Hikvision snapshot"},
|
|
{"/onvif/snapshot", "ONVIF snapshot"},
|
|
}
|
|
|
|
for _, hp := range httpPaths {
|
|
var streamURL string
|
|
if username != "" && password != "" {
|
|
// For HTTP, we'll rely on Basic Auth instead of URL embedding
|
|
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
|
} else {
|
|
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURL,
|
|
Type: "JPEG",
|
|
Protocol: "http",
|
|
Port: 80,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "common",
|
|
"notes": hp.notes,
|
|
"username": username,
|
|
"password": password,
|
|
},
|
|
})
|
|
}
|
|
|
|
return streams
|
|
} |