Add RTSP brand probing to ONVIF discovery

Extend device discovery to generate RTSP stream candidates using built-in brand profiles, RTSP DESCRIBE probing, auth-realm parsing, and port hints. Add `RTSPStreams`/`RTSPStream` to API responses, prefer verified stream URLs as primary `RTSPURL`, and let stronger RTSP-derived brand/model signals refine detected camera metadata. Also add focused unit tests for discriminating vs non-discriminating devices, realm-based brand/model detection, and generic fallback behavior.
This commit is contained in:
Cédric Verstraeten
2026-07-16 09:09:27 +02:00
parent c836cef28d
commit 6f2d35cdf1
5 changed files with 696 additions and 18 deletions

View File

@@ -24,20 +24,34 @@ type CameraStreams struct {
// with an active port scan and MAC/vendor lookup so cameras can be
// auto-detected and pre-filled in the configuration UI.
type DiscoveredDevice struct {
IP string `json:"ip" bson:"ip"`
Hostname string `json:"hostname,omitempty" bson:"hostname"`
MAC string `json:"mac,omitempty" bson:"mac"`
Vendor string `json:"vendor,omitempty" bson:"vendor"`
Manufacturer string `json:"manufacturer,omitempty" bson:"manufacturer"`
Model string `json:"model,omitempty" bson:"model"`
Type string `json:"type,omitempty" bson:"type"`
Server string `json:"server,omitempty" bson:"server"`
OpenPorts []int `json:"open_ports,omitempty" bson:"open_ports"`
Services []string `json:"services,omitempty" bson:"services"`
ONVIF bool `json:"onvif" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
RTSPURL string `json:"rtsp_url,omitempty" bson:"rtsp_url"`
IsCamera bool `json:"is_camera" bson:"is_camera"`
IP string `json:"ip" bson:"ip"`
Hostname string `json:"hostname,omitempty" bson:"hostname"`
MAC string `json:"mac,omitempty" bson:"mac"`
Vendor string `json:"vendor,omitempty" bson:"vendor"`
Manufacturer string `json:"manufacturer,omitempty" bson:"manufacturer"`
Model string `json:"model,omitempty" bson:"model"`
Type string `json:"type,omitempty" bson:"type"`
Server string `json:"server,omitempty" bson:"server"`
OpenPorts []int `json:"open_ports,omitempty" bson:"open_ports"`
Services []string `json:"services,omitempty" bson:"services"`
ONVIF bool `json:"onvif" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
RTSPURL string `json:"rtsp_url,omitempty" bson:"rtsp_url"`
RTSPStreams []RTSPStream `json:"rtsp_streams,omitempty" bson:"rtsp_streams"`
IsCamera bool `json:"is_camera" bson:"is_camera"`
}
// RTSPStream is a candidate RTSP stream URL for a discovered camera, derived
// from a built-in brand -> RTSP path mapping. When Verified is true the path was
// confirmed to exist on the device via an unauthenticated RTSP DESCRIBE probe
// (a 200 OK or a 401/403 "auth required" both prove the path is valid).
type RTSPStream struct {
Brand string `json:"brand,omitempty" bson:"brand"`
Stream string `json:"stream,omitempty" bson:"stream"` // "main" or "sub"
Path string `json:"path" bson:"path"`
URL string `json:"url" bson:"url"`
Verified bool `json:"verified" bson:"verified"`
RequiresAuth bool `json:"requires_auth,omitempty" bson:"requires_auth"`
}
type OnvifPanTilt struct {

View File

@@ -0,0 +1,446 @@
package onvif
import (
"bufio"
"net"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
// brandProfile describes a camera brand together with the RTSP URL path
// templates it exposes for its main (high quality) and sub (low quality)
// streams. The paths are the well-known, widely documented defaults for each
// vendor and are used both to identify the brand (by probing which path the
// device recognises) and to pre-fill a working RTSP URL for the user.
//
// The order of the list matters: more specific / more common brands come first
// so that when we actively probe a device the first matching profile wins.
type brandProfile struct {
Brand string
// aliases are lower-cased tokens that, when seen in a banner/realm/MAC
// vendor, map onto this brand.
Aliases []string
MainPath string
SubPath string
// extraMainPaths are alternative main-stream paths tried during active
// probing when the primary MainPath is not recognised.
extraMainPaths []string
}
// brandProfiles is the built-in brand -> RTSP path mapping. It mirrors the
// tables used by tools such as ONVIF Device Manager, iSpy/Agent DVR and
// Blue Iris.
var brandProfiles = []brandProfile{
{
Brand: "Hikvision",
Aliases: []string{"hikvision", "dvrdvs", "ds-", "hik"},
MainPath: "/Streaming/Channels/101",
SubPath: "/Streaming/Channels/102",
extraMainPaths: []string{"/h264/ch1/main/av_stream", "/ISAPI/Streaming/Channels/101"},
},
{
Brand: "Dahua",
Aliases: []string{"dahua", "dh-"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/live"},
},
{
Brand: "Amcrest",
Aliases: []string{"amcrest"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
},
{
Brand: "Axis",
Aliases: []string{"axis"},
MainPath: "/axis-media/media.amp",
SubPath: "/axis-media/media.amp?videocodec=h264&resolution=640x480",
extraMainPaths: []string{"/mpeg4/media.amp"},
},
{
Brand: "Reolink",
Aliases: []string{"reolink"},
MainPath: "/h264Preview_01_main",
SubPath: "/h264Preview_01_sub",
extraMainPaths: []string{"/Preview_01_main"},
},
{
Brand: "Hanwha",
Aliases: []string{"hanwha", "wisenet", "samsung techwin"},
MainPath: "/profile2/media.smp",
SubPath: "/profile3/media.smp",
extraMainPaths: []string{"/profile1/media.smp", "/onvif/profile2/media.smp"},
},
{
Brand: "Bosch",
Aliases: []string{"bosch"},
MainPath: "/rtsp_tunnel",
SubPath: "/rtsp_tunnel?inst=2",
extraMainPaths: []string{"/?inst=1"},
},
{
Brand: "Vivotek",
Aliases: []string{"vivotek"},
MainPath: "/live.sdp",
SubPath: "/live2.sdp",
extraMainPaths: []string{"/live1s1.sdp"},
},
{
Brand: "Foscam",
Aliases: []string{"foscam"},
MainPath: "/videoMain",
SubPath: "/videoSub",
},
{
Brand: "Uniview",
Aliases: []string{"uniview", "unv"},
MainPath: "/media/video1",
SubPath: "/media/video2",
extraMainPaths: []string{"/unicast/c1/s0/live", "/unicast/c1/s1/live"},
},
{
Brand: "TP-Link",
Aliases: []string{"tp-link", "tplink", "tapo"},
MainPath: "/stream1",
SubPath: "/stream2",
},
{
Brand: "Mobotix",
Aliases: []string{"mobotix"},
MainPath: "/cam0/mjpeg",
SubPath: "/cam1/mjpeg",
extraMainPaths: []string{"/live.sdp"},
},
{
Brand: "Ubiquiti",
Aliases: []string{"ubiquiti", "unifi"},
MainPath: "/s0",
SubPath: "/s1",
extraMainPaths: []string{"/live/ch00_0"},
},
{
Brand: "Panasonic",
Aliases: []string{"panasonic", "i-pro", "ipro"},
MainPath: "/MediaInput/h264",
SubPath: "/MediaInput/h264/stream_2",
},
{
Brand: "Sony",
Aliases: []string{"sony"},
MainPath: "/media/video1",
SubPath: "/media/video2",
},
}
// genericRTSPPaths are last-resort, vendor-neutral RTSP paths used when the
// brand is unknown. Many ONVIF/embedded cameras answer on one of these.
var genericRTSPPaths = []string{
"/onvif1", "/live", "/live/ch0", "/11", "/12",
"/stream0", "/stream1", "/h264", "/media/video1", "/ch0_0.h264",
}
// brandProfileFor returns the profile whose aliases best match the given brand
// hint (from a banner, realm or MAC vendor). It returns nil when nothing
// matches.
func brandProfileFor(hint string) *brandProfile {
hint = strings.ToLower(strings.TrimSpace(hint))
if hint == "" {
return nil
}
for i := range brandProfiles {
for _, alias := range brandProfiles[i].Aliases {
if strings.Contains(hint, alias) {
return &brandProfiles[i]
}
}
}
return nil
}
// realmBrands maps a lower-cased substring of an RTSP/HTTP WWW-Authenticate
// realm to a manufacturer. The auth realm is one of the most reliable brand
// signals because a camera advertises it even when it refuses every
// unauthenticated request (e.g. Hikvision realm "IP Camera(E3669)", Dahua realm
// "Login to <serial>"). Ordered so the most specific matches win.
var realmBrands = []struct {
Match string
Vendor string
}{
{"login to", "Dahua"},
{"surveillance server", "Dahua"},
{"real time streaming", "Dahua"},
{"dahua", "Dahua"},
{"ip camera(", "Hikvision"},
{"hikvision", "Hikvision"},
{"ds-", "Hikvision"},
{"axis", "Axis"},
{"reolink", "Reolink"},
{"amcrest", "Amcrest"},
{"wisenet", "Hanwha"},
{"hanwha", "Hanwha"},
{"uniview", "Uniview"},
{"tp-link", "TP-Link"},
{"tapo", "TP-Link"},
{"foscam", "Foscam"},
{"vivotek", "Vivotek"},
{"mobotix", "Mobotix"},
{"bosch", "Bosch"},
}
// brandFromRealm resolves a manufacturer from an auth realm string.
func brandFromRealm(realm string) string {
r := strings.ToLower(strings.TrimSpace(realm))
if r == "" {
return ""
}
for _, entry := range realmBrands {
if strings.Contains(r, entry.Match) {
return entry.Vendor
}
}
return ""
}
// modelFromRealm extracts a model/device code embedded in an auth realm, e.g.
// Hikvision's realm="IP Camera(E3669)" -> "E3669".
func modelFromRealm(realm string) string {
open := strings.Index(realm, "(")
closeIdx := strings.Index(realm, ")")
if open >= 0 && closeIdx > open+1 {
return strings.TrimSpace(realm[open+1 : closeIdx])
}
return ""
}
// firstNonEmpty returns the first non-blank value.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// guessRTSPStreams determines the most likely RTSP stream URLs for a camera. It
// combines the brand hint discovered from banners/MAC with an active,
// unauthenticated RTSP DESCRIBE probe and the auth realm advertised by the
// device.
//
// Detection strategy (most reliable first):
// 1. Send a control DESCRIBE for a random, non-existent path. Its 401 response
// usually carries a WWW-Authenticate realm that reveals the brand
// (Hikvision "IP Camera(...)", Dahua "Login to ..."). The realm is the
// strongest signal and works even when the device challenges auth for every
// request. The control also tells us whether the device distinguishes valid
// from invalid paths.
// 2. If the device discriminates paths, probe each brand's main path (realm
// brand first); the first the device recognises (200 or 401/403) confirms a
// working URL.
// 3. Otherwise fall back to the realm / hint / port brand's default paths and
// return them as unverified suggestions.
//
// It returns the detected brand, an optional model code parsed from the realm,
// and the ordered list of candidate streams (verified first).
func guessRTSPStreams(ip string, port int, brandHint string, openPorts []int, timeout time.Duration) (brand string, model string, streams []models.RTSPStream) {
base := "rtsp://" + net.JoinHostPort(ip, strconv.Itoa(port))
build := func(profileBrand, stream, path string, verified, requiresAuth bool) models.RTSPStream {
return models.RTSPStream{
Brand: profileBrand,
Stream: stream,
Path: path,
URL: base + path,
Verified: verified,
RequiresAuth: requiresAuth,
}
}
// 1) Control probe: distinguish behaviour + capture the auth realm.
bogusPath := "/kerberos-probe-" + strconv.FormatInt(time.Now().UnixNano(), 36)
controlStatus, controlRealm, _ := rtspDescribe(ip, port, bogusPath, timeout)
controlExists := controlStatus == 200 || controlStatus == 401 || controlStatus == 403
controlAuth := controlStatus == 401 || controlStatus == 403
discriminates := !controlExists
realmBrand := brandFromRealm(controlRealm)
model = modelFromRealm(controlRealm)
// The realm brand (when present) is authoritative and probed first.
primaryHint := firstNonEmpty(realmBrand, brandHint)
var verified []models.RTSPStream
var unverified []models.RTSPStream
detected := ""
// 2) Trustworthy active per-brand probing (device discriminates paths).
if discriminates {
for _, profile := range orderedProfiles(primaryHint) {
mainCandidates := append([]string{profile.MainPath}, profile.extraMainPaths...)
matchedMain := ""
matchedAuth := false
for _, path := range mainCandidates {
ok, requiresAuth := rtspPathExists(ip, port, path, timeout)
if ok {
matchedMain = path
matchedAuth = requiresAuth
break
}
}
if matchedMain == "" {
continue
}
detected = profile.Brand
verified = append(verified, build(profile.Brand, "main", matchedMain, true, matchedAuth))
if profile.SubPath != "" {
subOK, subAuth := rtspPathExists(ip, port, profile.SubPath, timeout)
verified = append(verified, build(profile.Brand, "sub", profile.SubPath, subOK, subAuth || matchedAuth))
}
break
}
}
// 3) Fall back to unverified suggestions from realm / hint / port signals.
if len(verified) == 0 {
profile := brandProfileFor(primaryHint)
if profile == nil {
profile = brandProfileForPorts(openPorts)
}
if profile != nil {
detected = profile.Brand
unverified = append(unverified, build(profile.Brand, "main", profile.MainPath, false, controlAuth))
if profile.SubPath != "" {
unverified = append(unverified, build(profile.Brand, "sub", profile.SubPath, false, controlAuth))
}
} else {
for _, path := range genericRTSPPaths {
unverified = append(unverified, build("Generic", "main", path, false, controlAuth))
}
}
}
// The realm brand always wins for the manufacturer name.
if realmBrand != "" {
detected = realmBrand
}
return detected, model, append(verified, unverified...)
}
// brandProfileForPorts derives a brand from vendor-specific control ports that
// were found open during the scan (used when banners give no hint).
func brandProfileForPorts(openPorts []int) *brandProfile {
if containsInt(openPorts, 37777) {
return brandProfileByName("Dahua")
}
return nil
}
// brandProfileByName returns the profile with the given brand name (nil when
// absent).
func brandProfileByName(name string) *brandProfile {
for i := range brandProfiles {
if brandProfiles[i].Brand == name {
return &brandProfiles[i]
}
}
return nil
}
// orderedProfiles returns the brand profiles with the profile matching the
// brand hint (if any) moved to the front so it is probed first.
func orderedProfiles(brandHint string) []brandProfile {
match := brandProfileFor(brandHint)
if match == nil {
return brandProfiles
}
ordered := make([]brandProfile, 0, len(brandProfiles))
ordered = append(ordered, *match)
for i := range brandProfiles {
if brandProfiles[i].Brand != match.Brand {
ordered = append(ordered, brandProfiles[i])
}
}
return ordered
}
// rtspDescribe sends an unauthenticated RTSP DESCRIBE for the given path and
// returns the response status code together with the WWW-Authenticate realm and
// Server header (when present). status is 0 when the device does not answer.
func rtspDescribe(ip string, port int, path string, timeout time.Duration) (status int, realm string, server string) {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return 0, "", ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "DESCRIBE rtsp://" + address + path + " RTSP/1.0\r\n" +
"CSeq: 1\r\n" +
"User-Agent: KerberosDiscovery\r\n" +
"Accept: application/sdp\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return 0, "", ""
}
status, headers := readRTSPResponse(conn)
return status, parseRealm(headers["www-authenticate"]), headers["server"]
}
// rtspPathExists reports whether the device recognises the given RTSP path. A
// 200 OK means the path is publicly accessible; a 401/403 means the path is
// valid but requires credentials (still a positive match). Any other status
// (404, 400, 455, ...) means the path is not recognised.
func rtspPathExists(ip string, port int, path string, timeout time.Duration) (exists bool, requiresAuth bool) {
status, _, _ := rtspDescribe(ip, port, path, timeout)
switch status {
case 200:
return true, false
case 401, 403:
return true, true
default:
return false, false
}
}
// readRTSPResponse reads and parses the status code and headers of an RTSP
// response. Only the first occurrence of each header is kept.
func readRTSPResponse(conn net.Conn) (status int, headers map[string]string) {
headers = make(map[string]string)
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return 0, headers
}
fields := strings.Fields(line)
if len(fields) >= 2 && strings.HasPrefix(strings.ToUpper(fields[0]), "RTSP/") {
status, _ = strconv.Atoi(fields[1])
}
for {
hline, err := reader.ReadString('\n')
if err != nil {
break
}
hline = strings.TrimRight(hline, "\r\n")
if hline == "" {
break
}
idx := strings.Index(hline, ":")
if idx <= 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(hline[:idx]))
value := strings.TrimSpace(hline[idx+1:])
if _, exists := headers[key]; !exists {
headers[key] = value
}
}
return status, headers
}

View File

@@ -0,0 +1,171 @@
package onvif
import (
"bufio"
"net"
"strconv"
"strings"
"testing"
"time"
)
// mockRTSPServer starts a TCP listener that answers RTSP DESCRIBE requests. For
// each incoming request it extracts the path and calls respond(path) to obtain
// the numeric status code and optional auth realm to return. It returns the
// listener host, port and a cleanup function.
func mockRTSPServer(t *testing.T, respond func(path string) (int, string)) (string, int, func()) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start mock RTSP server: %v", err)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
reader := bufio.NewReader(c)
line, err := reader.ReadString('\n')
if err != nil {
return
}
path := ""
fields := strings.Fields(line)
if len(fields) >= 2 {
url := fields[1]
url = strings.TrimPrefix(url, "rtsp://")
if idx := strings.Index(url, "/"); idx >= 0 {
path = url[idx:]
}
}
status, realm := respond(path)
reason := map[int]string{200: "OK", 401: "Unauthorized", 404: "Not Found"}[status]
response := "RTSP/1.0 " + strconv.Itoa(status) + " " + reason + "\r\nCSeq: 1\r\n"
if realm != "" {
response += "WWW-Authenticate: Digest realm=\"" + realm + "\", nonce=\"abc\"\r\n"
}
response += "\r\n"
_, _ = c.Write([]byte(response))
}(conn)
}
}()
host, portStr, _ := net.SplitHostPort(listener.Addr().String())
port, _ := strconv.Atoi(portStr)
return host, port, func() { listener.Close() }
}
// TestGuessRTSPStreams_DiscriminatingHikvision verifies that a device which
// distinguishes valid from invalid paths (returning 401 only for the Hikvision
// path) is correctly identified as Hikvision with a confirmed main/sub stream.
func TestGuessRTSPStreams_DiscriminatingHikvision(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
if strings.HasPrefix(path, "/Streaming/Channels/") {
return 401, "" // valid path, needs auth
}
return 404, "" // everything else is unknown -> device discriminates
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Hikvision" {
t.Fatalf("expected brand Hikvision, got %q", brand)
}
if len(streams) == 0 || !streams[0].Verified {
t.Fatalf("expected a verified main stream, got %+v", streams)
}
if !streams[0].RequiresAuth {
t.Errorf("expected main stream to require auth")
}
if streams[0].Path != "/Streaming/Channels/101" {
t.Errorf("expected main path /Streaming/Channels/101, got %q", streams[0].Path)
}
}
// TestGuessRTSPStreams_ChallengesEverything verifies that a device which returns
// 401 for *any* path (including a bogus one) does NOT get mis-detected via path
// probing, and instead falls back to the port hint (Dahua control port 37777)
// with unverified suggestions.
func TestGuessRTSPStreams_ChallengesEverything(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "" // challenges auth before checking the path, no realm
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", []int{37777}, 2*time.Second)
if brand != "Dahua" {
t.Fatalf("expected fallback brand Dahua from port hint, got %q", brand)
}
if len(streams) == 0 {
t.Fatalf("expected suggested streams, got none")
}
if streams[0].Verified {
t.Errorf("expected unverified suggestion for a non-discriminating device")
}
if streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
t.Errorf("expected Dahua main path, got %q", streams[0].Path)
}
}
// TestGuessRTSPStreams_RealmDetectsHikvision verifies that a device which
// challenges auth for every path (so path probing cannot help) is still
// identified from its RTSP auth realm, and the model code is extracted.
func TestGuessRTSPStreams_RealmDetectsHikvision(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "IP Camera(E3669)" // Hikvision realm signature, 401 for all paths
})
defer cleanup()
brand, model, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Hikvision" {
t.Fatalf("expected brand Hikvision from realm, got %q", brand)
}
if model != "E3669" {
t.Errorf("expected model E3669 from realm, got %q", model)
}
if len(streams) == 0 || streams[0].Path != "/Streaming/Channels/101" {
t.Fatalf("expected Hikvision default main path, got %+v", streams)
}
if !streams[0].RequiresAuth {
t.Errorf("expected the suggestion to be marked auth-required")
}
}
// TestGuessRTSPStreams_RealmDetectsDahua verifies Dahua detection from its
// "Login to ..." realm.
func TestGuessRTSPStreams_RealmDetectsDahua(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "Login to 5df61a6057b10cc99d471769516d3c11"
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Dahua" {
t.Fatalf("expected brand Dahua from realm, got %q", brand)
}
if len(streams) == 0 || streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
t.Fatalf("expected Dahua default main path, got %+v", streams)
}
}
// TestGuessRTSPStreams_UnknownFallsBackToGeneric verifies that an unknown device
// (discriminating but matching no brand) yields generic suggestions.
func TestGuessRTSPStreams_UnknownFallsBackToGeneric(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 404, "" // discriminates, but nothing matches
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "" {
t.Fatalf("expected no detected brand, got %q", brand)
}
if len(streams) == 0 || streams[0].Brand != "Generic" {
t.Fatalf("expected generic suggestions, got %+v", streams)
}
}

