4d6c2fd878
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)
128 lines
3.1 KiB
Go
128 lines
3.1 KiB
Go
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)
|
|
}
|