diff --git a/data/camera_oui.json b/data/camera_oui.json index a72f1cb..f08c0a2 100644 --- a/data/camera_oui.json +++ b/data/camera_oui.json @@ -2401,5 +2401,7 @@ "FC:E9:D8": "Ring", "FC:EC:DA": "Ubiquiti", "FC:F1:36": "Samsung", - "FC:F1:52": "Sony" + "FC:F1:52": "Sony", + "F0:23:B9": "Trassir", + "00:05:FE": "ZOSI" } \ No newline at end of file diff --git a/internal/api/routes.go b/internal/api/routes.go index 282d49c..a4e6191 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -89,6 +89,7 @@ func NewServer( &discovery.DNSProber{}, discovery.NewARPProber(ouiDB), &discovery.MDNSProber{}, + &discovery.HTTPProber{}, } probeService := discovery.NewProbeService(probers, logger) diff --git a/internal/camera/discovery/probe.go b/internal/camera/discovery/probe.go index 7996da6..7e51713 100644 --- a/internal/camera/discovery/probe.go +++ b/internal/camera/discovery/probe.go @@ -146,6 +146,10 @@ func (s *ProbeService) Probe(ctx context.Context, ip string) *models.ProbeRespon 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 + } } } diff --git a/internal/camera/discovery/prober_http.go b/internal/camera/discovery/prober_http.go new file mode 100644 index 0000000..fa3e66f --- /dev/null +++ b/internal/camera/discovery/prober_http.go @@ -0,0 +1,87 @@ +package discovery + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + + "github.com/eduard256/Strix/internal/models" +) + +// HTTPProber identifies the device by checking HTTP server headers. +// It sends HEAD and GET requests in parallel to port 80 (some devices +// like XMEye/JAWS don't respond to HEAD), and returns whichever +// responds first. +type HTTPProber struct{} + +func (p *HTTPProber) Name() string { return "http" } + +// Probe sends parallel HEAD+GET to port 80 and extracts Server header. +// Returns nil if no HTTP server is found. +func (p *HTTPProber) Probe(ctx context.Context, ip string) (any, error) { + ports := []int{80, 8080} + + client := &http.Client{ + // Don't follow redirects -- we want the original response headers + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + type result struct { + resp *http.Response + port int + err error + } + + for _, port := range ports { + url := fmt.Sprintf("http://%s:%d/", ip, port) + ch := make(chan result, 2) + + // HEAD and GET in parallel -- take whichever responds first + for _, method := range []string{"HEAD", "GET"} { + go func(method string) { + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + ch <- result{err: err} + return + } + req.Header.Set("User-Agent", "Strix/1.0") + resp, err := client.Do(req) + ch <- result{resp: resp, port: port, err: err} + }(method) + } + + // Wait for first success + for i := 0; i < 2; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case r := <-ch: + if r.err != nil { + continue + } + if r.resp.Body != nil { + r.resp.Body.Close() + } + + server := r.resp.Header.Get("Server") + if server == "" && r.resp.StatusCode == 0 { + continue + } + + return &models.HTTPProbeResult{ + Port: r.port, + StatusCode: r.resp.StatusCode, + Server: server, + }, nil + } + } + } + + return nil, nil +} diff --git a/internal/camera/discovery/prober_mdns.go b/internal/camera/discovery/prober_mdns.go index 88e0ac9..afd743d 100644 --- a/internal/camera/discovery/prober_mdns.go +++ b/internal/camera/discovery/prober_mdns.go @@ -2,15 +2,27 @@ package discovery import ( "context" + "time" "github.com/AlexxIT/go2rtc/pkg/hap" "github.com/AlexxIT/go2rtc/pkg/mdns" "github.com/eduard256/Strix/internal/models" ) +const ( + // mdnsTimeout is the maximum time to wait for mDNS response. + // HomeKit devices respond in 2-10ms. If no response in 100ms, + // the device is definitely not a HomeKit camera. + // The underlying mdns.Query has a 1s internal timeout, but we + // cut it short with this context-based wrapper. + mdnsTimeout = 100 * time.Millisecond +) + // 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. +// Uses a 100ms timeout wrapper around go2rtc's mdns.Query to avoid +// waiting the full 1s on non-HomeKit devices. type MDNSProber struct{} func (p *MDNSProber) Name() string { return "mdns" } @@ -18,11 +30,37 @@ 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 + // Run mdns.Query in a goroutine with 100ms timeout. + // mdns.Query has an internal 1s timeout and doesn't accept context, + // so we wrap it. The background goroutine will clean up on its own + // after the internal timeout expires (~1s, negligible resource cost). + type queryResult struct { + entry *mdns.ServiceEntry + err error + } + + ch := make(chan queryResult, 1) + go func() { + entry, err := mdns.Query(ip, mdns.ServiceHAP) + ch <- queryResult{entry, err} + }() + + // Wait for result or timeout + timer := time.NewTimer(mdnsTimeout) + defer timer.Stop() + + var entry *mdns.ServiceEntry + + select { + case r := <-ch: + if r.err != nil || r.entry == nil { + return nil, nil + } + entry = r.entry + case <-timer.C: + return nil, nil // No response within 100ms -- not a HomeKit device + case <-ctx.Done(): + return nil, nil } // Check if it's complete (has IP, port, and TXT records) diff --git a/internal/models/probe.go b/internal/models/probe.go index 1306d12..ebe43cf 100644 --- a/internal/models/probe.go +++ b/internal/models/probe.go @@ -20,6 +20,14 @@ type ProbeResults struct { DNS *DNSProbeResult `json:"dns"` ARP *ARPProbeResult `json:"arp"` MDNS *MDNSProbeResult `json:"mdns"` + HTTP *HTTPProbeResult `json:"http"` +} + +// HTTPProbeResult contains HTTP server identification from port 80. +type HTTPProbeResult struct { + Port int `json:"port"` + StatusCode int `json:"status_code"` + Server string `json:"server"` } // DNSProbeResult contains reverse DNS lookup result.