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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user