Enhance ONVIF fingerprinting and brand profiles

Expand discovery and classification to better identify device vendors and stream paths by adding new brand aliases/profiles (including D-Link, Trendnet, Lorex, Honeywell, Pelco, and TOA), improved realm matching, and hostname-based brand hints. Add HTTP body fingerprinting for OEM/rebadged devices, introduce audio-device detection with a new `is_audio` API field, and prevent camera RTSP guessing/fallback URLs for audio-only devices while updating discovery logging labels.
This commit is contained in:
Cédric Verstraeten
2026-07-16 14:39:55 +02:00
parent ddf58fe633
commit 57ef7ebaaf
5 changed files with 213 additions and 5 deletions

View File

@@ -39,6 +39,9 @@ type DiscoveredDevice struct {
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"`
// IsAudio marks audio-only devices (e.g. IP speakers / intercoms such as
// TOA) that expose RTSP to receive/stream audio rather than video.
IsAudio bool `json:"is_audio" bson:"is_audio"`
}
// RTSPStream is a candidate RTSP stream URL for a discovered camera, derived

View File

@@ -63,7 +63,7 @@ var brandProfiles = []brandProfile{
},
{
Brand: "Reolink",
Aliases: []string{"reolink"},
Aliases: []string{"reolink", "rlc", "rln", "rlc-", "rln-", "trackmix", "duo"},
MainPath: "/h264Preview_01_main",
SubPath: "/h264Preview_01_sub",
extraMainPaths: []string{"/Preview_01_main"},
@@ -80,7 +80,7 @@ var brandProfiles = []brandProfile{
Aliases: []string{"bosch"},
MainPath: "/rtsp_tunnel",
SubPath: "/rtsp_tunnel?inst=2",
extraMainPaths: []string{"/?inst=1"},
extraMainPaths: []string{"/rtsp_tunnel?inst=1", "/?inst=1"},
},
{
Brand: "Vivotek",
@@ -134,6 +134,62 @@ var brandProfiles = []brandProfile{
MainPath: "/media/video1",
SubPath: "/media/video2",
},
{
// D-Link mydlink IP cameras. Older models stream MJPEG over HTTP; the
// RTSP-capable ones expose SDP-named streams, newer DCS models use
// "/live/profile.0".
Brand: "D-Link",
Aliases: []string{"d-link", "dlink", "dcs-", "dcs"},
MainPath: "/live1.sdp",
SubPath: "/live2.sdp",
extraMainPaths: []string{"/live.sdp", "/live/profile.0", "/play1.sdp"},
},
{
// TRENDnet. Newer PoE bullet/dome models (TV-IPxxxPI) use a
// Hikvision-style path; older ones expose SDP streams.
Brand: "Trendnet",
Aliases: []string{"trendnet", "tv-ip"},
MainPath: "/Streaming/Channels/101",
SubPath: "/Streaming/Channels/102",
extraMainPaths: []string{"/play1.sdp", "/play2.sdp", "/ch0_0.h264", "/live/av0"},
},
{
// Lorex is built largely on Dahua hardware, so it shares Dahua's
// realmonitor path scheme.
Brand: "Lorex",
Aliases: []string{"lorex"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/ch01/0"},
},
{
// Honeywell ships both Dahua-OEM models (realmonitor) and in-house
// firmwares exposing "/h264" or "/media".
Brand: "Honeywell",
Aliases: []string{"honeywell"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/h264", "/media", "/live.sdp"},
},
{
Brand: "Pelco",
Aliases: []string{"pelco"},
MainPath: "/stream1",
SubPath: "/stream2",
extraMainPaths: []string{"/1/stream1"},
},
{
// TOA network audio devices (IP horn speakers / intercoms, banner
// "TOA rtsp server") expose their stream through ONVIF rather than a
// documented fixed RTSP path. These ONVIF-style paths are a best-effort
// default; the authoritative URL should come from an ONVIF GetStreamUri
// query with credentials.
Brand: "TOA",
Aliases: []string{"toa"},
MainPath: "/ONVIF/channel1",
SubPath: "/ONVIF/channel2",
extraMainPaths: []string{"/media/video1", "/live"},
},
{
// Linksys/Cisco IP cameras (e.g. LCAD03FLN, LCAB03VLNOD, LCAM0336OD)
// run a mini_httpd server and expose ONVIF-style stream paths with a
@@ -199,6 +255,13 @@ var realmBrands = []struct {
{"vivotek", "Vivotek"},
{"mobotix", "Mobotix"},
{"bosch", "Bosch"},
{"please log in with a valid username", "Bosch"},
{"d-link", "D-Link"},
{"dcs-", "D-Link"},
{"trendnet", "Trendnet"},
{"lorex", "Lorex"},
{"honeywell", "Honeywell"},
{"pelco", "Pelco"},
{"linksys", "Linksys"},
{"lcad", "Linksys"},
{"lcab", "Linksys"},

View File

@@ -153,6 +153,20 @@ func DiscoverDevices(timeout time.Duration, subnets ...string) []models.Discover
// manufacturer, model and type without any credentials.
fingerprint := fingerprintHost(ip, openPorts, dialTimeout)
// Resolve a hostname now (ONVIF WS-Discovery may already have set
// one; otherwise fall back to reverse DNS). Camera hostnames often
// encode the model (e.g. Reolink "RLC-823S2"), which is a useful
// brand hint when the RTSP/HTTP banners are anonymous.
mutex.Lock()
hostname := ""
if existing, ok := devicesByIP[ip]; ok {
hostname = existing.Hostname
}
mutex.Unlock()
if hostname == "" {
hostname = reverseDNS(ip, 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
@@ -162,20 +176,34 @@ func DiscoverDevices(timeout time.Duration, subnets ...string) []models.Discover
break
}
}
// The banner manufacturer is most reliable; fall back to the
// hostname (model code) so devices that only reveal themselves via
// their name (e.g. Reolink RLC-*) still get the right stream paths.
brandHint := fingerprint.Manufacturer
if brandHint == "" {
brandHint = hostname
}
var rtspStreams []models.RTSPStream
detectedBrand := ""
detectedModel := ""
if rtspPort != 0 {
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, fingerprint.Manufacturer, openPorts, dialTimeout)
if rtspPort != 0 && !fingerprint.IsAudio {
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, brandHint, openPorts, dialTimeout)
}
device := upsert(ip)
mutex.Lock()
device.OpenPorts = mergeSortedInts(device.OpenPorts, openPorts)
device.Services = mergeUniqueStrings(device.Services, services)
if hostname != "" && device.Hostname == "" {
device.Hostname = hostname
}
if isCamera || fingerprint.IsCamera {
device.IsCamera = true
}
if fingerprint.IsAudio {
device.IsAudio = true
device.IsCamera = false
}
if fingerprint.Manufacturer != "" {
device.Manufacturer = fingerprint.Manufacturer
}
@@ -208,7 +236,7 @@ func DiscoverDevices(timeout time.Duration, subnets ...string) []models.Discover
break
}
}
} else if rtspPort != 0 {
} else if rtspPort != 0 && !fingerprint.IsAudio {
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(rtspPort) + "/"
}
mutex.Unlock()

View File

@@ -20,9 +20,16 @@ type deviceFingerprint struct {
// realm is the WWW-Authenticate realm advertised by the HTTP service. Many
// cameras expose their model or vendor here (e.g. realm="Hikvision").
realm string
// body holds a lower-cased slice of the HTTP landing page, fetched only when
// the banners are anonymous. Rebadged/OEM cameras often reveal their vendor
// there (logo filenames, embedded scripts), e.g. ADI "Capture".
body string
// IsCamera is set when the collected evidence confidently identifies the
// device as a camera, NVR or DVR.
IsCamera bool
// IsAudio is set for audio-only devices (IP speakers / intercoms, e.g. TOA)
// that use RTSP for audio rather than video.
IsAudio bool
}
// bannerVendors maps a lower-cased substring commonly found in RTSP/HTTP
@@ -51,6 +58,13 @@ var bannerVendors = []struct {
{"tp-link", "TP-Link", true},
{"tapo", "TP-Link", true},
{"linksys", "Linksys", true},
{"d-link", "D-Link", true},
{"dlink", "D-Link", true},
{"trendnet", "Trendnet", true},
{"lorex", "Lorex", true},
{"honeywell", "Honeywell", true},
{"pelco", "Pelco", true},
{"toa rtsp", "TOA", false},
{"hipcam", "Hipcam", true},
{"h264dvr", "Generic DVR", true},
{"dvrdvs", "Hikvision", true},
@@ -60,6 +74,20 @@ var bannerVendors = []struct {
{"live555", "", true},
}
// bodyVendors maps a distinctive lower-cased substring found in a camera's HTML
// landing page (logo filename, embedded script, product string) to a
// manufacturer. Used only when the RTSP/HTTP banners are anonymous, so it can
// identify rebadged/OEM cameras (e.g. ADI "Capture") that hide their model
// behind a generic "httpd" server and an "RTSP" realm.
var bodyVendors = []struct {
Match string
Vendor string
IsCamera bool
}{
{"logo_white(capture)", "Capture", true},
{"logo_capture", "Capture", true},
}
// genericRealms are auth realms that carry no useful model/vendor information.
var genericRealms = map[string]struct{}{
"": {},
@@ -74,6 +102,8 @@ var genericRealms = map[string]struct{}{
"web": {},
"protected": {},
"authorized users only": {},
"please log in with a valid username.": {},
"please log in with a valid username": {},
}
// fingerprintHost grabs the RTSP and HTTP banners for the given host (based on
@@ -98,6 +128,7 @@ func fingerprintHost(ip string, openPorts []int, timeout time.Duration) deviceFi
// 2) HTTP banner + auth realm on the first open HTTP/ONVIF port. Cameras
// frequently expose their vendor/model in the Server header or the
// WWW-Authenticate realm.
httpPort := 0
for _, port := range openPorts {
if port == 80 || port == 8080 || port == 8000 {
server, realm := httpBanner(ip, port, timeout)
@@ -105,14 +136,68 @@ func fingerprintHost(ip string, openPorts []int, timeout time.Duration) deviceFi
fp.Server = server
}
fp.realm = realm
httpPort = port
break
}
}
// 3) When the banners are anonymous (generic server, no vendor realm), fetch
// a slice of the landing page. Rebadged/OEM cameras (e.g. ADI "Capture")
// only reveal their vendor in the HTML.
if httpPort != 0 && isGenericServer(fp.Server) {
fp.body = httpBody(ip, httpPort, timeout)
}
classifyFingerprint(&fp, openPorts)
return fp
}
// isGenericServer reports whether an HTTP Server header is a generic embedded
// web server that carries no vendor information (so the HTML body is worth a
// look).
func isGenericServer(server string) bool {
s := strings.ToLower(strings.TrimSpace(server))
if s == "" {
return true
}
for _, generic := range []string{"httpd", "webs", "boa", "lighttpd", "nginx", "gsoap", "mini_httpd", "thttpd", "apache"} {
if strings.Contains(s, generic) {
return true
}
}
return false
}
// httpBody issues an unauthenticated HTTP GET / and returns a lower-cased,
// size-bounded slice of the response (headers + body). Best-effort; empty on
// error.
func httpBody(ip string, port int, timeout time.Duration) string {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "GET / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return ""
}
var builder strings.Builder
buf := make([]byte, 4096)
for builder.Len() < 65536 {
n, err := conn.Read(buf)
if n > 0 {
builder.Write(buf[:n])
}
if err != nil {
break
}
}
return strings.ToLower(builder.String())
}
// rtspServerBanner issues an unauthenticated RTSP OPTIONS request and returns
// the value of the Server response header (empty when the host does not answer
// or exposes no banner).
@@ -238,11 +323,38 @@ func classifyFingerprint(fp *deviceFingerprint, openPorts []int) {
}
}
// Vendor from the HTML landing page when the banners revealed nothing.
// Rebadged/OEM cameras (e.g. ADI "Capture") only identify themselves via
// logo filenames or embedded scripts.
if fp.Manufacturer == "" && fp.body != "" {
for _, entry := range bodyVendors {
if strings.Contains(fp.body, entry.Match) {
fp.Manufacturer = entry.Vendor
if entry.IsCamera {
fp.IsCamera = true
}
break
}
}
}
// Device type from ports and banners.
hasRTSP := containsInt(openPorts, 554) || containsInt(openPorts, 8554)
hasONVIF := containsInt(openPorts, 8000) || containsInt(openPorts, 8899)
hasDVRPort := containsInt(openPorts, 37777) || containsInt(openPorts, 34567)
// Audio devices (IP speakers / intercoms) also speak RTSP, but for audio
// rather than video, so classify them separately and never as a camera.
if fp.Manufacturer == "TOA" ||
strings.Contains(haystack, "speaker") ||
strings.Contains(haystack, "sip audio") ||
strings.Contains(haystack, "audio server") {
fp.IsAudio = true
fp.IsCamera = false
fp.Type = "IP Speaker/Audio"
return
}
switch {
case strings.Contains(haystack, "nvr"):
fp.Type = "NVR"

View File

@@ -50,6 +50,8 @@ func Discover(timeout time.Duration, subnets ...string) {
label := "device"
if device.IsCamera {
label = "camera"
} else if device.IsAudio {
label = "speaker"
}
summary := "onvif.Discover(): [" + label + "] " + device.IP
if device.Hostname != "" {