mirror of
https://github.com/kerberos-io/agent.git
synced 2026-08-23 15:08:32 +00:00
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:
@@ -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 {
|
||||
|
||||
446
machinery/src/onvif/brands.go
Normal file
446
machinery/src/onvif/brands.go
Normal 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
|
||||
}
|
||||
171
machinery/src/onvif/brands_test.go
Normal file
171
machinery/src/onvif/brands_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user