3fec89be7f
- Add HTTPProber: parallel HEAD+GET on ports 80/8080, extracts Server header - Reduce mDNS timeout from 1s to 100ms using context wrapper around mdns.Query (HomeKit devices respond in 2-10ms, no need to wait 1s) - Add Trassir (F0:23:B9) and ZOSI (00:05:FE) to camera OUI database - Probe response time improved from ~1s to ~110ms for reachable devices
185 lines
4.6 KiB
Go
185 lines
4.6 KiB
Go
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
|
|
}
|
|
case "http":
|
|
if v, ok := r.data.(*models.HTTPProbeResult); ok {
|
|
response.Probes.HTTP = 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
|
|
}
|