NextNVR/onvif.go

242 lines
6.9 KiB
Go

// NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT
//
// Strategy:
// 1. TCP-probe port 80 — if reachable, try ONVIF GetDeviceInformation.
// 2. TCP-probe port 554 — if reachable, camera supports RTSP.
// 3. Build RTSP URLs from known brand patterns, falling back to generic URLs.
//
// No external dependencies beyond the Go standard library.
package main
import (
"bytes"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// ONVIFDiscovery represents a discovered camera.
type ONVIFDiscovery struct {
IP string `json:"ip"`
Reachable bool `json:"reachable"`
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Firmware string `json:"firmware"`
Serial string `json:"serial"`
RTSPMain string `json:"rtsp_main"`
RTSPSub string `json:"rtsp_sub"`
ONVIFPort int `json:"onvif_port"`
RTSPPort int `json:"rtsp_port"`
}
// ScanRequest is the JSON body for /api/scan.
type ScanRequest struct {
Method string `json:"method"` // "range" | "discovery"
From string `json:"from"` // starting IP for range scan
To string `json:"to"` // ending IP for range scan
Username string `json:"username"` // camera credentials
Password string `json:"password"`
}
// probeDevice attempts to detect a camera at the given IP.
// Returns an ONVIFDiscovery with as much info as it can gather.
func probeDevice(ip, username, password string) ONVIFDiscovery {
d := ONVIFDiscovery{
IP: ip,
Reachable: false,
ONVIFPort: 80,
RTSPPort: 554,
}
// 1. Check ONVIF port (80).
onvifOK := tcpProbe(ip, 80, 2*time.Second)
// 2. Check RTSP port (554).
rtspOK := tcpProbe(ip, 554, 2*time.Second)
if !onvifOK && !rtspOK {
return d
}
d.Reachable = true
// 3. Try ONVIF GetDeviceInformation if ONVIF port is open.
if onvifOK {
info, err := getDeviceInfo(ip)
if err == nil {
d.Manufacturer = info.Manufacturer
d.Model = info.Model
d.Firmware = info.FirmwareVersion
d.Serial = info.SerialNumber
}
}
// 4. Build RTSP URLs from known patterns or generic fallback.
if rtspOK {
d.RTSPMain, d.RTSPSub = buildRTSPURLs(ip, username, password, d.Manufacturer)
}
return d
}
// tcpProbe checks if a TCP port is open on the given IP.
func tcpProbe(ip string, port int, timeout time.Duration) bool {
addr := net.JoinHostPort(ip, itoa(port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
conn.Close()
return true
}
// ── Minimal ONVIF SOAP Client ──
const (
onvifDeviceService = "/onvif/device_service"
onvifXMLNS = "http://www.onvif.org/ver10/device/wsdl"
onvifSchema = "http://www.w3.org/2003/05/soap-envelope"
onvifAddrSpace = "http://www.onvif.org/ver10/device/wsdl"
)
// getDeviceInfo sends a GetDeviceInformation SOAP request to the camera.
func getDeviceInfo(ip string) (OnvifDeviceInfo, error) {
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="%s">
<s:Body>
<GetDeviceInformation xmlns="%s"/>
</s:Body>
</s:Envelope>`, onvifSchema, onvifAddrSpace)
url := "http://" + ip + onvifDeviceService
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
if err != nil {
return OnvifDeviceInfo{}, err
}
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return OnvifDeviceInfo{}, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
return parseDeviceInfo(respBody)
}
// OnvifDeviceInfo holds the parsed GetDeviceInformation response.
type OnvifDeviceInfo struct {
Manufacturer string
Model string
FirmwareVersion string
SerialNumber string
HardwareID string
}
// parseDeviceInfo extracts fields from the SOAP XML response.
func parseDeviceInfo(data []byte) (OnvifDeviceInfo, error) {
var info OnvifDeviceInfo
// Simple XML string extraction — avoids importing encoding/xml for complex SOAP.
info.Manufacturer = extractXMLTag(data, "Manufacturer")
info.Model = extractXMLTag(data, "Model")
info.FirmwareVersion = extractXMLTag(data, "FirmwareVersion")
info.SerialNumber = extractXMLTag(data, "SerialNumber")
info.HardwareID = extractXMLTag(data, "HardwareId")
if info.Manufacturer == "" && info.Model == "" {
return info, fmt.Errorf("no device info found")
}
return info, nil
}
// extractXMLTag extracts the content between <tag> and </tag>.
func extractXMLTag(data []byte, tag string) string {
open := "<" + tag + ">"
close := "</" + tag + ">"
start := bytes.Index(data, []byte(open))
if start < 0 {
// Try with namespace prefix.
start = bytes.Index(data, []byte(":"+tag+">"))
if start < 0 {
return ""
}
// Find the actual tag content after the namespace prefix.
start = bytes.Index(data[start:], []byte(">")) + start + 1
end := bytes.Index(data[start:], []byte(close))
if end < 0 {
// Try </ns:tag>
endClose := bytes.Index(data[start:], []byte("</"))
if endClose < 0 {
return ""
}
return string(data[start : start+endClose])
}
return string(data[start : start+end])
}
start += len(open)
end := bytes.Index(data[start:], []byte(close))
if end < 0 {
return ""
}
return string(data[start : start+end])
}
// ── RTSP URL Construction ──
// rtspPatterns maps manufacturer names to known RTSP URL patterns.
// {user}, {pass}, {ip}, {port} are substituted at runtime.
var rtspPatterns = map[string]struct{ main, sub string }{
"hikvision": {
main: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/101",
sub: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/102",
},
"dahua": {
main: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=0",
sub: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=1",
},
"axis": {
main: "rtsp://{user}:{pass}@{ip}:{port}/axis-media/media.amp",
sub: "rtsp://{user}:{pass}@{ip}:{port}/axis-media/media.amp?videocodec=h264&resolution=640x480",
},
"reolink": {
main: "rtsp://{user}:{pass}@{ip}:{port}/h264Preview_01_main",
sub: "rtsp://{user}:{pass}@{ip}:{port}/h264Preview_01_sub",
},
"amcrest": {
main: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=0",
sub: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=1",
},
}
// generic pattern used when manufacturer is unknown.
var genericRTSP = struct{ main, sub string }{
main: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/101",
sub: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/102",
}
// buildRTSPURLs returns main and sub stream RTSP URLs for a camera.
func buildRTSPURLs(ip, user, pass, manufacturer string) (string, string) {
patterns, ok := rtspPatterns[strings.ToLower(manufacturer)]
if !ok {
patterns = genericRTSP
}
replacer := strings.NewReplacer(
"{user}", user,
"{pass}", pass,
"{ip}", ip,
"{port}", "554",
)
return replacer.Replace(patterns.main), replacer.Replace(patterns.sub)
}