// 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(` `, 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 and . func extractXMLTag(data []byte, tag string) string { open := "<" + tag + ">" close := "" 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 endClose := bytes.Index(data[start:], []byte("