242 lines
6.9 KiB
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)
|
|
}
|