Rewrite Strix from scratch as single binary
Complete architecture rewrite following go2rtc patterns: - pkg/ for pure logic (camdb, tester, probe, generate) - internal/ for application glue with Init() modules - Single HTTP server on :4567 with all endpoints - zerolog with password masking and memory ring buffer - Environment-based config only (no YAML files) API endpoints: /api/search, /api/streams, /api/test, /api/probe, /api/generate, /api/health, /api/log Dependencies: go2rtc v1.9.14, go-sqlite3, miekg/dns, zerolog
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LookupARP reads /proc/net/arp to find MAC address for ip. Linux only.
|
||||
func LookupARP(ip string) string {
|
||||
file, err := os.Open("/proc/net/arp")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Scan() // skip header
|
||||
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if fields[0] == ip {
|
||||
mac := fields[3]
|
||||
if mac == "00:00:00:00:00:00" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(mac)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReverseDNS(ctx context.Context, ip string) (*DNSResult, error) {
|
||||
names, err := net.DefaultResolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hostname := strings.TrimSuffix(names[0], ".")
|
||||
if hostname == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &DNSResult{Hostname: hostname}, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ProbeHTTP(ctx context.Context, ip string, ports []int) (*HTTPResult, error) {
|
||||
if len(ports) == 0 {
|
||||
ports = []int{80, 8080}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
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
|
||||
}
|
||||
|
||||
ch := make(chan result, len(ports))
|
||||
|
||||
for _, port := range ports {
|
||||
go func(port int) {
|
||||
url := fmt.Sprintf("http://%s:%d/", ip, port)
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Strix/2.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ch <- result{resp: resp, port: port}
|
||||
}(port)
|
||||
}
|
||||
|
||||
for range ports {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil
|
||||
case r := <-ch:
|
||||
if r.resp.Body != nil {
|
||||
r.resp.Body.Close()
|
||||
}
|
||||
return &HTTPResult{
|
||||
Port: r.port,
|
||||
StatusCode: r.resp.StatusCode,
|
||||
Server: r.resp.Header.Get("Server"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const (
|
||||
hapService = "_hap._tcp.local."
|
||||
|
||||
txtCategory = "ci"
|
||||
txtDeviceID = "id"
|
||||
txtModel = "md"
|
||||
txtStatusFlags = "sf"
|
||||
|
||||
statusPaired = "0"
|
||||
categoryCamera = "17"
|
||||
categoryDoorbell = "18"
|
||||
)
|
||||
|
||||
// QueryHAP sends unicast mDNS query to ip:5353 for HomeKit service.
|
||||
// Returns nil if device is not a HomeKit camera/doorbell.
|
||||
func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
|
||||
msg := &dns.Msg{
|
||||
Question: []dns.Question{
|
||||
{Name: hapService, Qtype: dns.TypePTR, Qclass: dns.ClassINET},
|
||||
},
|
||||
}
|
||||
|
||||
query, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.ListenPacket("udp4", ":0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(100 * time.Millisecond)
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 5353}
|
||||
if _, err = conn.WriteTo(query, addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 1500)
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return nil, nil // timeout = not a HomeKit device
|
||||
}
|
||||
|
||||
var resp dns.Msg
|
||||
if err = resp.Unpack(buf[:n]); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return parseHAPResponse(&resp)
|
||||
}
|
||||
|
||||
// internals
|
||||
|
||||
func parseHAPResponse(msg *dns.Msg) (*MDNSResult, error) {
|
||||
records := make([]dns.RR, 0, len(msg.Answer)+len(msg.Extra))
|
||||
records = append(records, msg.Answer...)
|
||||
records = append(records, msg.Extra...)
|
||||
|
||||
var ptrName string
|
||||
for _, rr := range records {
|
||||
if ptr, ok := rr.(*dns.PTR); ok && ptr.Hdr.Name == hapService {
|
||||
ptrName = ptr.Ptr
|
||||
break
|
||||
}
|
||||
}
|
||||
if ptrName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ex. "My Camera._hap._tcp.local." -> "My Camera"
|
||||
var name string
|
||||
if i := strings.Index(ptrName, "."+hapService); i > 0 {
|
||||
name = strings.ReplaceAll(ptrName[:i], `\ `, " ")
|
||||
}
|
||||
|
||||
info := map[string]string{}
|
||||
for _, rr := range records {
|
||||
txt, ok := rr.(*dns.TXT)
|
||||
if !ok || txt.Hdr.Name != ptrName {
|
||||
continue
|
||||
}
|
||||
for _, s := range txt.Txt {
|
||||
k, v, _ := strings.Cut(s, "=")
|
||||
info[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
category := info[txtCategory]
|
||||
if category != categoryCamera && category != categoryDoorbell {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
categoryName := "camera"
|
||||
if category == categoryDoorbell {
|
||||
categoryName = "doorbell"
|
||||
}
|
||||
|
||||
var port int
|
||||
for _, rr := range records {
|
||||
if srv, ok := rr.(*dns.SRV); ok && srv.Hdr.Name == ptrName {
|
||||
port = int(srv.Port)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &MDNSResult{
|
||||
Name: name,
|
||||
DeviceID: info[txtDeviceID],
|
||||
Model: info[txtModel],
|
||||
Category: categoryName,
|
||||
Paired: info[txtStatusFlags] == statusPaired,
|
||||
Port: port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
dns.Id = func() uint16 { return 0 }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package probe
|
||||
|
||||
type Response struct {
|
||||
IP string `json:"ip"`
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs float64 `json:"latency_ms,omitempty"`
|
||||
Type string `json:"type"` // "unreachable", "standard", "homekit"
|
||||
Error string `json:"error,omitempty"`
|
||||
Probes Probes `json:"probes"`
|
||||
}
|
||||
|
||||
type Probes struct {
|
||||
Ping *PingResult `json:"ping"`
|
||||
Ports *PortsResult `json:"ports"`
|
||||
DNS *DNSResult `json:"dns"`
|
||||
ARP *ARPResult `json:"arp"`
|
||||
MDNS *MDNSResult `json:"mdns"`
|
||||
HTTP *HTTPResult `json:"http"`
|
||||
}
|
||||
|
||||
type PingResult struct {
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
type PortsResult struct {
|
||||
Open []int `json:"open"`
|
||||
}
|
||||
|
||||
type DNSResult struct {
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
type ARPResult struct {
|
||||
MAC string `json:"mac"`
|
||||
Vendor string `json:"vendor"`
|
||||
}
|
||||
|
||||
type MDNSResult 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"`
|
||||
}
|
||||
|
||||
type HTTPResult struct {
|
||||
Port int `json:"port"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Server string `json:"server"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LookupOUI returns vendor name for MAC address from SQLite oui table.
|
||||
// MAC format: "C0:56:E3:AA:BB:CC" -> prefix "C0:56:E3"
|
||||
func LookupOUI(db *sql.DB, mac string) string {
|
||||
if len(mac) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := strings.ToUpper(mac[:8])
|
||||
prefix = strings.ReplaceAll(prefix, "-", ":")
|
||||
|
||||
var brand string
|
||||
_ = db.QueryRow("SELECT brand FROM oui WHERE prefix = ?", prefix).Scan(&brand)
|
||||
return brand
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CanICMP() bool {
|
||||
conn, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func Ping(ctx context.Context, ip string) (*PingResult, error) {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
return &PingResult{
|
||||
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ScanPorts(ctx context.Context, ip string, ports []int) (*PortsResult, error) {
|
||||
if len(ports) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(100 * time.Millisecond)
|
||||
}
|
||||
timeout := time.Until(deadline)
|
||||
if timeout <= 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
type hit struct {
|
||||
port int
|
||||
latency time.Duration
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var hits []hit
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, port := range ports {
|
||||
wg.Add(1)
|
||||
go func(port int) {
|
||||
defer wg.Done()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, port), timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
mu.Lock()
|
||||
hits = append(hits, hit{port: port, latency: time.Since(start)})
|
||||
mu.Unlock()
|
||||
}(port)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(hits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
open := make([]int, len(hits))
|
||||
for i, h := range hits {
|
||||
open[i] = h.port
|
||||
}
|
||||
|
||||
return &PortsResult{Open: open}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user