View File

@@ -153,6 +153,22 @@ func DiscoverDevices(timeout time.Duration, subnets ...string) []models.Discover
// manufacturer, model and type without any credentials.
fingerprint := fingerprintHost(ip, openPorts, dialTimeout)
// Guess (and actively confirm) the RTSP stream URLs from a built-in
// brand -> RTSP path mapping when an RTSP port is open.
var rtspPort int
for _, port := range openPorts {
if port == 554 || port == 8554 {
rtspPort = port
break
}
}
var rtspStreams []models.RTSPStream
detectedBrand := ""
detectedModel := ""
if rtspPort != 0 {
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, fingerprint.Manufacturer, openPorts, dialTimeout)
}
device := upsert(ip)
mutex.Lock()
device.OpenPorts = mergeSortedInts(device.OpenPorts, openPorts)
@@ -163,20 +179,37 @@ func DiscoverDevices(timeout time.Duration, subnets ...string) []models.Discover
if fingerprint.Manufacturer != "" {
device.Manufacturer = fingerprint.Manufacturer
}
// A brand derived from the RTSP auth realm, a confirmed path probe or
// a vendor-specific control port is more reliable than a banner
// string, so let it win.
if detectedBrand != "" && detectedBrand != "Generic" {
device.Manufacturer = detectedBrand
device.IsCamera = true
}
if fingerprint.Model != "" {
device.Model = fingerprint.Model
}
if device.Model == "" && detectedModel != "" {
device.Model = detectedModel
}
if fingerprint.Type != "" {
device.Type = fingerprint.Type
}
if fingerprint.Server != "" {
device.Server = fingerprint.Server
}
for _, port := range openPorts {
if port == 554 || port == 8554 {
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(port) + "/"
break
if len(rtspStreams) > 0 {
device.RTSPStreams = rtspStreams
// Prefer the first verified stream as the primary RTSP URL.
device.RTSPURL = rtspStreams[0].URL
for _, stream := range rtspStreams {
if stream.Verified {
device.RTSPURL = stream.URL
break
}
}
} else if rtspPort != 0 {
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(rtspPort) + "/"
}
mutex.Unlock()
}(ip)

View File

@@ -83,6 +83,20 @@ func Discover(timeout time.Duration, subnets ...string) {
summary += " rtsp=" + device.RTSPURL
}
log.Log.Info(summary)
// Detail the guessed RTSP stream URLs from the brand -> RTSP mapping.
for _, stream := range device.RTSPStreams {
status := "guess"
if stream.Verified {
status = "confirmed"
}
line := "onvif.Discover(): -> " + stream.Stream + " stream [" + status + "]"
if stream.RequiresAuth {
line += " (auth required)"
}
line += ": " + stream.URL
log.Log.Info(line)
}
}
}