Add GET /api/v1/probe endpoint for device inspection
Fast (~1-3s) endpoint that gathers network info about a device before full stream discovery. Runs ping first, then parallel probes. Features: - Ping with ICMP + TCP fallback (works without root) - Reverse DNS hostname lookup - ARP table MAC address + OUI vendor identification (2403 entries, 51 camera vendors) - mDNS HomeKit detection (camera/doorbell, paired status) - Extensible Prober interface for adding new probe types - 3-second overall timeout, parallel execution Response includes "type" field: - "unreachable" - device not responding - "standard" - normal IP camera (RTSP/HTTP/ONVIF flow) - "homekit" - Apple HomeKit camera (PIN pairing flow)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/eduard256/Strix/internal/camera/discovery"
|
||||
)
|
||||
|
||||
// ProbeHandler handles device probe requests.
|
||||
// GET /api/v1/probe?ip=192.168.1.50
|
||||
type ProbeHandler struct {
|
||||
probeService *discovery.ProbeService
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
}
|
||||
}
|
||||
|
||||
// NewProbeHandler creates a new probe handler.
|
||||
func NewProbeHandler(
|
||||
probeService *discovery.ProbeService,
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
},
|
||||
) *ProbeHandler {
|
||||
return &ProbeHandler{
|
||||
probeService: probeService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles probe requests.
|
||||
func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.URL.Query().Get("ip")
|
||||
if ip == "" {
|
||||
h.sendError(w, "Missing required parameter: ip", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate IP format
|
||||
if net.ParseIP(ip) == nil {
|
||||
h.sendError(w, "Invalid IP address: "+ip, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("probe requested", "ip", ip, "remote_addr", r.RemoteAddr)
|
||||
|
||||
// Run probe
|
||||
result := h.probeService.Probe(r.Context(), ip)
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
h.logger.Error("failed to encode probe response", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends a JSON error response.
|
||||
func (h *ProbeHandler) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": true,
|
||||
"message": message,
|
||||
"code": statusCode,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Server struct {
|
||||
loader *database.Loader
|
||||
searchEngine *database.SearchEngine
|
||||
scanner *discovery.Scanner
|
||||
probeService *discovery.ProbeService
|
||||
sseServer *sse.Server
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
|
||||
}
|
||||
@@ -75,6 +76,22 @@ func NewServer(
|
||||
// Initialize SSE server
|
||||
sseServer := sse.NewServer(logger)
|
||||
|
||||
// Initialize OUI database for vendor identification
|
||||
ouiDB := discovery.NewOUIDatabase()
|
||||
if err := ouiDB.LoadFromFile(cfg.Database.OUIPath); err != nil {
|
||||
logger.Error("failed to load OUI database, vendor lookup will be unavailable", err)
|
||||
} else {
|
||||
logger.Info("OUI database loaded", "entries", ouiDB.Size())
|
||||
}
|
||||
|
||||
// Initialize ProbeService with all probers
|
||||
probers := []discovery.Prober{
|
||||
&discovery.DNSProber{},
|
||||
discovery.NewARPProber(ouiDB),
|
||||
&discovery.MDNSProber{},
|
||||
}
|
||||
probeService := discovery.NewProbeService(probers, logger)
|
||||
|
||||
// Create server
|
||||
server := &Server{
|
||||
router: chi.NewRouter(),
|
||||
@@ -82,6 +99,7 @@ func NewServer(
|
||||
loader: loader,
|
||||
searchEngine: searchEngine,
|
||||
scanner: scanner,
|
||||
probeService: probeService,
|
||||
sseServer: sseServer,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -127,6 +145,9 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
// Stream discovery (SSE)
|
||||
s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP)
|
||||
|
||||
// Device probe (ping + DNS + ARP/OUI + mDNS)
|
||||
s.router.Get("/probe", handlers.NewProbeHandler(s.probeService, s.logger).ServeHTTP)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// OUIDatabase provides MAC address prefix to vendor name lookup.
|
||||
// Data is loaded from a JSON file containing camera/surveillance vendor OUI prefixes.
|
||||
type OUIDatabase struct {
|
||||
data map[string]string // "C0:56:E3" -> "Hikvision"
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewOUIDatabase creates an empty OUI database.
|
||||
func NewOUIDatabase() *OUIDatabase {
|
||||
return &OUIDatabase{
|
||||
data: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromFile loads OUI data from a JSON file.
|
||||
// Expected format: {"C0:56:E3": "Hikvision", "54:EF:44": "Lumi/Aqara", ...}
|
||||
func (db *OUIDatabase) LoadFromFile(path string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open OUI database: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var data map[string]string
|
||||
if err := json.NewDecoder(file).Decode(&data); err != nil {
|
||||
return fmt.Errorf("failed to decode OUI database: %w", err)
|
||||
}
|
||||
|
||||
// Normalize all keys to uppercase
|
||||
normalized := make(map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
normalized[strings.ToUpper(k)] = v
|
||||
}
|
||||
|
||||
db.mu.Lock()
|
||||
db.data = normalized
|
||||
db.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupVendor returns the vendor name for a given MAC address.
|
||||
// MAC can be in any format: "C0:56:E3:AA:BB:CC", "c0:56:e3:aa:bb:cc", "C0-56-E3-AA-BB-CC".
|
||||
// Returns empty string if not found.
|
||||
func (db *OUIDatabase) LookupVendor(mac string) string {
|
||||
if len(mac) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Normalize: uppercase and replace dashes with colons
|
||||
prefix := strings.ToUpper(mac[:8])
|
||||
prefix = strings.ReplaceAll(prefix, "-", ":")
|
||||
|
||||
db.mu.RLock()
|
||||
vendor := db.data[prefix]
|
||||
db.mu.RUnlock()
|
||||
|
||||
return vendor
|
||||
}
|
||||
|
||||
// Size returns the number of entries in the database.
|
||||
func (db *OUIDatabase) Size() int {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
return len(db.data)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProbeTimeout is the overall timeout for all probes combined.
|
||||
ProbeTimeout = 3 * time.Second
|
||||
|
||||
// ProbeTypeUnreachable indicates the device did not respond to ping.
|
||||
ProbeTypeUnreachable = "unreachable"
|
||||
// ProbeTypeStandard indicates a normal IP camera (RTSP/HTTP/ONVIF).
|
||||
ProbeTypeStandard = "standard"
|
||||
// ProbeTypeHomeKit indicates an Apple HomeKit camera that needs PIN pairing.
|
||||
ProbeTypeHomeKit = "homekit"
|
||||
)
|
||||
|
||||
// Prober is an interface for network probe implementations.
|
||||
// Each prober discovers specific information about a device at a given IP.
|
||||
// New probers can be added by implementing this interface and registering
|
||||
// them with ProbeService.
|
||||
type Prober interface {
|
||||
// Name returns a unique identifier for this prober (e.g., "dns", "arp", "mdns").
|
||||
Name() string
|
||||
// Probe runs the probe against the given IP address.
|
||||
// Must respect context cancellation/timeout.
|
||||
// Returns nil result if nothing was found (not an error).
|
||||
Probe(ctx context.Context, ip string) (any, error)
|
||||
}
|
||||
|
||||
// ProbeService orchestrates multiple probers to gather information about a device.
|
||||
// It first pings the device, then runs all registered probers in parallel.
|
||||
type ProbeService struct {
|
||||
pinger *PingProber
|
||||
probers []Prober
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
}
|
||||
}
|
||||
|
||||
// NewProbeService creates a new ProbeService with the given probers.
|
||||
// The ping prober is always included and runs first.
|
||||
func NewProbeService(
|
||||
probers []Prober,
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
},
|
||||
) *ProbeService {
|
||||
return &ProbeService{
|
||||
pinger: &PingProber{},
|
||||
probers: probers,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Probe runs ping + all registered probers against the given IP.
|
||||
// Overall timeout is 3 seconds. Results are collected from whatever
|
||||
// finishes in time; slow probers are omitted (nil in response).
|
||||
func (s *ProbeService) Probe(ctx context.Context, ip string) *models.ProbeResponse {
|
||||
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
response := &models.ProbeResponse{
|
||||
IP: ip,
|
||||
Type: ProbeTypeStandard,
|
||||
}
|
||||
|
||||
// Step 1: Ping
|
||||
s.logger.Debug("probing device", "ip", ip)
|
||||
|
||||
pingResult, err := s.pinger.Ping(ctx, ip)
|
||||
if err != nil || !pingResult.Reachable {
|
||||
errMsg := "device unreachable"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
s.logger.Debug("ping failed", "ip", ip, "error", errMsg)
|
||||
response.Reachable = false
|
||||
response.Type = ProbeTypeUnreachable
|
||||
response.Error = errMsg
|
||||
return response
|
||||
}
|
||||
|
||||
response.Reachable = true
|
||||
response.LatencyMs = pingResult.LatencyMs
|
||||
s.logger.Debug("ping OK", "ip", ip, "latency_ms", pingResult.LatencyMs)
|
||||
|
||||
// Step 2: Run all probers in parallel
|
||||
type probeResult struct {
|
||||
name string
|
||||
data any
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan probeResult, len(s.probers))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, p := range s.probers {
|
||||
wg.Add(1)
|
||||
go func(prober Prober) {
|
||||
defer wg.Done()
|
||||
data, err := prober.Probe(ctx, ip)
|
||||
results <- probeResult{
|
||||
name: prober.Name(),
|
||||
data: data,
|
||||
err: err,
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
|
||||
// Close results channel when all probers finish
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
for r := range results {
|
||||
if r.err != nil {
|
||||
s.logger.Debug("prober failed", "prober", r.name, "error", r.err.Error())
|
||||
continue
|
||||
}
|
||||
if r.data == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch r.name {
|
||||
case "dns":
|
||||
if v, ok := r.data.(*models.DNSProbeResult); ok {
|
||||
response.Probes.DNS = v
|
||||
}
|
||||
case "arp":
|
||||
if v, ok := r.data.(*models.ARPProbeResult); ok {
|
||||
response.Probes.ARP = v
|
||||
}
|
||||
case "mdns":
|
||||
if v, ok := r.data.(*models.MDNSProbeResult); ok {
|
||||
response.Probes.MDNS = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Determine type based on probe results
|
||||
response.Type = s.determineType(response)
|
||||
|
||||
s.logger.Info("probe completed",
|
||||
"ip", ip,
|
||||
"reachable", response.Reachable,
|
||||
"type", response.Type,
|
||||
"latency_ms", response.LatencyMs,
|
||||
)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// determineType decides the device type based on collected probe results.
|
||||
func (s *ProbeService) determineType(response *models.ProbeResponse) string {
|
||||
if !response.Reachable {
|
||||
return ProbeTypeUnreachable
|
||||
}
|
||||
|
||||
// HomeKit camera that is not yet paired
|
||||
if response.Probes.MDNS != nil && !response.Probes.MDNS.Paired {
|
||||
category := response.Probes.MDNS.Category
|
||||
if category == "camera" || category == "doorbell" {
|
||||
return ProbeTypeHomeKit
|
||||
}
|
||||
}
|
||||
|
||||
return ProbeTypeStandard
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// ARPProber looks up the MAC address from the system ARP table
|
||||
// and resolves it to a vendor name using the OUI database.
|
||||
type ARPProber struct {
|
||||
ouiDB *OUIDatabase
|
||||
}
|
||||
|
||||
// NewARPProber creates a new ARP prober with the given OUI database.
|
||||
func NewARPProber(ouiDB *OUIDatabase) *ARPProber {
|
||||
return &ARPProber{ouiDB: ouiDB}
|
||||
}
|
||||
|
||||
func (p *ARPProber) Name() string { return "arp" }
|
||||
|
||||
// Probe looks up the MAC address for the given IP in the ARP table.
|
||||
// Returns nil if the IP is not in the ARP table (e.g., different subnet, VPN).
|
||||
// This only works on Linux (reads /proc/net/arp).
|
||||
func (p *ARPProber) Probe(ctx context.Context, ip string) (any, error) {
|
||||
mac, err := p.lookupARP(ip)
|
||||
if err != nil || mac == "" {
|
||||
return nil, nil // Not in ARP table is not an error
|
||||
}
|
||||
|
||||
vendor := ""
|
||||
if p.ouiDB != nil {
|
||||
vendor = p.ouiDB.LookupVendor(mac)
|
||||
}
|
||||
|
||||
return &models.ARPProbeResult{
|
||||
MAC: mac,
|
||||
Vendor: vendor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// lookupARP reads /proc/net/arp to find the MAC address for the given IP.
|
||||
//
|
||||
// Format of /proc/net/arp:
|
||||
//
|
||||
// IP address HW type Flags HW address Mask Device
|
||||
// 192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
|
||||
func (p *ARPProber) lookupARP(ip string) (string, error) {
|
||||
file, err := os.Open("/proc/net/arp")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open ARP table: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Scan() // Skip header line
|
||||
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
// fields[0] = IP address, fields[3] = HW address
|
||||
if fields[0] == ip {
|
||||
mac := fields[3]
|
||||
// "00:00:00:00:00:00" means incomplete ARP entry
|
||||
if mac == "00:00:00:00:00:00" {
|
||||
return "", nil
|
||||
}
|
||||
return strings.ToUpper(mac), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// DNSProber performs reverse DNS lookup to find the hostname of a device.
|
||||
type DNSProber struct{}
|
||||
|
||||
func (p *DNSProber) Name() string { return "dns" }
|
||||
|
||||
// Probe performs a reverse DNS lookup on the given IP.
|
||||
// Returns nil if no hostname is found (not an error).
|
||||
func (p *DNSProber) Probe(ctx context.Context, ip string) (any, error) {
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
names, err := resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
return nil, nil // No hostname found is not an error
|
||||
}
|
||||
|
||||
// LookupAddr returns FQDNs with trailing dot, remove it
|
||||
hostname := strings.TrimSuffix(names[0], ".")
|
||||
|
||||
if hostname == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &models.DNSProbeResult{
|
||||
Hostname: hostname,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/AlexxIT/go2rtc/pkg/hap"
|
||||
"github.com/AlexxIT/go2rtc/pkg/mdns"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// MDNSProber performs mDNS unicast query to detect HomeKit devices.
|
||||
// It sends a DNS query to ip:5353 for the _hap._tcp.local. service
|
||||
// and parses TXT records to extract device information.
|
||||
type MDNSProber struct{}
|
||||
|
||||
func (p *MDNSProber) Name() string { return "mdns" }
|
||||
|
||||
// Probe queries the device for HomeKit (HAP) mDNS service.
|
||||
// Returns nil if the device does not advertise HomeKit or is not a camera/doorbell.
|
||||
func (p *MDNSProber) Probe(ctx context.Context, ip string) (any, error) {
|
||||
// Unicast mDNS query directly to the device IP.
|
||||
// mdns.Query has internal timeouts (~1s), which fits within our 3s budget.
|
||||
entry, err := mdns.Query(ip, mdns.ServiceHAP)
|
||||
if err != nil || entry == nil {
|
||||
return nil, nil // Not a HomeKit device is not an error
|
||||
}
|
||||
|
||||
// Check if it's complete (has IP, port, and TXT records)
|
||||
if !entry.Complete() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Check if it's a camera or doorbell
|
||||
category := entry.Info[hap.TXTCategory]
|
||||
if category != hap.CategoryCamera && category != hap.CategoryDoorbell {
|
||||
return nil, nil // Not a camera/doorbell, ignore
|
||||
}
|
||||
|
||||
// Map category ID to human-readable name
|
||||
categoryName := "camera"
|
||||
if category == hap.CategoryDoorbell {
|
||||
categoryName = "doorbell"
|
||||
}
|
||||
|
||||
// Determine paired status: sf=0 means paired, sf=1 means not paired
|
||||
paired := entry.Info[hap.TXTStatusFlags] == hap.StatusPaired
|
||||
|
||||
return &models.MDNSProbeResult{
|
||||
Name: entry.Name,
|
||||
DeviceID: entry.Info[hap.TXTDeviceID],
|
||||
Model: entry.Info[hap.TXTModel],
|
||||
Category: categoryName,
|
||||
Paired: paired,
|
||||
Port: int(entry.Port),
|
||||
Feature: entry.Info[hap.TXTFeatureFlags],
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PingResult contains the result of a ping probe.
|
||||
type PingResult struct {
|
||||
Reachable bool
|
||||
LatencyMs float64
|
||||
}
|
||||
|
||||
// PingProber checks if a device is reachable on the network.
|
||||
// It tries ICMP ping first (requires root/CAP_NET_RAW), then falls back
|
||||
// to TCP connect on common camera ports (80, 554, 443, 8080).
|
||||
type PingProber struct{}
|
||||
|
||||
// Ping checks if the device at the given IP is reachable.
|
||||
func (p *PingProber) Ping(ctx context.Context, ip string) (*PingResult, error) {
|
||||
// Try ICMP first (works if running as root or with CAP_NET_RAW)
|
||||
result, err := p.tryICMP(ctx, ip)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Fallback: TCP connect on common camera ports
|
||||
result, err = p.tryTCP(ctx, ip)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return &PingResult{Reachable: false}, fmt.Errorf("device unreachable: %s", ip)
|
||||
}
|
||||
|
||||
// tryICMP attempts an ICMP ping using raw socket.
|
||||
func (p *PingProber) tryICMP(ctx context.Context, ip string) (*PingResult, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
}
|
||||
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
// Cap ICMP timeout to 2 seconds to leave time for other probes
|
||||
if timeout > 2*time.Second {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
return &PingResult{
|
||||
Reachable: true,
|
||||
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tryTCP attempts TCP connect on common camera ports as a ping fallback.
|
||||
// This works without root privileges and is reliable for cameras since
|
||||
// they almost always have at least one of these ports open.
|
||||
func (p *PingProber) tryTCP(ctx context.Context, ip string) (*PingResult, error) {
|
||||
commonPorts := []int{80, 554, 443, 8080, 8443, 34567, 5353}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(2 * time.Second)
|
||||
}
|
||||
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
// Cap per-port timeout
|
||||
perPortTimeout := timeout / time.Duration(len(commonPorts))
|
||||
if perPortTimeout > 500*time.Millisecond {
|
||||
perPortTimeout = 500 * time.Millisecond
|
||||
}
|
||||
|
||||
type tcpResult struct {
|
||||
latency time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan tcpResult, len(commonPorts))
|
||||
|
||||
for _, port := range commonPorts {
|
||||
go func(port int) {
|
||||
addr := fmt.Sprintf("%s:%d", ip, port)
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, perPortTimeout)
|
||||
if err != nil {
|
||||
results <- tcpResult{err: err}
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
results <- tcpResult{latency: time.Since(start)}
|
||||
}(port)
|
||||
}
|
||||
|
||||
// Wait for first success or all failures
|
||||
var lastErr error
|
||||
for range commonPorts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case r := <-results:
|
||||
if r.err == nil {
|
||||
return &PingResult{
|
||||
Reachable: true,
|
||||
LatencyMs: float64(r.latency.Microseconds()) / 1000.0,
|
||||
}, nil
|
||||
}
|
||||
lastErr = r.err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all TCP ports closed: %w", lastErr)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type DatabaseConfig struct {
|
||||
BrandsPath string
|
||||
PatternsPath string
|
||||
ParametersPath string
|
||||
OUIPath string
|
||||
CacheEnabled bool
|
||||
CacheTTL time.Duration
|
||||
}
|
||||
@@ -80,6 +81,7 @@ func Load() *Config {
|
||||
BrandsPath: filepath.Join(dataPath, "brands"),
|
||||
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
|
||||
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
|
||||
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
|
||||
CacheEnabled: true,
|
||||
CacheTTL: 5 * time.Minute,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
// ProbeResponse represents the result of probing an IP address.
|
||||
// The Type field determines which UI flow the frontend should use:
|
||||
// - "unreachable" -- device did not respond to ping
|
||||
// - "standard" -- normal IP camera (RTSP/HTTP/ONVIF)
|
||||
// - "homekit" -- Apple HomeKit camera (needs PIN pairing)
|
||||
type ProbeResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs float64 `json:"latency_ms,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Probes ProbeResults `json:"probes"`
|
||||
}
|
||||
|
||||
// ProbeResults contains results from all parallel probers.
|
||||
// Nil fields mean the prober did not find anything or timed out.
|
||||
type ProbeResults struct {
|
||||
DNS *DNSProbeResult `json:"dns"`
|
||||
ARP *ARPProbeResult `json:"arp"`
|
||||
MDNS *MDNSProbeResult `json:"mdns"`
|
||||
}
|
||||
|
||||
// DNSProbeResult contains reverse DNS lookup result.
|
||||
type DNSProbeResult struct {
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// ARPProbeResult contains ARP table lookup + OUI vendor identification.
|
||||
type ARPProbeResult struct {
|
||||
MAC string `json:"mac"`
|
||||
Vendor string `json:"vendor"`
|
||||
}
|
||||
|
||||
// MDNSProbeResult contains mDNS service discovery result (HomeKit).
|
||||
type MDNSProbeResult struct {
|
||||
Name string `json:"name"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Model string `json:"model"`
|
||||
Category string `json:"category"` // "camera", "doorbell"
|
||||
Paired bool `json:"paired"`
|
||||
Port int `json:"port"`
|
||||
Feature string `json:"feature"`
|
||||
}
|
||||
Reference in New Issue
Block a user