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,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/strix/internal/app"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
var Handler http.Handler
|
||||
|
||||
func Init() {
|
||||
listen := app.Env("STRIX_LISTEN", ":4567")
|
||||
|
||||
log = app.GetLogger("api")
|
||||
|
||||
HandleFunc("api", apiHandler)
|
||||
HandleFunc("api/health", apiHealth)
|
||||
HandleFunc("api/log", apiLog)
|
||||
|
||||
// serve frontend from embedded web/ directory
|
||||
if sub, err := fs.Sub(webFS, "web"); err == nil {
|
||||
http.Handle("/", http.FileServer(http.FS(sub)))
|
||||
}
|
||||
|
||||
Handler = middlewareCORS(http.DefaultServeMux)
|
||||
|
||||
if log.Trace().Enabled() {
|
||||
Handler = middlewareLog(Handler)
|
||||
}
|
||||
|
||||
go listen_serve("tcp", listen)
|
||||
}
|
||||
|
||||
//go:embed web
|
||||
var webFS embed.FS
|
||||
|
||||
func listen_serve(network, address string) {
|
||||
ln, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[api] listen")
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("addr", address).Msg("[api] listen")
|
||||
|
||||
server := http.Server{
|
||||
Handler: Handler,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute, // long for test sessions
|
||||
}
|
||||
if err = server.Serve(ln); err != nil {
|
||||
log.Fatal().Err(err).Msg("[api] serve")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleFunc registers handler on http.DefaultServeMux with "/" prefix
|
||||
func HandleFunc(pattern string, handler http.HandlerFunc) {
|
||||
if len(pattern) == 0 || pattern[0] != '/' {
|
||||
pattern = "/" + pattern
|
||||
}
|
||||
log.Trace().Str("path", pattern).Msg("[api] register")
|
||||
http.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
// ResponseJSON writes JSON response with Content-Type header
|
||||
func ResponseJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// Error logs error and writes HTTP error response
|
||||
func Error(w http.ResponseWriter, err error, code int) {
|
||||
log.Error().Err(err).Caller(1).Send()
|
||||
http.Error(w, err.Error(), code)
|
||||
}
|
||||
|
||||
func middlewareCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func middlewareLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msgf("[api] %s %s %s", r.Method, r.URL, r.RemoteAddr)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ResponseJSON(w, app.Info)
|
||||
}
|
||||
|
||||
func apiHealth(w http.ResponseWriter, r *http.Request) {
|
||||
ResponseJSON(w, map[string]any{
|
||||
"version": app.Version,
|
||||
"uptime": time.Since(app.StartTime).String(),
|
||||
})
|
||||
}
|
||||
|
||||
func apiLog(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "application/jsonlines")
|
||||
app.MemoryLog.WriteTo(w)
|
||||
case "DELETE":
|
||||
app.MemoryLog.Reset()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/eduard256/Strix/internal/camera/discovery"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
"github.com/eduard256/Strix/internal/utils/logger"
|
||||
"github.com/eduard256/Strix/pkg/sse"
|
||||
)
|
||||
|
||||
// DiscoverHandler handles stream discovery requests
|
||||
type DiscoverHandler struct {
|
||||
scanner *discovery.Scanner
|
||||
sseServer *sse.Server
|
||||
validator *validator.Validate
|
||||
secrets *logger.SecretStore
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
|
||||
}
|
||||
|
||||
// NewDiscoverHandler creates a new discover handler
|
||||
func NewDiscoverHandler(
|
||||
scanner *discovery.Scanner,
|
||||
sseServer *sse.Server,
|
||||
secrets *logger.SecretStore,
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) },
|
||||
) *DiscoverHandler {
|
||||
return &DiscoverHandler{
|
||||
scanner: scanner,
|
||||
sseServer: sseServer,
|
||||
secrets: secrets,
|
||||
validator: validator.New(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles discovery requests
|
||||
func (h *DiscoverHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.StreamDiscoveryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode discovery request", err)
|
||||
h.sendErrorResponse(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
if req.ModelLimit <= 0 {
|
||||
req.ModelLimit = 6
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = 240 // 4 minutes
|
||||
}
|
||||
if req.MaxStreams <= 0 {
|
||||
req.MaxStreams = 10
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
h.logger.Error("discovery request validation failed", err)
|
||||
h.sendErrorResponse(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Register password as a secret so it gets masked in all log output.
|
||||
// The secret is automatically unregistered when the request completes.
|
||||
if req.Password != "" {
|
||||
h.secrets.Add(req.Password)
|
||||
defer h.secrets.Remove(req.Password)
|
||||
}
|
||||
|
||||
h.logger.Info("stream discovery requested",
|
||||
"target", req.Target,
|
||||
"model", req.Model,
|
||||
"timeout", req.Timeout,
|
||||
"max_streams", req.MaxStreams,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Check if SSE is supported
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
h.logger.Info("SSE not supported by client", "remote_addr", r.RemoteAddr)
|
||||
h.sendErrorResponse(w, "SSE not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // Disable Nginx buffering
|
||||
|
||||
// Flush headers
|
||||
flusher.Flush()
|
||||
|
||||
// Create SSE stream writer
|
||||
streamWriter, err := h.sseServer.NewStreamWriter(w, r)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create SSE stream", err)
|
||||
return
|
||||
}
|
||||
defer streamWriter.Close()
|
||||
|
||||
// Perform discovery
|
||||
result, err := h.scanner.Scan(r.Context(), req, streamWriter)
|
||||
if err != nil {
|
||||
h.logger.Error("discovery failed", err)
|
||||
_ = streamWriter.SendError(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("discovery completed",
|
||||
"target", req.Target,
|
||||
"tested", result.TotalTested,
|
||||
"found", result.TotalFound,
|
||||
"duration", result.Duration,
|
||||
)
|
||||
}
|
||||
|
||||
// sendErrorResponse sends an error response for non-SSE requests
|
||||
func (h *DiscoverHandler) sendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": true,
|
||||
"message": message,
|
||||
"code": statusCode,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthResponse represents health check response
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime"` // seconds
|
||||
Timestamp string `json:"timestamp"`
|
||||
System SystemInfo `json:"system"`
|
||||
Services map[string]string `json:"services"`
|
||||
}
|
||||
|
||||
// SystemInfo contains system information
|
||||
type SystemInfo struct {
|
||||
GoVersion string `json:"go_version"`
|
||||
NumGoroutine int `json:"num_goroutines"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
MemoryMB uint64 `json:"memory_mb"`
|
||||
}
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
// HealthHandler handles health check endpoint
|
||||
type HealthHandler struct {
|
||||
version string
|
||||
logger interface{ Info(string, ...any) }
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health handler
|
||||
func NewHealthHandler(version string, logger interface{ Info(string, ...any) }) *HealthHandler {
|
||||
return &HealthHandler{
|
||||
version: version,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles health check requests
|
||||
func (h *HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("health check requested", "remote_addr", r.RemoteAddr)
|
||||
|
||||
// Get memory stats
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
response := HealthResponse{
|
||||
Status: "healthy",
|
||||
Version: h.version,
|
||||
Uptime: int64(time.Since(startTime).Seconds()),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
System: SystemInfo{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
MemoryMB: memStats.Alloc / 1024 / 1024,
|
||||
},
|
||||
Services: map[string]string{
|
||||
"api": "running",
|
||||
"database": "loaded",
|
||||
"scanner": "ready",
|
||||
"sse": "active",
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
h.logger.Info("failed to encode health response", "error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/eduard256/Strix/internal/camera/discovery"
|
||||
)
|
||||
|
||||
// ProbeHandler handles device probe requests.
|
||||
// GET /api/v1/probe?ip=192.168.1.50
|
||||
type ProbeHandler struct {
|
||||
probeService *discovery.ProbeService
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
}
|
||||
}
|
||||
|
||||
// NewProbeHandler creates a new probe handler.
|
||||
func NewProbeHandler(
|
||||
probeService *discovery.ProbeService,
|
||||
logger interface {
|
||||
Debug(string, ...any)
|
||||
Error(string, error, ...any)
|
||||
Info(string, ...any)
|
||||
},
|
||||
) *ProbeHandler {
|
||||
return &ProbeHandler{
|
||||
probeService: probeService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles probe requests.
|
||||
func (h *ProbeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ip := r.URL.Query().Get("ip")
|
||||
if ip == "" {
|
||||
h.sendError(w, "Missing required parameter: ip", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate IP format
|
||||
if net.ParseIP(ip) == nil {
|
||||
h.sendError(w, "Invalid IP address: "+ip, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("probe requested", "ip", ip, "remote_addr", r.RemoteAddr)
|
||||
|
||||
// Run probe
|
||||
result := h.probeService.Probe(r.Context(), ip)
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||
h.logger.Error("failed to encode probe response", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends a JSON error response.
|
||||
func (h *ProbeHandler) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": true,
|
||||
"message": message,
|
||||
"code": statusCode,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/eduard256/Strix/internal/camera/database"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// SearchHandler handles camera search requests
|
||||
type SearchHandler struct {
|
||||
searchEngine *database.SearchEngine
|
||||
validator *validator.Validate
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
|
||||
}
|
||||
|
||||
// NewSearchHandler creates a new search handler
|
||||
func NewSearchHandler(
|
||||
searchEngine *database.SearchEngine,
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) },
|
||||
) *SearchHandler {
|
||||
return &SearchHandler{
|
||||
searchEngine: searchEngine,
|
||||
validator: validator.New(),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles search requests
|
||||
func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req models.CameraSearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode search request", err)
|
||||
h.sendErrorResponse(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set default limit if not provided
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
h.logger.Error("search request validation failed", err)
|
||||
h.sendErrorResponse(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("camera search requested",
|
||||
"query", req.Query,
|
||||
"limit", req.Limit,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
// Perform search
|
||||
response, err := h.searchEngine.Search(req.Query, req.Limit)
|
||||
if err != nil {
|
||||
h.logger.Error("search failed", err)
|
||||
h.sendErrorResponse(w, "Search failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
h.logger.Error("failed to encode search response", err)
|
||||
}
|
||||
|
||||
h.logger.Info("search completed",
|
||||
"query", req.Query,
|
||||
"returned", response.Returned,
|
||||
"total", response.Total,
|
||||
)
|
||||
}
|
||||
|
||||
// sendErrorResponse sends an error response
|
||||
func (h *SearchHandler) sendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": true,
|
||||
"message": message,
|
||||
"code": statusCode,
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/eduard256/Strix/internal/api/handlers"
|
||||
"github.com/eduard256/Strix/internal/camera/database"
|
||||
"github.com/eduard256/Strix/internal/camera/discovery"
|
||||
"github.com/eduard256/Strix/internal/camera/stream"
|
||||
"github.com/eduard256/Strix/internal/config"
|
||||
logutil "github.com/eduard256/Strix/internal/utils/logger"
|
||||
"github.com/eduard256/Strix/pkg/sse"
|
||||
)
|
||||
|
||||
// Server represents the API server
|
||||
type Server struct {
|
||||
router chi.Router
|
||||
config *config.Config
|
||||
loader *database.Loader
|
||||
searchEngine *database.SearchEngine
|
||||
scanner *discovery.Scanner
|
||||
probeService *discovery.ProbeService
|
||||
sseServer *sse.Server
|
||||
secrets *logutil.SecretStore
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
|
||||
}
|
||||
|
||||
// NewServer creates a new API server
|
||||
func NewServer(
|
||||
cfg *config.Config,
|
||||
secrets *logutil.SecretStore,
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) },
|
||||
) (*Server, error) {
|
||||
// Initialize database loader
|
||||
loader := database.NewLoader(
|
||||
cfg.Database.BrandsPath,
|
||||
cfg.Database.PatternsPath,
|
||||
cfg.Database.ParametersPath,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Load query parameters for URL builder
|
||||
queryParams, err := loader.LoadQueryParameters()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize search engine
|
||||
searchEngine := database.NewSearchEngine(loader, logger)
|
||||
|
||||
// Initialize stream components
|
||||
builder := stream.NewBuilder(queryParams, logger)
|
||||
tester := stream.NewTester(cfg.Scanner.FFProbeTimeout, logger)
|
||||
|
||||
// Initialize ONVIF discovery
|
||||
onvif := discovery.NewONVIFDiscovery(logger)
|
||||
|
||||
// Initialize scanner
|
||||
scannerConfig := discovery.ScannerConfig{
|
||||
WorkerPoolSize: cfg.Scanner.WorkerPoolSize,
|
||||
DefaultTimeout: cfg.Scanner.DefaultTimeout,
|
||||
MaxStreams: cfg.Scanner.MaxStreams,
|
||||
ModelSearchLimit: cfg.Scanner.ModelSearchLimit,
|
||||
FFProbeTimeout: cfg.Scanner.FFProbeTimeout,
|
||||
}
|
||||
|
||||
scanner := discovery.NewScanner(
|
||||
loader,
|
||||
searchEngine,
|
||||
builder,
|
||||
tester,
|
||||
onvif,
|
||||
scannerConfig,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Initialize SSE server
|
||||
sseServer := sse.NewServer(logger)
|
||||
|
||||
// Initialize OUI database for vendor identification
|
||||
ouiDB := discovery.NewOUIDatabase()
|
||||
if err := ouiDB.LoadFromFile(cfg.Database.OUIPath); err != nil {
|
||||
logger.Error("failed to load OUI database, vendor lookup will be unavailable", err)
|
||||
} else {
|
||||
logger.Info("OUI database loaded", "entries", ouiDB.Size())
|
||||
}
|
||||
|
||||
// Initialize ProbeService with all probers
|
||||
probers := []discovery.Prober{
|
||||
&discovery.DNSProber{},
|
||||
discovery.NewARPProber(ouiDB),
|
||||
&discovery.MDNSProber{},
|
||||
&discovery.HTTPProber{},
|
||||
}
|
||||
probeService := discovery.NewProbeService(probers, logger)
|
||||
|
||||
// Create server
|
||||
server := &Server{
|
||||
router: chi.NewRouter(),
|
||||
config: cfg,
|
||||
loader: loader,
|
||||
searchEngine: searchEngine,
|
||||
scanner: scanner,
|
||||
probeService: probeService,
|
||||
sseServer: sseServer,
|
||||
secrets: secrets,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
server.setupRoutes()
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// setupRoutes configures all routes and middleware
|
||||
func (s *Server) setupRoutes() {
|
||||
// Global middleware
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(middleware.RealIP)
|
||||
s.router.Use(middleware.Logger)
|
||||
s.router.Use(middleware.Recoverer)
|
||||
// Note: No global timeout middleware - endpoints use context-based timeouts from request parameters
|
||||
|
||||
// CORS middleware
|
||||
s.router.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
// API routes (mounted at /api/v1 in main.go)
|
||||
// Health check (GET + HEAD for Docker/CasaOS healthcheck compatibility)
|
||||
healthHandler := handlers.NewHealthHandler(s.config.Version, s.logger).ServeHTTP
|
||||
s.router.Get("/health", healthHandler)
|
||||
s.router.Head("/health", healthHandler)
|
||||
|
||||
// Camera search
|
||||
s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP)
|
||||
|
||||
// Stream discovery (SSE)
|
||||
s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.secrets, s.logger).ServeHTTP)
|
||||
|
||||
// Device probe (ping + DNS + ARP/OUI + mDNS)
|
||||
s.router.Get("/probe", handlers.NewProbeHandler(s.probeService, s.logger).ServeHTTP)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetRouter returns the chi router
|
||||
func (s *Server) GetRouter() chi.Router {
|
||||
return s.router
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Strix</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: monospace; background: #1a1a1a; color: #e0e0e0; display: flex; align-items: center; justify-content: center; height: 100vh; }
|
||||
h1 { font-size: 24px; color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Strix 2.0</h1>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var Version string
|
||||
|
||||
var Logger zerolog.Logger
|
||||
|
||||
var Info = map[string]any{}
|
||||
|
||||
var StartTime = time.Now()
|
||||
|
||||
// DB is the shared SQLite database path
|
||||
var DB string
|
||||
|
||||
func Init() {
|
||||
initLogger()
|
||||
|
||||
Info["version"] = Version
|
||||
Info["platform"] = runtime.GOARCH
|
||||
|
||||
Logger.Info().Str("version", Version).Str("platform", runtime.GOARCH).Msg("[app] start")
|
||||
|
||||
DB = Env("STRIX_DB_PATH", "cameras.db")
|
||||
}
|
||||
|
||||
func Env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var MemoryLog = NewRingLog(16, 64*1024)
|
||||
|
||||
func GetLogger(module string) zerolog.Logger {
|
||||
return Logger.With().Str("module", module).Logger()
|
||||
}
|
||||
|
||||
func initLogger() {
|
||||
level := Env("STRIX_LOG_LEVEL", "info")
|
||||
lvl, err := zerolog.ParseLevel(level)
|
||||
if err != nil {
|
||||
lvl = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
writer := zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.DateTime,
|
||||
NoColor: !isTTY(),
|
||||
}
|
||||
|
||||
multi := io.MultiWriter(&writer, &SecretWriter{w: MemoryLog})
|
||||
|
||||
Logger = zerolog.New(multi).With().Timestamp().Logger().Level(lvl)
|
||||
}
|
||||
|
||||
func isTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// SecretWriter masks passwords in log output
|
||||
type SecretWriter struct {
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
var reURLPassword = regexp.MustCompile(`://([^:]+):([^@]+)@`)
|
||||
var reQueryPassword = regexp.MustCompile(`(?i)(pass(?:word)?|pwd)=([^&\s"]+)`)
|
||||
|
||||
func (s *SecretWriter) Write(p []byte) (int, error) {
|
||||
masked := reURLPassword.ReplaceAll(p, []byte("://$1:***@"))
|
||||
masked = reQueryPassword.ReplaceAll(masked, []byte("${1}=***"))
|
||||
return s.w.Write(masked)
|
||||
}
|
||||
|
||||
// RingLog is a circular buffer for storing log entries in memory
|
||||
type RingLog struct {
|
||||
chunks [][]byte
|
||||
pos int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewRingLog(count, size int) *RingLog {
|
||||
chunks := make([][]byte, count)
|
||||
for i := range chunks {
|
||||
chunks[i] = make([]byte, 0, size)
|
||||
}
|
||||
return &RingLog{chunks: chunks}
|
||||
}
|
||||
|
||||
func (r *RingLog) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
|
||||
chunk := r.chunks[r.pos]
|
||||
if len(chunk)+len(p) > cap(chunk) {
|
||||
r.pos = (r.pos + 1) % len(r.chunks)
|
||||
r.chunks[r.pos] = r.chunks[r.pos][:0]
|
||||
chunk = r.chunks[r.pos]
|
||||
}
|
||||
r.chunks[r.pos] = append(chunk, p...)
|
||||
|
||||
r.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (r *RingLog) WriteTo(w io.Writer) (int64, error) {
|
||||
r.mu.Lock()
|
||||
|
||||
var total int64
|
||||
start := (r.pos + 1) % len(r.chunks)
|
||||
for i := range r.chunks {
|
||||
idx := (start + i) % len(r.chunks)
|
||||
chunk := r.chunks[idx]
|
||||
if len(chunk) == 0 {
|
||||
continue
|
||||
}
|
||||
n, err := w.Write(chunk)
|
||||
total += int64(n)
|
||||
if err != nil {
|
||||
r.mu.Unlock()
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Unlock()
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *RingLog) Reset() {
|
||||
r.mu.Lock()
|
||||
for i := range r.chunks {
|
||||
r.chunks[i] = r.chunks[i][:0]
|
||||
}
|
||||
r.pos = 0
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// MaskURL masks password in a URL string for use in log messages
|
||||
func MaskURL(rawURL string) string {
|
||||
s := reURLPassword.ReplaceAllString(rawURL, "://$1:***@")
|
||||
s = reQueryPassword.ReplaceAllString(s, "${1}=***")
|
||||
return s
|
||||
}
|
||||
|
||||
// MaskPlaceholders masks password placeholders like [PASSWORD], [PASS], [PWD]
|
||||
func MaskPlaceholders(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"[PASSWORD]", "[***]", "[password]", "[***]",
|
||||
"[PASS]", "[***]", "[pass]", "[***]",
|
||||
"[PWD]", "[***]", "[pwd]", "[***]",
|
||||
"[PASWORD]", "[***]", "[pasword]", "[***]",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// Loader handles efficient loading of camera database
|
||||
type Loader struct {
|
||||
brandsPath string
|
||||
patternsPath string
|
||||
parametersPath string
|
||||
brandsCache map[string]*models.Camera
|
||||
patternsCache []models.StreamPattern
|
||||
paramsCache []string
|
||||
mu sync.RWMutex
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
||||
}
|
||||
|
||||
// NewLoader creates a new database loader
|
||||
func NewLoader(brandsPath, patternsPath, parametersPath string, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Loader {
|
||||
return &Loader{
|
||||
brandsPath: brandsPath,
|
||||
patternsPath: patternsPath,
|
||||
parametersPath: parametersPath,
|
||||
brandsCache: make(map[string]*models.Camera),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBrand loads a specific brand's camera data
|
||||
func (l *Loader) LoadBrand(brandID string) (*models.Camera, error) {
|
||||
l.mu.RLock()
|
||||
if cached, ok := l.brandsCache[brandID]; ok {
|
||||
l.mu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
l.mu.RUnlock()
|
||||
|
||||
// Load from file
|
||||
filePath := filepath.Join(l.brandsPath, brandID+".json")
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("brand %s not found", brandID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open brand file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var camera models.Camera
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(&camera); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode brand data: %w", err)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
l.mu.Lock()
|
||||
l.brandsCache[brandID] = &camera
|
||||
l.mu.Unlock()
|
||||
|
||||
return &camera, nil
|
||||
}
|
||||
|
||||
// ListBrands returns all available brand IDs
|
||||
func (l *Loader) ListBrands() ([]string, error) {
|
||||
files, err := os.ReadDir(l.brandsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read brands directory: %w", err)
|
||||
}
|
||||
|
||||
var brands []string
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
|
||||
// Skip index files
|
||||
if file.Name() == "index.json" || file.Name() == "indexa.json" {
|
||||
continue
|
||||
}
|
||||
brandID := strings.TrimSuffix(file.Name(), ".json")
|
||||
brands = append(brands, brandID)
|
||||
}
|
||||
}
|
||||
|
||||
return brands, nil
|
||||
}
|
||||
|
||||
// LoadPopularPatterns loads popular stream patterns
|
||||
func (l *Loader) LoadPopularPatterns() ([]models.StreamPattern, error) {
|
||||
l.mu.RLock()
|
||||
if l.patternsCache != nil {
|
||||
patterns := l.patternsCache
|
||||
l.mu.RUnlock()
|
||||
return patterns, nil
|
||||
}
|
||||
l.mu.RUnlock()
|
||||
|
||||
file, err := os.Open(l.patternsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open patterns file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var patterns []models.StreamPattern
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(&patterns); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode patterns: %w", err)
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.patternsCache = patterns
|
||||
l.mu.Unlock()
|
||||
|
||||
return patterns, nil
|
||||
}
|
||||
|
||||
// LoadQueryParameters loads supported query parameters
|
||||
func (l *Loader) LoadQueryParameters() ([]string, error) {
|
||||
l.mu.RLock()
|
||||
if l.paramsCache != nil {
|
||||
params := l.paramsCache
|
||||
l.mu.RUnlock()
|
||||
return params, nil
|
||||
}
|
||||
l.mu.RUnlock()
|
||||
|
||||
file, err := os.Open(l.parametersPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open parameters file: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var params []string
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(¶ms); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode parameters: %w", err)
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.paramsCache = params
|
||||
l.mu.Unlock()
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// StreamingSearch performs memory-efficient search across all brands
|
||||
func (l *Loader) StreamingSearch(searchFunc func(*models.Camera) bool) ([]*models.Camera, error) {
|
||||
files, err := os.ReadDir(l.brandsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read brands directory: %w", err)
|
||||
}
|
||||
|
||||
var results []*models.Camera
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip index.json as it contains brand list, not camera data
|
||||
if file.Name() == "index.json" || file.Name() == "indexa.json" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(l.brandsPath, file.Name())
|
||||
camera, err := l.loadCameraFromFile(filePath)
|
||||
if err != nil {
|
||||
l.logger.Error("failed to load camera file", err, "file", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if searchFunc(camera) {
|
||||
results = append(results, camera)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// loadCameraFromFile loads a camera from a file without caching
|
||||
func (l *Loader) loadCameraFromFile(filePath string) (*models.Camera, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
var camera models.Camera
|
||||
decoder := json.NewDecoder(file)
|
||||
if err := decoder.Decode(&camera); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &camera, nil
|
||||
}
|
||||
|
||||
// GetEntriesForModels returns all entries for specific models with similarity threshold
|
||||
func (l *Loader) GetEntriesForModels(modelNames []string, similarityThreshold float64) ([]models.CameraEntry, error) {
|
||||
entriesMap := make(map[string]models.CameraEntry)
|
||||
|
||||
for _, modelName := range modelNames {
|
||||
// Search for similar models across all brands
|
||||
cameras, err := l.StreamingSearch(func(camera *models.Camera) bool {
|
||||
for _, entry := range camera.Entries {
|
||||
for _, model := range entry.Models {
|
||||
similarity := calculateSimilarity(modelName, model)
|
||||
if similarity >= similarityThreshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect unique entries
|
||||
for _, camera := range cameras {
|
||||
for _, entry := range camera.Entries {
|
||||
for _, model := range entry.Models {
|
||||
similarity := calculateSimilarity(modelName, model)
|
||||
if similarity >= similarityThreshold {
|
||||
// Create unique key for deduplication
|
||||
key := fmt.Sprintf("%s://%d/%s", entry.Protocol, entry.Port, entry.URL)
|
||||
entriesMap[key] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
var entries []models.CameraEntry
|
||||
for _, entry := range entriesMap {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// calculateSimilarity calculates similarity between two strings (0.0 to 1.0)
|
||||
func calculateSimilarity(s1, s2 string) float64 {
|
||||
s1 = strings.ToLower(s1)
|
||||
s2 = strings.ToLower(s2)
|
||||
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Simple Levenshtein-based similarity
|
||||
maxLen := max(len(s1), len(s2))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
func min(values ...int) int {
|
||||
minVal := values[0]
|
||||
for _, v := range values[1:] {
|
||||
if v < minVal {
|
||||
minVal = v
|
||||
}
|
||||
}
|
||||
return minVal
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ClearCache clears the internal caches
|
||||
func (l *Loader) ClearCache() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.brandsCache = make(map[string]*models.Camera)
|
||||
l.patternsCache = nil
|
||||
l.paramsCache = nil
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// SearchEngine handles intelligent camera searching
|
||||
type SearchEngine struct {
|
||||
loader *Loader
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
||||
}
|
||||
|
||||
// NewSearchEngine creates a new search engine
|
||||
func NewSearchEngine(loader *Loader, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *SearchEngine {
|
||||
return &SearchEngine{
|
||||
loader: loader,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchResult represents a single search result with score
|
||||
type SearchResult struct {
|
||||
Camera *models.Camera
|
||||
Score float64
|
||||
}
|
||||
|
||||
// Search performs intelligent camera search
|
||||
func (s *SearchEngine) Search(query string, limit int) (*models.CameraSearchResponse, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// Normalize query
|
||||
normalizedQuery := s.normalizeQuery(query)
|
||||
tokens := s.tokenizeQuery(normalizedQuery)
|
||||
|
||||
s.logger.Debug("searching cameras", "query", query, "normalized", normalizedQuery, "tokens", tokens)
|
||||
|
||||
// Extract potential brand and model
|
||||
brandToken, modelTokens := s.extractBrandModel(tokens)
|
||||
|
||||
// Perform search
|
||||
results, err := s.performSearch(brandToken, modelTokens, normalizedQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
// Expand each camera into individual model entries with model-specific scores
|
||||
type ModelResult struct {
|
||||
Camera models.Camera
|
||||
Score float64
|
||||
}
|
||||
var modelResults []ModelResult
|
||||
|
||||
for _, result := range results {
|
||||
camera := result.Camera
|
||||
|
||||
// Collect unique models with their scores
|
||||
modelScores := make(map[string]float64)
|
||||
for _, entry := range camera.Entries {
|
||||
for _, model := range entry.Models {
|
||||
if model != "" && model != "Other" {
|
||||
// Calculate model-specific score
|
||||
modelScore := s.calculateModelScore(model, modelTokens, normalizedQuery)
|
||||
if modelScore > modelScores[model] {
|
||||
modelScores[model] = modelScore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a separate camera entry for each unique model
|
||||
for model, modelScore := range modelScores {
|
||||
// Combine brand score with model score
|
||||
finalScore := result.Score*0.3 + modelScore*0.7
|
||||
|
||||
expandedCamera := models.Camera{
|
||||
Brand: camera.Brand,
|
||||
BrandID: camera.BrandID,
|
||||
Model: model,
|
||||
LastUpdated: camera.LastUpdated,
|
||||
Source: camera.Source,
|
||||
Website: camera.Website,
|
||||
Entries: camera.Entries,
|
||||
MatchScore: finalScore,
|
||||
}
|
||||
modelResults = append(modelResults, ModelResult{
|
||||
Camera: expandedCamera,
|
||||
Score: finalScore,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by final score (best matches first)
|
||||
sort.Slice(modelResults, func(i, j int) bool {
|
||||
return modelResults[i].Score > modelResults[j].Score
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if len(modelResults) > limit {
|
||||
modelResults = modelResults[:limit]
|
||||
}
|
||||
|
||||
// Convert to camera slice
|
||||
cameras := make([]models.Camera, len(modelResults))
|
||||
for i, result := range modelResults {
|
||||
cameras[i] = result.Camera
|
||||
}
|
||||
|
||||
return &models.CameraSearchResponse{
|
||||
Cameras: cameras,
|
||||
Total: len(cameras),
|
||||
Returned: len(cameras),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeQuery normalizes the search query
|
||||
func (s *SearchEngine) normalizeQuery(query string) string {
|
||||
// Convert to lowercase
|
||||
normalized := strings.ToLower(query)
|
||||
|
||||
// Remove multiple spaces
|
||||
normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, " ")
|
||||
|
||||
// Remove special characters but keep spaces
|
||||
normalized = regexp.MustCompile(`[^a-z0-9\s\-]`).ReplaceAllString(normalized, " ")
|
||||
|
||||
// Trim spaces
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// tokenizeQuery splits query into tokens
|
||||
func (s *SearchEngine) tokenizeQuery(query string) []string {
|
||||
// Split by spaces and filter empty tokens
|
||||
tokens := strings.Fields(query)
|
||||
|
||||
var result []string
|
||||
for _, token := range tokens {
|
||||
if token != "" {
|
||||
result = append(result, token)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractBrandModel attempts to extract brand and model from tokens
|
||||
func (s *SearchEngine) extractBrandModel(tokens []string) (string, []string) {
|
||||
if len(tokens) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// First token is likely the brand
|
||||
brandToken := tokens[0]
|
||||
|
||||
// Rest are model tokens
|
||||
var modelTokens []string
|
||||
if len(tokens) > 1 {
|
||||
modelTokens = tokens[1:]
|
||||
}
|
||||
|
||||
return brandToken, modelTokens
|
||||
}
|
||||
|
||||
// performSearch executes the actual search
|
||||
func (s *SearchEngine) performSearch(brandToken string, modelTokens []string, fullQuery string) ([]SearchResult, error) {
|
||||
var results []SearchResult
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Get all brands
|
||||
brands, err := s.loader.ListBrands()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Search in parallel with limited concurrency
|
||||
sem := make(chan struct{}, 10) // Limit to 10 concurrent searches
|
||||
|
||||
for _, brandID := range brands {
|
||||
wg.Add(1)
|
||||
go func(brandID string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Calculate brand match score
|
||||
brandScore := s.calculateBrandScore(brandID, brandToken)
|
||||
|
||||
// Skip if brand score is too low
|
||||
if brandScore < 0.3 {
|
||||
return
|
||||
}
|
||||
|
||||
// Load brand data
|
||||
camera, err := s.loader.LoadBrand(brandID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load brand", err, "brand", brandID)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate model scores for entries
|
||||
maxModelScore := 0.0
|
||||
for _, entry := range camera.Entries {
|
||||
for _, model := range entry.Models {
|
||||
modelScore := s.calculateModelScore(model, modelTokens, fullQuery)
|
||||
if modelScore > maxModelScore {
|
||||
maxModelScore = modelScore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final score
|
||||
finalScore := s.calculateFinalScore(brandScore, maxModelScore)
|
||||
|
||||
// Add to results if score is high enough
|
||||
if finalScore >= 0.3 {
|
||||
mu.Lock()
|
||||
results = append(results, SearchResult{
|
||||
Camera: camera,
|
||||
Score: finalScore,
|
||||
})
|
||||
mu.Unlock()
|
||||
}
|
||||
}(brandID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// calculateBrandScore calculates how well a brand matches
|
||||
func (s *SearchEngine) calculateBrandScore(brandID, brandToken string) float64 {
|
||||
brandID = strings.ToLower(brandID)
|
||||
brandToken = strings.ToLower(brandToken)
|
||||
|
||||
// Exact match
|
||||
if brandID == brandToken {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Remove hyphens for comparison
|
||||
brandIDClean := strings.ReplaceAll(brandID, "-", "")
|
||||
brandTokenClean := strings.ReplaceAll(brandToken, "-", "")
|
||||
|
||||
if brandIDClean == brandTokenClean {
|
||||
return 0.95
|
||||
}
|
||||
|
||||
// Check if brand starts with token
|
||||
if strings.HasPrefix(brandID, brandToken) || strings.HasPrefix(brandIDClean, brandTokenClean) {
|
||||
return 0.85
|
||||
}
|
||||
|
||||
// Check if token is contained in brand
|
||||
if strings.Contains(brandID, brandToken) || strings.Contains(brandIDClean, brandTokenClean) {
|
||||
return 0.75
|
||||
}
|
||||
|
||||
// Fuzzy match
|
||||
if fuzzy.Match(brandToken, brandID) {
|
||||
return 0.6
|
||||
}
|
||||
|
||||
// Calculate similarity
|
||||
similarity := calculateSimilarity(brandID, brandToken)
|
||||
return similarity * 0.5
|
||||
}
|
||||
|
||||
// calculateModelScore calculates how well a model matches
|
||||
func (s *SearchEngine) calculateModelScore(model string, modelTokens []string, fullQuery string) float64 {
|
||||
model = strings.ToLower(model)
|
||||
fullQuery = strings.ToLower(fullQuery)
|
||||
|
||||
// Check if full query matches the model
|
||||
if model == fullQuery {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Check if model contains all tokens
|
||||
modelNormalized := s.normalizeQuery(model)
|
||||
allTokensFound := true
|
||||
tokenMatchScore := 0.0
|
||||
|
||||
for _, token := range modelTokens {
|
||||
if strings.Contains(modelNormalized, token) {
|
||||
tokenMatchScore += 0.2
|
||||
} else {
|
||||
allTokensFound = false
|
||||
}
|
||||
}
|
||||
|
||||
if allTokensFound && len(modelTokens) > 0 {
|
||||
return 0.8 + tokenMatchScore/float64(len(modelTokens))*0.2
|
||||
}
|
||||
|
||||
// Fuzzy match on full model
|
||||
modelCombined := strings.Join(modelTokens, "")
|
||||
if fuzzy.Match(modelCombined, modelNormalized) {
|
||||
return 0.6
|
||||
}
|
||||
|
||||
// Calculate similarity
|
||||
similarity := calculateSimilarity(modelNormalized, strings.Join(modelTokens, " "))
|
||||
return similarity * 0.5
|
||||
}
|
||||
|
||||
// calculateFinalScore combines brand and model scores
|
||||
func (s *SearchEngine) calculateFinalScore(brandScore, modelScore float64) float64 {
|
||||
// If we have both brand and model matches
|
||||
if brandScore > 0 && modelScore > 0 {
|
||||
// Weighted average: brand 30%, model 70%
|
||||
return brandScore*0.3 + modelScore*0.7
|
||||
}
|
||||
|
||||
// If only brand matches
|
||||
if brandScore > 0 {
|
||||
return brandScore * 0.5
|
||||
}
|
||||
|
||||
// If only model matches
|
||||
return modelScore * 0.5
|
||||
}
|
||||
|
||||
// SearchByModel searches for cameras by model name with fuzzy matching
|
||||
func (s *SearchEngine) SearchByModel(modelName string, similarityThreshold float64, limit int) ([]models.Camera, error) {
|
||||
if similarityThreshold <= 0 {
|
||||
similarityThreshold = 0.8
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 6
|
||||
}
|
||||
|
||||
normalizedModel := s.normalizeQuery(modelName)
|
||||
var results []SearchResult
|
||||
|
||||
// Search through all brands
|
||||
cameras, err := s.loader.StreamingSearch(func(camera *models.Camera) bool {
|
||||
maxScore := 0.0
|
||||
for _, entry := range camera.Entries {
|
||||
for _, model := range entry.Models {
|
||||
normalizedEntryModel := s.normalizeQuery(model)
|
||||
similarity := calculateSimilarity(normalizedModel, normalizedEntryModel)
|
||||
|
||||
// Also check fuzzy match
|
||||
if fuzzy.Match(normalizedModel, normalizedEntryModel) {
|
||||
if similarity < 0.7 {
|
||||
similarity = 0.7
|
||||
}
|
||||
}
|
||||
|
||||
if similarity > maxScore {
|
||||
maxScore = similarity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if maxScore >= similarityThreshold {
|
||||
camera.MatchScore = maxScore
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert to SearchResult for sorting
|
||||
for _, camera := range cameras {
|
||||
results = append(results, SearchResult{
|
||||
Camera: camera,
|
||||
Score: camera.MatchScore,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
// Convert back to Camera slice
|
||||
var finalCameras []models.Camera
|
||||
for _, result := range results {
|
||||
finalCameras = append(finalCameras, *result.Camera)
|
||||
}
|
||||
|
||||
return finalCameras, nil
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/IOTechSystems/onvif"
|
||||
"github.com/IOTechSystems/onvif/media"
|
||||
xsdonvif "github.com/IOTechSystems/onvif/xsd/onvif"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// ONVIFDiscovery handles ONVIF device discovery and stream detection
|
||||
type ONVIFDiscovery struct {
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
||||
}
|
||||
|
||||
// NewONVIFDiscovery creates a new ONVIF discovery instance
|
||||
func NewONVIFDiscovery(logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *ONVIFDiscovery {
|
||||
return &ONVIFDiscovery{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverStreamsForIP discovers all possible streams for a given IP
|
||||
func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username, password string) ([]models.DiscoveredStream, error) {
|
||||
o.logger.Debug("=== ONVIF DiscoverStreamsForIP STARTED ===",
|
||||
"ip", ip,
|
||||
"username", username,
|
||||
"password_len", len(password))
|
||||
|
||||
// Clean IP (remove port if present)
|
||||
if idx := strings.IndexByte(ip, ':'); idx > 0 {
|
||||
o.logger.Debug("cleaning IP address", "original", ip, "cleaned", ip[:idx])
|
||||
ip = ip[:idx]
|
||||
}
|
||||
|
||||
var allStreams []models.DiscoveredStream
|
||||
|
||||
// Try real ONVIF discovery first
|
||||
o.logger.Debug(">>> Starting ONVIF device discovery", "ip", ip)
|
||||
onvifStreams := o.discoverViaONVIF(ctx, ip, username, password)
|
||||
o.logger.Debug("<<< ONVIF device discovery completed", "streams_found", len(onvifStreams))
|
||||
|
||||
if len(onvifStreams) > 0 {
|
||||
o.logger.Debug("ONVIF streams details:")
|
||||
for i, stream := range onvifStreams {
|
||||
o.logger.Debug(" ONVIF stream found",
|
||||
"index", i,
|
||||
"url", stream.URL,
|
||||
"protocol", stream.Protocol,
|
||||
"port", stream.Port,
|
||||
"type", stream.Type)
|
||||
}
|
||||
}
|
||||
allStreams = append(allStreams, onvifStreams...)
|
||||
|
||||
// Add common RTSP streams
|
||||
o.logger.Debug(">>> Adding common RTSP streams", "ip", ip)
|
||||
commonStreams := o.getCommonRTSPStreams(ip, username, password)
|
||||
o.logger.Debug("<<< Common RTSP streams added", "count", len(commonStreams))
|
||||
allStreams = append(allStreams, commonStreams...)
|
||||
|
||||
o.logger.Debug("=== ONVIF DiscoverStreamsForIP COMPLETED ===",
|
||||
"onvif_streams", len(onvifStreams),
|
||||
"common_streams", len(commonStreams),
|
||||
"total_streams", len(allStreams))
|
||||
|
||||
return allStreams, nil
|
||||
}
|
||||
|
||||
// discoverViaONVIF performs real ONVIF discovery
|
||||
func (o *ONVIFDiscovery) discoverViaONVIF(ctx context.Context, ip, username, password string) []models.DiscoveredStream {
|
||||
o.logger.Debug(">>> discoverViaONVIF STARTED", "ip", ip)
|
||||
var streams []models.DiscoveredStream
|
||||
|
||||
// Try standard ONVIF ports
|
||||
ports := []int{80, 8080, 8000}
|
||||
o.logger.Debug("Will try ONVIF ports", "ports", ports)
|
||||
|
||||
for portIdx, port := range ports {
|
||||
o.logger.Debug("--- Trying ONVIF port ---",
|
||||
"port_index", portIdx+1,
|
||||
"total_ports", len(ports),
|
||||
"port", port)
|
||||
|
||||
// Create timeout context for ONVIF connection
|
||||
onvifCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
xaddr := fmt.Sprintf("%s:%d", ip, port)
|
||||
o.logger.Debug("Creating ONVIF device",
|
||||
"xaddr", xaddr,
|
||||
"username", username,
|
||||
"has_password", password != "")
|
||||
|
||||
// Create ONVIF device
|
||||
startTime := time.Now()
|
||||
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
||||
Xaddr: xaddr,
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
o.logger.Debug("❌ ONVIF device creation FAILED",
|
||||
"xaddr", xaddr,
|
||||
"error", err.Error(),
|
||||
"elapsed", elapsed.String())
|
||||
continue
|
||||
}
|
||||
|
||||
o.logger.Debug("✅ ONVIF device created successfully",
|
||||
"xaddr", xaddr,
|
||||
"elapsed", elapsed.String())
|
||||
|
||||
// Try to get profiles with context
|
||||
o.logger.Debug("Getting media profiles...", "xaddr", xaddr)
|
||||
profileStreams := o.getProfileStreams(onvifCtx, dev, ip)
|
||||
|
||||
if len(profileStreams) > 0 {
|
||||
// Add ONVIF device service endpoint
|
||||
deviceServiceURL := fmt.Sprintf("http://%s/onvif/device_service", xaddr)
|
||||
|
||||
// Embed credentials in URL if provided
|
||||
if username != "" && password != "" {
|
||||
u, err := url.Parse(deviceServiceURL)
|
||||
if err == nil {
|
||||
u.User = url.UserPassword(username, password)
|
||||
deviceServiceURL = u.String()
|
||||
}
|
||||
}
|
||||
|
||||
streams = append(streams, models.DiscoveredStream{
|
||||
URL: deviceServiceURL,
|
||||
Type: "ONVIF",
|
||||
Protocol: "http",
|
||||
Port: port,
|
||||
Working: true, // Mark as working since ONVIF connection succeeded
|
||||
Metadata: map[string]interface{}{
|
||||
"source": "onvif",
|
||||
"description": "ONVIF Device Service - used for PTZ control and device management",
|
||||
},
|
||||
})
|
||||
|
||||
// Add profile streams
|
||||
streams = append(streams, profileStreams...)
|
||||
|
||||
o.logger.Debug("🎉 ONVIF discovery SUCCESSFUL!",
|
||||
"xaddr", xaddr,
|
||||
"device_service", deviceServiceURL,
|
||||
"profiles_found", len(profileStreams))
|
||||
|
||||
// Log device service
|
||||
o.logger.Debug(" Device Service",
|
||||
"url", deviceServiceURL)
|
||||
|
||||
// Log each profile
|
||||
for i, stream := range profileStreams {
|
||||
o.logger.Debug(" Profile stream",
|
||||
"index", i+1,
|
||||
"url", stream.URL,
|
||||
"metadata", stream.Metadata)
|
||||
}
|
||||
break // Found working port, stop trying
|
||||
} else {
|
||||
o.logger.Debug("⚠️ No profiles returned from port", "xaddr", xaddr)
|
||||
}
|
||||
}
|
||||
|
||||
o.logger.Debug("<<< discoverViaONVIF COMPLETED",
|
||||
"total_streams_found", len(streams))
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
// getProfileStreams gets stream URIs from media profiles
|
||||
func (o *ONVIFDiscovery) getProfileStreams(ctx context.Context, dev *onvif.Device, ip string) []models.DiscoveredStream {
|
||||
o.logger.Debug(">>> getProfileStreams STARTED", "ip", ip)
|
||||
var streams []models.DiscoveredStream
|
||||
|
||||
// Get media profiles
|
||||
o.logger.Debug("Calling GetProfiles ONVIF method...")
|
||||
getProfilesReq := media.GetProfiles{}
|
||||
startTime := time.Now()
|
||||
profilesResp, err := dev.CallMethod(getProfilesReq)
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
o.logger.Debug("❌ Failed to call GetProfiles",
|
||||
"error", err.Error(),
|
||||
"elapsed", elapsed.String())
|
||||
return streams
|
||||
}
|
||||
defer func() { _ = profilesResp.Body.Close() }()
|
||||
|
||||
o.logger.Debug("✅ GetProfiles call successful",
|
||||
"elapsed", elapsed.String(),
|
||||
"status_code", profilesResp.StatusCode)
|
||||
|
||||
// Read and parse XML response
|
||||
o.logger.Debug("Reading response body...")
|
||||
body, err := io.ReadAll(profilesResp.Body)
|
||||
if err != nil {
|
||||
o.logger.Debug("❌ Failed to read profiles response",
|
||||
"error", err.Error())
|
||||
return streams
|
||||
}
|
||||
|
||||
o.logger.Debug("Response body read",
|
||||
"body_length", len(body),
|
||||
"body_preview", string(body[:min(200, len(body))]))
|
||||
|
||||
// Parse SOAP envelope
|
||||
o.logger.Debug("Parsing SOAP envelope...")
|
||||
var envelope struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
Body struct {
|
||||
GetProfilesResponse media.GetProfilesResponse `xml:"GetProfilesResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &envelope); err != nil {
|
||||
o.logger.Debug("❌ Failed to parse profiles response",
|
||||
"error", err.Error())
|
||||
return streams
|
||||
}
|
||||
|
||||
profileCount := len(envelope.Body.GetProfilesResponse.Profiles)
|
||||
o.logger.Debug("✅ SOAP envelope parsed successfully",
|
||||
"profiles_count", profileCount)
|
||||
|
||||
// Get stream URI for each profile
|
||||
for i, profile := range envelope.Body.GetProfilesResponse.Profiles {
|
||||
o.logger.Debug("Processing profile",
|
||||
"index", i+1,
|
||||
"total", profileCount,
|
||||
"token", string(profile.Token),
|
||||
"name", string(profile.Name))
|
||||
|
||||
streamURI := o.getStreamURI(dev, string(profile.Token))
|
||||
if streamURI != "" {
|
||||
o.logger.Debug("✅ Got stream URI for profile",
|
||||
"profile_token", string(profile.Token),
|
||||
"stream_uri", streamURI)
|
||||
|
||||
streams = append(streams, models.DiscoveredStream{
|
||||
URL: streamURI,
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
Working: false, // Will be tested later
|
||||
Metadata: map[string]interface{}{
|
||||
"source": "onvif",
|
||||
"profile_token": string(profile.Token),
|
||||
"profile_name": string(profile.Name),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
o.logger.Debug("⚠️ Failed to get stream URI for profile",
|
||||
"profile_token", string(profile.Token))
|
||||
}
|
||||
}
|
||||
|
||||
o.logger.Debug("<<< getProfileStreams COMPLETED",
|
||||
"streams_collected", len(streams))
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// getStreamURI retrieves stream URI for a profile
|
||||
func (o *ONVIFDiscovery) getStreamURI(dev *onvif.Device, profileToken string) string {
|
||||
o.logger.Debug(">>> getStreamURI STARTED", "profile_token", profileToken)
|
||||
|
||||
stream := xsdonvif.StreamType("RTP-Unicast")
|
||||
protocol := xsdonvif.TransportProtocol("RTSP")
|
||||
token := xsdonvif.ReferenceToken(profileToken)
|
||||
|
||||
getStreamURIReq := media.GetStreamUri{
|
||||
ProfileToken: &token,
|
||||
StreamSetup: &xsdonvif.StreamSetup{
|
||||
Stream: &stream,
|
||||
Transport: &xsdonvif.Transport{
|
||||
Protocol: &protocol,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
o.logger.Debug("Calling GetStreamUri ONVIF method...", "profile_token", profileToken)
|
||||
startTime := time.Now()
|
||||
resp, err := dev.CallMethod(getStreamURIReq)
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
o.logger.Debug("❌ Failed to get stream URI",
|
||||
"profile", profileToken,
|
||||
"error", err.Error(),
|
||||
"elapsed", elapsed.String())
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
o.logger.Debug("✅ GetStreamUri call successful",
|
||||
"profile", profileToken,
|
||||
"elapsed", elapsed.String(),
|
||||
"status_code", resp.StatusCode)
|
||||
|
||||
// Read and parse XML response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
o.logger.Debug("❌ Failed to read stream URI response",
|
||||
"error", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
o.logger.Debug("Response body read",
|
||||
"body_length", len(body),
|
||||
"body_preview", string(body[:min(200, len(body))]))
|
||||
|
||||
// Parse SOAP envelope
|
||||
var envelope struct {
|
||||
XMLName xml.Name `xml:"Envelope"`
|
||||
Body struct {
|
||||
GetStreamUriResponse media.GetStreamUriResponse `xml:"GetStreamUriResponse"`
|
||||
} `xml:"Body"`
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal(body, &envelope); err != nil {
|
||||
o.logger.Debug("❌ Failed to parse stream URI response",
|
||||
"error", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
streamURI := string(envelope.Body.GetStreamUriResponse.MediaUri.Uri)
|
||||
o.logger.Debug("<<< getStreamURI COMPLETED",
|
||||
"stream_uri", streamURI)
|
||||
|
||||
return streamURI
|
||||
}
|
||||
|
||||
// getCommonRTSPStreams returns common RTSP stream URLs
|
||||
func (o *ONVIFDiscovery) getCommonRTSPStreams(ip, username, password string) []models.DiscoveredStream {
|
||||
// Common RTSP paths that work with many cameras
|
||||
commonPaths := []struct {
|
||||
path string
|
||||
notes string
|
||||
}{
|
||||
{"/stream1", "Common main stream"},
|
||||
{"/stream2", "Common sub stream"},
|
||||
{"/ch0", "Thingino main"},
|
||||
{"/ch1", "Thingino sub"},
|
||||
{"/live/main", "ONVIF standard main"},
|
||||
{"/live/sub", "ONVIF standard sub"},
|
||||
{"/Streaming/Channels/101", "Hikvision main"},
|
||||
{"/Streaming/Channels/102", "Hikvision sub"},
|
||||
{"/cam/realmonitor?channel=1&subtype=0", "Dahua main"},
|
||||
{"/cam/realmonitor?channel=1&subtype=1", "Dahua sub"},
|
||||
{"/h264/main", "Generic H264 main"},
|
||||
{"/h264/sub", "Generic H264 sub"},
|
||||
{"/media/video1", "Axis main"},
|
||||
{"/media/video2", "Axis sub"},
|
||||
{"/videoMain", "Foscam main"},
|
||||
{"/videoSub", "Foscam sub"},
|
||||
{"/11", "Simple numeric main"},
|
||||
{"/12", "Simple numeric sub"},
|
||||
{"/user=admin_password=tlJwpbo6_channel=1_stream=0.sdp", "Dahua alternative"},
|
||||
{"/live.sdp", "Generic live"},
|
||||
{"/stream", "Generic stream"},
|
||||
{"/video.h264", "Generic H264"},
|
||||
{"/live/0/MAIN", "Alternative main"},
|
||||
{"/live/0/SUB", "Alternative sub"},
|
||||
{"/MediaInput/h264", "Alternative H264"},
|
||||
{"/0/video0", "Alternative video0"},
|
||||
{"/0/video1", "Alternative video1"},
|
||||
}
|
||||
|
||||
var streams []models.DiscoveredStream
|
||||
|
||||
for _, cp := range commonPaths {
|
||||
var streamURL string
|
||||
if username != "" && password != "" {
|
||||
streamURL = fmt.Sprintf("rtsp://%s:%s@%s:554%s", url.QueryEscape(username), url.QueryEscape(password), ip, cp.path)
|
||||
} else {
|
||||
streamURL = fmt.Sprintf("rtsp://%s:554%s", ip, cp.path)
|
||||
}
|
||||
|
||||
streams = append(streams, models.DiscoveredStream{
|
||||
URL: streamURL,
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
Working: false, // Will be tested later
|
||||
Metadata: map[string]interface{}{
|
||||
"source": "common",
|
||||
"notes": cp.notes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add some HTTP snapshot URLs too
|
||||
httpPaths := []struct {
|
||||
path string
|
||||
notes string
|
||||
}{
|
||||
{"/snapshot.jpg", "Common snapshot"},
|
||||
{"/snap.jpg", "Alternative snapshot"},
|
||||
{"/image/jpeg.cgi", "CGI snapshot"},
|
||||
{"/cgi-bin/snapshot.cgi", "CGI bin snapshot"},
|
||||
{"/jpg/image.jpg", "JPEG image"},
|
||||
{"/tmpfs/auto.jpg", "Tmpfs snapshot"},
|
||||
{"/axis-cgi/jpg/image.cgi", "Axis snapshot"},
|
||||
{"/cgi-bin/viewer/video.jpg", "Viewer snapshot"},
|
||||
{"/Streaming/channels/1/picture", "Hikvision snapshot"},
|
||||
{"/onvif/snapshot", "ONVIF snapshot"},
|
||||
}
|
||||
|
||||
for _, hp := range httpPaths {
|
||||
var streamURL string
|
||||
if username != "" && password != "" {
|
||||
// For HTTP, we'll rely on Basic Auth instead of URL embedding
|
||||
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
||||
} else {
|
||||
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
||||
}
|
||||
|
||||
streams = append(streams, models.DiscoveredStream{
|
||||
URL: streamURL,
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
Working: false, // Will be tested later
|
||||
Metadata: map[string]interface{}{
|
||||
"source": "common",
|
||||
"notes": hp.notes,
|
||||
"username": username,
|
||||
"password": password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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" }
|
||||
|
||||
// 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) {
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/Strix/internal/camera/database"
|
||||
"github.com/eduard256/Strix/internal/camera/stream"
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
"github.com/eduard256/Strix/pkg/sse"
|
||||
)
|
||||
|
||||
// Scanner orchestrates stream discovery
|
||||
type Scanner struct {
|
||||
loader *database.Loader
|
||||
searchEngine *database.SearchEngine
|
||||
builder *stream.Builder
|
||||
tester *stream.Tester
|
||||
onvif *ONVIFDiscovery
|
||||
config ScannerConfig
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }
|
||||
}
|
||||
|
||||
// ScannerConfig contains scanner configuration
|
||||
type ScannerConfig struct {
|
||||
WorkerPoolSize int
|
||||
DefaultTimeout time.Duration
|
||||
MaxStreams int
|
||||
ModelSearchLimit int
|
||||
FFProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewScanner creates a new stream scanner
|
||||
func NewScanner(
|
||||
loader *database.Loader,
|
||||
searchEngine *database.SearchEngine,
|
||||
builder *stream.Builder,
|
||||
tester *stream.Tester,
|
||||
onvif *ONVIFDiscovery,
|
||||
config ScannerConfig,
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) },
|
||||
) *Scanner {
|
||||
return &Scanner{
|
||||
loader: loader,
|
||||
searchEngine: searchEngine,
|
||||
builder: builder,
|
||||
tester: tester,
|
||||
onvif: onvif,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ScanResult contains the scan results
|
||||
type ScanResult struct {
|
||||
Streams []models.DiscoveredStream
|
||||
TotalTested int
|
||||
TotalFound int
|
||||
Duration time.Duration
|
||||
Error error
|
||||
}
|
||||
|
||||
// Scan performs stream discovery
|
||||
func (s *Scanner) Scan(ctx context.Context, req models.StreamDiscoveryRequest, streamWriter *sse.StreamWriter) (*ScanResult, error) {
|
||||
startTime := time.Now()
|
||||
result := &ScanResult{}
|
||||
|
||||
// Set defaults
|
||||
if req.Timeout <= 0 {
|
||||
req.Timeout = int(s.config.DefaultTimeout.Seconds())
|
||||
}
|
||||
if req.MaxStreams <= 0 {
|
||||
req.MaxStreams = s.config.MaxStreams
|
||||
}
|
||||
if req.ModelLimit <= 0 {
|
||||
req.ModelLimit = s.config.ModelSearchLimit
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
scanCtx, cancel := context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.logger.Info("starting stream discovery",
|
||||
"target", req.Target,
|
||||
"model", req.Model,
|
||||
"timeout", req.Timeout,
|
||||
"max_streams", req.MaxStreams,
|
||||
)
|
||||
|
||||
// Send initial message
|
||||
_ = streamWriter.SendJSON("scan_started", map[string]interface{}{
|
||||
"target": req.Target,
|
||||
"model": req.Model,
|
||||
"max_streams": req.MaxStreams,
|
||||
"timeout": req.Timeout,
|
||||
})
|
||||
|
||||
// Check if target is a direct stream URL
|
||||
if s.isDirectStreamURL(req.Target) {
|
||||
return s.scanDirectStream(scanCtx, req, streamWriter, result)
|
||||
}
|
||||
|
||||
// Extract IP from target
|
||||
ip := s.extractIP(req.Target)
|
||||
if ip == "" {
|
||||
err := fmt.Errorf("invalid target IP: %s", req.Target)
|
||||
_ = streamWriter.SendError(err)
|
||||
result.Error = err
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Collect all streams to test (includes metadata like type)
|
||||
streams, err := s.collectStreams(scanCtx, req, ip)
|
||||
if err != nil {
|
||||
_ = streamWriter.SendError(err)
|
||||
result.Error = err
|
||||
return result, err
|
||||
}
|
||||
|
||||
s.logger.Info("collected streams for testing", "count", len(streams))
|
||||
|
||||
// Send progress update
|
||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||
Tested: 0,
|
||||
Found: 0,
|
||||
Remaining: len(streams),
|
||||
})
|
||||
|
||||
// Test streams concurrently
|
||||
s.testStreamsConcurrently(scanCtx, streams, req, streamWriter, result)
|
||||
|
||||
// Calculate duration
|
||||
result.Duration = time.Since(startTime)
|
||||
|
||||
// Send completion message
|
||||
_ = streamWriter.SendJSON("complete", models.CompleteMessage{
|
||||
TotalTested: result.TotalTested,
|
||||
TotalFound: result.TotalFound,
|
||||
Duration: result.Duration.Seconds(),
|
||||
})
|
||||
|
||||
// Send final done event to signal proper stream closure
|
||||
_ = streamWriter.SendJSON("done", map[string]interface{}{
|
||||
"message": "Stream discovery finished",
|
||||
})
|
||||
|
||||
// Small delay to ensure all data is flushed to client
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
s.logger.Info("stream discovery completed",
|
||||
"tested", result.TotalTested,
|
||||
"found", result.TotalFound,
|
||||
"duration", result.Duration,
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isDirectStreamURL checks if target is a direct stream URL
|
||||
func (s *Scanner) isDirectStreamURL(target string) bool {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "rtsp" || u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
|
||||
// scanDirectStream scans a direct stream URL
|
||||
func (s *Scanner) scanDirectStream(ctx context.Context, req models.StreamDiscoveryRequest, streamWriter *sse.StreamWriter, result *ScanResult) (*ScanResult, error) {
|
||||
s.logger.Debug("testing direct stream URL", "url", req.Target)
|
||||
|
||||
testResult := s.tester.TestStream(ctx, req.Target)
|
||||
result.TotalTested = 1
|
||||
|
||||
if testResult.Working {
|
||||
result.TotalFound = 1
|
||||
|
||||
discoveredStream := models.DiscoveredStream{
|
||||
URL: testResult.URL,
|
||||
Type: testResult.Type,
|
||||
Protocol: testResult.Protocol,
|
||||
Working: true,
|
||||
Resolution: testResult.Resolution,
|
||||
Codec: testResult.Codec,
|
||||
FPS: testResult.FPS,
|
||||
Bitrate: testResult.Bitrate,
|
||||
HasAudio: testResult.HasAudio,
|
||||
TestTime: testResult.TestTime,
|
||||
Metadata: testResult.Metadata,
|
||||
}
|
||||
|
||||
result.Streams = append(result.Streams, discoveredStream)
|
||||
|
||||
// Send to SSE
|
||||
_ = streamWriter.SendJSON("stream_found", map[string]interface{}{
|
||||
"stream": discoveredStream,
|
||||
})
|
||||
} else {
|
||||
_ = streamWriter.SendJSON("stream_failed", map[string]interface{}{
|
||||
"url": req.Target,
|
||||
"error": testResult.Error,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractIP extracts IP address from target
|
||||
func (s *Scanner) extractIP(target string) string {
|
||||
// Remove protocol if present
|
||||
if u, err := url.Parse(target); err == nil && u.Host != "" {
|
||||
target = u.Host
|
||||
}
|
||||
|
||||
// Remove port if present
|
||||
if idx := len(target) - 1; idx >= 0 && target[idx] == ']' {
|
||||
// IPv6 address
|
||||
return target
|
||||
}
|
||||
|
||||
for i := len(target) - 1; i >= 0; i-- {
|
||||
if target[i] == ':' {
|
||||
return target[:i]
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
|
||||
// collectStreams collects all streams to test with their metadata
|
||||
func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscoveryRequest, ip string) ([]models.DiscoveredStream, error) {
|
||||
var allStreams []models.DiscoveredStream
|
||||
urlMap := make(map[string]bool) // For deduplication
|
||||
var onvifCount, modelCount, popularCount int
|
||||
|
||||
s.logger.Debug("collectStreams started",
|
||||
"ip", ip,
|
||||
"model", req.Model,
|
||||
"username", req.Username,
|
||||
"channel", req.Channel)
|
||||
|
||||
// Build context for URL generation
|
||||
buildCtx := stream.BuildContext{
|
||||
IP: ip,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Channel: req.Channel,
|
||||
}
|
||||
|
||||
// 1. ONVIF discovery (always first)
|
||||
s.logger.Debug("========================================")
|
||||
s.logger.Debug("PHASE 1: STARTING ONVIF DISCOVERY")
|
||||
s.logger.Debug("========================================")
|
||||
s.logger.Debug("ONVIF parameters",
|
||||
"ip", ip,
|
||||
"username", req.Username,
|
||||
"password_len", len(req.Password),
|
||||
"channel", req.Channel)
|
||||
|
||||
startTime := time.Now()
|
||||
onvifStreams, err := s.onvif.DiscoverStreamsForIP(ctx, ip, req.Username, req.Password)
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error("❌ ONVIF discovery FAILED", err,
|
||||
"elapsed", elapsed.String())
|
||||
} else {
|
||||
s.logger.Debug("✅ ONVIF discovery returned",
|
||||
"streams_count", len(onvifStreams),
|
||||
"elapsed", elapsed.String())
|
||||
|
||||
for i, stream := range onvifStreams {
|
||||
s.logger.Debug("ONVIF stream returned",
|
||||
"index", i+1,
|
||||
"url", stream.URL,
|
||||
"type", stream.Type,
|
||||
"source", stream.Metadata["source"])
|
||||
|
||||
if !urlMap[stream.URL] {
|
||||
allStreams = append(allStreams, stream)
|
||||
urlMap[stream.URL] = true
|
||||
onvifCount++
|
||||
s.logger.Debug(" ✓ Added to stream list (unique)")
|
||||
} else {
|
||||
s.logger.Debug(" ✗ Skipped (duplicate)")
|
||||
}
|
||||
}
|
||||
s.logger.Debug("ONVIF phase completed",
|
||||
"total_streams_returned", len(onvifStreams),
|
||||
"unique_streams_added", onvifCount)
|
||||
}
|
||||
s.logger.Debug("========================================\n")
|
||||
|
||||
// 2. Model-specific patterns
|
||||
if req.Model != "" {
|
||||
s.logger.Debug("phase 2: searching model-specific patterns",
|
||||
"model", req.Model,
|
||||
"limit", req.ModelLimit)
|
||||
|
||||
// Search for cameras using intelligent brand+model search
|
||||
searchResp, err := s.searchEngine.Search(req.Model, req.ModelLimit)
|
||||
if err != nil {
|
||||
s.logger.Error("model search failed", err)
|
||||
} else {
|
||||
cameras := searchResp.Cameras
|
||||
|
||||
// Collect entries from all matching cameras
|
||||
var entries []models.CameraEntry
|
||||
for _, camera := range cameras {
|
||||
entries = append(entries, camera.Entries...)
|
||||
}
|
||||
|
||||
s.logger.Debug("model entries collected",
|
||||
"cameras_matched", len(cameras),
|
||||
"total_entries", len(entries))
|
||||
|
||||
// Build streams from entries
|
||||
for _, entry := range entries {
|
||||
buildCtx.Port = entry.Port
|
||||
buildCtx.Protocol = entry.Protocol
|
||||
|
||||
urls := s.builder.BuildURLsFromEntry(entry, buildCtx)
|
||||
for _, url := range urls {
|
||||
if !urlMap[url] {
|
||||
allStreams = append(allStreams, models.DiscoveredStream{
|
||||
URL: url,
|
||||
Type: entry.Type,
|
||||
Protocol: entry.Protocol,
|
||||
Port: entry.Port,
|
||||
Working: false, // Will be tested
|
||||
})
|
||||
urlMap[url] = true
|
||||
modelCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Debug("model patterns streams built",
|
||||
"total_unique_model_streams", modelCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Popular patterns (always add as fallback)
|
||||
s.logger.Debug("phase 3: adding popular patterns")
|
||||
patterns, err := s.loader.LoadPopularPatterns()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to load popular patterns", err)
|
||||
} else {
|
||||
s.logger.Debug("popular patterns loaded", "count", len(patterns))
|
||||
|
||||
for _, pattern := range patterns {
|
||||
entry := models.CameraEntry{
|
||||
Type: pattern.Type,
|
||||
Protocol: pattern.Protocol,
|
||||
Port: pattern.Port,
|
||||
URL: pattern.URL,
|
||||
}
|
||||
|
||||
buildCtx.Port = pattern.Port
|
||||
buildCtx.Protocol = pattern.Protocol
|
||||
|
||||
// Generate all URL variants for this pattern
|
||||
urls := s.builder.BuildURLsFromEntry(entry, buildCtx)
|
||||
for _, url := range urls {
|
||||
if !urlMap[url] {
|
||||
allStreams = append(allStreams, models.DiscoveredStream{
|
||||
URL: url,
|
||||
Type: pattern.Type,
|
||||
Protocol: pattern.Protocol,
|
||||
Port: pattern.Port,
|
||||
Working: false, // Will be tested
|
||||
})
|
||||
urlMap[url] = true
|
||||
popularCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalBeforeDedup := onvifCount + modelCount + popularCount
|
||||
duplicatesRemoved := totalBeforeDedup - len(allStreams)
|
||||
|
||||
s.logger.Debug("stream collection complete",
|
||||
"total_unique_streams", len(allStreams),
|
||||
"from_onvif", onvifCount,
|
||||
"from_model_patterns", modelCount,
|
||||
"from_popular_patterns", popularCount,
|
||||
"total_before_dedup", totalBeforeDedup,
|
||||
"duplicates_removed", duplicatesRemoved)
|
||||
|
||||
return allStreams, nil
|
||||
}
|
||||
|
||||
// testStreamsConcurrently tests streams concurrently
|
||||
func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models.DiscoveredStream, req models.StreamDiscoveryRequest, streamWriter *sse.StreamWriter, result *ScanResult) {
|
||||
var wg sync.WaitGroup
|
||||
var tested int32
|
||||
var found int32
|
||||
|
||||
// Create worker pool
|
||||
sem := make(chan struct{}, s.config.WorkerPoolSize)
|
||||
streamsChan := make(chan models.DiscoveredStream, 100)
|
||||
|
||||
// Start periodic progress updates
|
||||
progressCtx, cancelProgress := context.WithCancel(ctx)
|
||||
defer cancelProgress()
|
||||
|
||||
go func() {
|
||||
// Use longer interval for Ingress mode to reduce traffic (padding is ~64KB per event)
|
||||
// Normal mode: 1 second, Ingress mode: 3 seconds
|
||||
progressInterval := 1 * time.Second
|
||||
if streamWriter.IsIngress() {
|
||||
progressInterval = 3 * time.Second
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(progressInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-progressCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Send progress to prevent WriteTimeout and show scanning activity
|
||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||
Tested: int(atomic.LoadInt32(&tested)),
|
||||
Found: int(atomic.LoadInt32(&found)),
|
||||
Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start result collector
|
||||
var collectorWg sync.WaitGroup
|
||||
collectorWg.Add(1)
|
||||
go func() {
|
||||
defer collectorWg.Done()
|
||||
for stream := range streamsChan {
|
||||
result.Streams = append(result.Streams, stream)
|
||||
|
||||
s.logger.Info("sending stream_found event", "url", stream.URL, "type", stream.Type)
|
||||
|
||||
// Send to SSE
|
||||
_ = streamWriter.SendJSON("stream_found", map[string]interface{}{
|
||||
"stream": stream,
|
||||
})
|
||||
|
||||
// Send progress (immediate update when stream is found)
|
||||
_ = streamWriter.SendJSON("progress", models.ProgressMessage{
|
||||
Tested: int(atomic.LoadInt32(&tested)),
|
||||
Found: int(atomic.LoadInt32(&found)),
|
||||
Remaining: len(streams) - int(atomic.LoadInt32(&tested)),
|
||||
})
|
||||
|
||||
// Check if we've found enough streams
|
||||
if int(atomic.LoadInt32(&found)) >= req.MaxStreams {
|
||||
s.logger.Debug("max streams reached", "count", req.MaxStreams)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Test each stream
|
||||
TestLoop:
|
||||
for _, streamToTest := range streams {
|
||||
// Check if context is done or max streams reached
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Debug("scan cancelled or timeout")
|
||||
break TestLoop
|
||||
default:
|
||||
}
|
||||
|
||||
if int(atomic.LoadInt32(&found)) >= req.MaxStreams {
|
||||
break
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(stream models.DiscoveredStream) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Special handling for ONVIF device service - skip testing, already verified
|
||||
if stream.Type == "ONVIF" && stream.Working {
|
||||
atomic.AddInt32(&tested, 1)
|
||||
atomic.AddInt32(&found, 1)
|
||||
streamsChan <- stream
|
||||
s.logger.Debug("ONVIF device service added without testing", "url", stream.URL)
|
||||
return
|
||||
}
|
||||
|
||||
// Test the stream
|
||||
testResult := s.tester.TestStream(ctx, stream.URL)
|
||||
atomic.AddInt32(&tested, 1)
|
||||
|
||||
if testResult.Working {
|
||||
atomic.AddInt32(&found, 1)
|
||||
|
||||
discoveredStream := models.DiscoveredStream{
|
||||
URL: testResult.URL,
|
||||
Type: testResult.Type,
|
||||
Protocol: testResult.Protocol,
|
||||
Port: 0, // Will be extracted from URL if needed
|
||||
Working: true,
|
||||
Resolution: testResult.Resolution,
|
||||
Codec: testResult.Codec,
|
||||
FPS: testResult.FPS,
|
||||
Bitrate: testResult.Bitrate,
|
||||
HasAudio: testResult.HasAudio,
|
||||
TestTime: testResult.TestTime,
|
||||
Metadata: testResult.Metadata,
|
||||
}
|
||||
|
||||
streamsChan <- discoveredStream
|
||||
} else {
|
||||
s.logger.Debug("stream test failed", "url", stream.URL, "error", testResult.Error)
|
||||
}
|
||||
}(streamToTest)
|
||||
}
|
||||
|
||||
// Wait for all tests to complete
|
||||
wg.Wait()
|
||||
close(streamsChan)
|
||||
|
||||
// Wait for result collector to finish processing all streams
|
||||
collectorWg.Wait()
|
||||
|
||||
// Update final counts
|
||||
result.TotalTested = int(atomic.LoadInt32(&tested))
|
||||
result.TotalFound = int(atomic.LoadInt32(&found))
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// Builder handles stream URL construction
|
||||
type Builder struct {
|
||||
queryParams []string
|
||||
logger interface{ Debug(string, ...any) }
|
||||
}
|
||||
|
||||
// NewBuilder creates a new stream URL builder
|
||||
func NewBuilder(queryParams []string, logger interface{ Debug(string, ...any) }) *Builder {
|
||||
return &Builder{
|
||||
queryParams: queryParams,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext contains parameters for URL building
|
||||
type BuildContext struct {
|
||||
IP string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Channel int
|
||||
Width int
|
||||
Height int
|
||||
Protocol string
|
||||
Path string
|
||||
}
|
||||
|
||||
// BuildURL builds a complete URL from an entry and context
|
||||
func (b *Builder) BuildURL(entry models.CameraEntry, ctx BuildContext) string {
|
||||
b.logger.Debug("BuildURL called",
|
||||
"entry_type", entry.Type,
|
||||
"entry_url", entry.URL,
|
||||
"entry_port", entry.Port,
|
||||
"entry_protocol", entry.Protocol,
|
||||
"ctx_ip", ctx.IP,
|
||||
"ctx_port", ctx.Port,
|
||||
"ctx_username", ctx.Username,
|
||||
"ctx_channel", ctx.Channel)
|
||||
|
||||
// Set defaults
|
||||
if ctx.Width == 0 {
|
||||
ctx.Width = 640
|
||||
}
|
||||
if ctx.Height == 0 {
|
||||
ctx.Height = 480
|
||||
}
|
||||
// NOTE: Channel default is 0 - will only be used for [CHANNEL] placeholder replacement
|
||||
// Literal channel values in URLs (like "channel=1") are preserved as-is
|
||||
|
||||
// Use entry's port if not specified
|
||||
if ctx.Port == 0 {
|
||||
ctx.Port = entry.Port
|
||||
|
||||
// If entry port is also 0, use default port for the protocol
|
||||
if ctx.Port == 0 {
|
||||
// Use entry's protocol if not specified for port determination
|
||||
protocol := ctx.Protocol
|
||||
if protocol == "" {
|
||||
protocol = entry.Protocol
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
case "http":
|
||||
ctx.Port = 80
|
||||
case "https":
|
||||
ctx.Port = 443
|
||||
case "rtsp", "rtsps":
|
||||
ctx.Port = 554
|
||||
default:
|
||||
ctx.Port = 80 // Default to 80 if unknown
|
||||
}
|
||||
|
||||
b.logger.Debug("using default port for protocol",
|
||||
"protocol", protocol,
|
||||
"default_port", ctx.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// Use entry's protocol if not specified
|
||||
if ctx.Protocol == "" {
|
||||
ctx.Protocol = entry.Protocol
|
||||
}
|
||||
|
||||
// Replace placeholders in URL path (credentials are handled separately
|
||||
// to ensure proper encoding depending on their position in the URL).
|
||||
path := b.replacePlaceholders(entry.URL, ctx)
|
||||
b.logger.Debug("placeholders replaced", "original", entry.URL, "after_replacement", path)
|
||||
|
||||
// Build the complete URL using url.URL struct for correct encoding
|
||||
var fullURL string
|
||||
|
||||
// Check if the URL already contains authentication parameters
|
||||
hasAuthInURL := b.hasAuthenticationParams(path)
|
||||
b.logger.Debug("auth params detection", "has_auth_in_url", hasAuthInURL, "path", path)
|
||||
|
||||
// Determine host string (omit default port for cleaner URLs)
|
||||
host := b.buildHost(ctx.IP, ctx.Port, ctx.Protocol)
|
||||
|
||||
// Split path and query for url.URL (it expects them separately)
|
||||
pathPart, queryPart := b.splitPathQuery(path)
|
||||
|
||||
// Ensure path starts with exactly one slash
|
||||
if !strings.HasPrefix(pathPart, "/") {
|
||||
pathPart = "/" + pathPart
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: ctx.Protocol,
|
||||
Host: host,
|
||||
Path: pathPart,
|
||||
RawQuery: queryPart,
|
||||
}
|
||||
|
||||
switch ctx.Protocol {
|
||||
case "rtsp", "rtsps":
|
||||
if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL {
|
||||
u.User = url.UserPassword(ctx.Username, ctx.Password)
|
||||
}
|
||||
|
||||
case "http", "https":
|
||||
// For HTTP, credentials are NOT embedded in the URL by BuildURL.
|
||||
// BuildURLsFromEntry handles auth variants (userinfo, query params, etc.)
|
||||
// separately with url.UserPassword for proper encoding.
|
||||
|
||||
default:
|
||||
// Generic: no credentials in URL
|
||||
}
|
||||
|
||||
fullURL = u.String()
|
||||
|
||||
b.logger.Debug("BuildURL complete",
|
||||
"final_url", fullURL,
|
||||
"entry_type", entry.Type,
|
||||
"entry_url_pattern", entry.URL,
|
||||
"protocol", ctx.Protocol,
|
||||
"port", ctx.Port,
|
||||
"has_auth_in_url", hasAuthInURL)
|
||||
|
||||
return fullURL
|
||||
}
|
||||
|
||||
// credentialPlaceholders lists all placeholder strings that represent
|
||||
// username or password values. These must NOT be replaced via simple string
|
||||
// substitution because they require context-aware encoding (different for
|
||||
// query parameters, path segments, and userinfo).
|
||||
var credentialPlaceholders = []string{
|
||||
"[USERNAME]", "[username]", "[USER]", "[user]",
|
||||
"[PASSWORD]", "[password]", "[PASWORD]", "[pasword]",
|
||||
"[PASS]", "[pass]", "[PWD]", "[pwd]",
|
||||
}
|
||||
|
||||
// replacePlaceholders replaces all placeholders in the URL.
|
||||
//
|
||||
// Credential placeholders ([USERNAME], [PASSWORD], etc.) are handled in two
|
||||
// phases to ensure correct encoding:
|
||||
// 1. Non-credential placeholders (channel, resolution, IP, etc.) are replaced
|
||||
// first — these contain only safe characters.
|
||||
// 2. Credential placeholders are then replaced with proper encoding:
|
||||
// - In query strings: via url.Values.Set + Encode (automatic encoding)
|
||||
// - In path segments: via url.PathEscape
|
||||
func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
|
||||
result := urlPath
|
||||
|
||||
// Generate base64 auth for [AUTH] placeholder (already safe — base64 has no
|
||||
// characters that need URL encoding)
|
||||
auth := ""
|
||||
if ctx.Username != "" && ctx.Password != "" {
|
||||
auth = base64.StdEncoding.EncodeToString([]byte(ctx.Username + ":" + ctx.Password))
|
||||
}
|
||||
|
||||
// Phase 1: Replace non-credential placeholders (all values are safe strings)
|
||||
safeReplacements := map[string]string{
|
||||
"[CHANNEL]": strconv.Itoa(ctx.Channel),
|
||||
"[channel]": strconv.Itoa(ctx.Channel),
|
||||
"[CHANNEL+1]": strconv.Itoa(ctx.Channel + 1),
|
||||
"[channel+1]": strconv.Itoa(ctx.Channel + 1),
|
||||
"{CHANNEL}": strconv.Itoa(ctx.Channel),
|
||||
"{channel}": strconv.Itoa(ctx.Channel),
|
||||
"{CHANNEL+1}": strconv.Itoa(ctx.Channel + 1),
|
||||
"{channel+1}": strconv.Itoa(ctx.Channel + 1),
|
||||
"[WIDTH]": strconv.Itoa(ctx.Width),
|
||||
"[width]": strconv.Itoa(ctx.Width),
|
||||
"[HEIGHT]": strconv.Itoa(ctx.Height),
|
||||
"[height]": strconv.Itoa(ctx.Height),
|
||||
"[IP]": ctx.IP,
|
||||
"[ip]": ctx.IP,
|
||||
"[PORT]": strconv.Itoa(ctx.Port),
|
||||
"[port]": strconv.Itoa(ctx.Port),
|
||||
"[AUTH]": auth,
|
||||
"[auth]": auth,
|
||||
"[TOKEN]": "",
|
||||
"[token]": "",
|
||||
}
|
||||
|
||||
for placeholder, value := range safeReplacements {
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
}
|
||||
|
||||
// Phase 2: Replace credential placeholders with proper encoding.
|
||||
// First handle query parameters (via url.Values for safe encoding),
|
||||
// then handle any remaining credential placeholders in the path.
|
||||
result = b.replaceQueryCredentials(result, ctx)
|
||||
result = b.replacePathCredentials(result, ctx)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// replaceQueryCredentials handles credential replacement in query parameters.
|
||||
// It parses the query string while credential placeholders are still intact
|
||||
// (safe ASCII strings like "[PASSWORD]"), replaces them with real values via
|
||||
// url.Values.Set, and re-encodes. This ensures special characters in passwords
|
||||
// are always properly percent-encoded.
|
||||
func (b *Builder) replaceQueryCredentials(urlPath string, ctx BuildContext) string {
|
||||
parts := strings.SplitN(urlPath, "?", 2)
|
||||
if len(parts) < 2 {
|
||||
return urlPath
|
||||
}
|
||||
|
||||
basePath := parts[0]
|
||||
queryString := parts[1]
|
||||
|
||||
// Parse the query string — placeholders like [PASSWORD] are safe to parse
|
||||
// because they contain no special URL characters.
|
||||
params, err := url.ParseQuery(queryString)
|
||||
if err != nil {
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// Username placeholder values that should be replaced
|
||||
usernamePlaceholders := map[string]bool{
|
||||
"[USERNAME]": true, "[username]": true,
|
||||
"[USER]": true, "[user]": true,
|
||||
}
|
||||
|
||||
// Password placeholder values that should be replaced
|
||||
passwordPlaceholders := map[string]bool{
|
||||
"[PASSWORD]": true, "[password]": true,
|
||||
"[PASWORD]": true, "[pasword]": true,
|
||||
"[PASS]": true, "[pass]": true,
|
||||
"[PWD]": true, "[pwd]": true,
|
||||
}
|
||||
|
||||
changed := false
|
||||
for key, values := range params {
|
||||
for _, val := range values {
|
||||
if usernamePlaceholders[val] {
|
||||
params.Set(key, ctx.Username)
|
||||
changed = true
|
||||
} else if passwordPlaceholders[val] {
|
||||
params.Set(key, ctx.Password)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Also handle auth-named keys whose values are still placeholders
|
||||
// or already contain the raw value from a previous step.
|
||||
// This covers patterns like "?user=admin&pwd=12345" that come from
|
||||
// replaceQueryParams in the old code.
|
||||
lowerKey := strings.ToLower(key)
|
||||
switch lowerKey {
|
||||
case "user", "username", "usr", "loginuse":
|
||||
if params.Get(key) == "" || isCredentialPlaceholder(params.Get(key)) {
|
||||
params.Set(key, ctx.Username)
|
||||
changed = true
|
||||
}
|
||||
case "password", "pass", "pwd", "loginpas", "passwd":
|
||||
if params.Get(key) == "" || isCredentialPlaceholder(params.Get(key)) {
|
||||
params.Set(key, ctx.Password)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// params.Encode() automatically percent-encodes all values
|
||||
return basePath + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// replacePathCredentials replaces any remaining credential placeholders in the
|
||||
// path portion of the URL using url.PathEscape for safe encoding.
|
||||
func (b *Builder) replacePathCredentials(urlPath string, ctx BuildContext) string {
|
||||
// Map of credential placeholders to their escaped values for use in paths
|
||||
pathReplacements := map[string]string{
|
||||
"[USERNAME]": url.PathEscape(ctx.Username),
|
||||
"[username]": url.PathEscape(ctx.Username),
|
||||
"[USER]": url.PathEscape(ctx.Username),
|
||||
"[user]": url.PathEscape(ctx.Username),
|
||||
"[PASSWORD]": url.PathEscape(ctx.Password),
|
||||
"[password]": url.PathEscape(ctx.Password),
|
||||
"[PASWORD]": url.PathEscape(ctx.Password),
|
||||
"[pasword]": url.PathEscape(ctx.Password),
|
||||
"[PASS]": url.PathEscape(ctx.Password),
|
||||
"[pass]": url.PathEscape(ctx.Password),
|
||||
"[PWD]": url.PathEscape(ctx.Password),
|
||||
"[pwd]": url.PathEscape(ctx.Password),
|
||||
}
|
||||
|
||||
for placeholder, value := range pathReplacements {
|
||||
urlPath = strings.ReplaceAll(urlPath, placeholder, value)
|
||||
}
|
||||
|
||||
return urlPath
|
||||
}
|
||||
|
||||
// isCredentialPlaceholder checks if a string is one of the known credential
|
||||
// placeholder tokens.
|
||||
func isCredentialPlaceholder(s string) bool {
|
||||
for _, p := range credentialPlaceholders {
|
||||
if s == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// hasAuthenticationParams checks if URL contains auth parameters
|
||||
func (b *Builder) hasAuthenticationParams(urlPath string) bool {
|
||||
authParams := []string{
|
||||
"user=", "username=", "usr=", "loginuse=",
|
||||
"password=", "pass=", "pwd=", "loginpas=", "passwd=",
|
||||
}
|
||||
|
||||
lowerPath := strings.ToLower(urlPath)
|
||||
for _, param := range authParams {
|
||||
if strings.Contains(lowerPath, param) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildHost returns the host:port string, omitting the port when it matches
|
||||
// the default for the given protocol.
|
||||
func (b *Builder) buildHost(ip string, port int, protocol string) string {
|
||||
isDefault := (protocol == "http" && port == 80) ||
|
||||
(protocol == "https" && port == 443) ||
|
||||
(protocol == "rtsp" && port == 554) ||
|
||||
(protocol == "rtsps" && port == 322)
|
||||
|
||||
if isDefault || port == 0 {
|
||||
return ip
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", ip, port)
|
||||
}
|
||||
|
||||
// splitPathQuery splits a path string into path and raw query components.
|
||||
// The input may contain "?" separating the path from the query string.
|
||||
func (b *Builder) splitPathQuery(path string) (string, string) {
|
||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||
return path[:idx], path[idx+1:]
|
||||
}
|
||||
return path, ""
|
||||
}
|
||||
|
||||
// BuildURLsFromEntry generates all possible URLs from a camera entry
|
||||
func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) []string {
|
||||
urlMap := make(map[string]bool)
|
||||
var urls []string
|
||||
|
||||
// Helper to add unique URLs
|
||||
addURL := func(url string) {
|
||||
if !urlMap[url] {
|
||||
urls = append(urls, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
switch entry.Protocol {
|
||||
case "bubble":
|
||||
// BUBBLE protocol: proprietary Chinese NVR/DVR protocol
|
||||
// Always use HTTP with embedded credentials
|
||||
if ctx.Username != "" && ctx.Password != "" {
|
||||
// Build HTTP URL with credentials embedded
|
||||
ctxHTTP := ctx
|
||||
ctxHTTP.Protocol = "http"
|
||||
|
||||
baseURL := b.BuildURL(entry, ctxHTTP)
|
||||
|
||||
// Parse and add credentials to URL
|
||||
if u, err := url.Parse(baseURL); err == nil {
|
||||
u.User = url.UserPassword(ctx.Username, ctx.Password)
|
||||
addURL(u.String())
|
||||
}
|
||||
} else {
|
||||
// No credentials - try anyway (some cameras might work)
|
||||
ctxHTTP := ctx
|
||||
ctxHTTP.Protocol = "http"
|
||||
addURL(b.BuildURL(entry, ctxHTTP))
|
||||
}
|
||||
|
||||
case "rtsp", "rtsps":
|
||||
// For RTSP: generate ONLY with credentials if provided, otherwise without
|
||||
if ctx.Username != "" && ctx.Password != "" {
|
||||
// Credentials provided - generate ONLY URL with auth
|
||||
addURL(b.BuildURL(entry, ctx))
|
||||
} else {
|
||||
// No credentials - generate ONLY URL without auth
|
||||
ctxNoAuth := ctx
|
||||
ctxNoAuth.Username = ""
|
||||
ctxNoAuth.Password = ""
|
||||
addURL(b.BuildURL(entry, ctxNoAuth))
|
||||
}
|
||||
|
||||
case "http", "https":
|
||||
// For HTTP/HTTPS: ALWAYS generate 4 authentication variants
|
||||
if ctx.Username != "" && ctx.Password != "" {
|
||||
// 1. No authentication
|
||||
ctxNoAuth := ctx
|
||||
ctxNoAuth.Username = ""
|
||||
ctxNoAuth.Password = ""
|
||||
urlNoAuth := b.BuildURL(entry, ctxNoAuth)
|
||||
addURL(urlNoAuth)
|
||||
|
||||
// 2. Basic Auth only (embedded credentials)
|
||||
urlBasic := b.BuildURL(entry, ctxNoAuth) // Use clean URL
|
||||
if u, err := url.Parse(urlBasic); err == nil {
|
||||
u.User = url.UserPassword(ctx.Username, ctx.Password)
|
||||
addURL(u.String())
|
||||
}
|
||||
|
||||
// 3. Query parameters only
|
||||
urlWithParams := b.BuildURL(entry, ctx) // This will replace placeholders if any
|
||||
|
||||
// If URL has auth placeholders, they're already replaced
|
||||
if strings.Contains(entry.URL, "[USERNAME]") || strings.Contains(entry.URL, "[PASSWORD]") {
|
||||
addURL(urlWithParams)
|
||||
} else {
|
||||
// No placeholders - add query params for auth (don't overwrite existing params)
|
||||
if u, err := url.Parse(urlWithParams); err == nil {
|
||||
q := u.Query()
|
||||
|
||||
// Add user/pwd if not already present
|
||||
if !q.Has("user") && !q.Has("usr") && !q.Has("username") {
|
||||
q.Set("user", ctx.Username)
|
||||
}
|
||||
if !q.Has("pwd") && !q.Has("password") && !q.Has("pass") {
|
||||
q.Set("pwd", ctx.Password)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
addURL(u.String())
|
||||
|
||||
// Try alternative names too
|
||||
q2 := url.Values{}
|
||||
for k, v := range u.Query() {
|
||||
q2[k] = v
|
||||
}
|
||||
if !q2.Has("username") && !q2.Has("user") && !q2.Has("usr") {
|
||||
q2.Set("username", ctx.Username)
|
||||
}
|
||||
if !q2.Has("password") && !q2.Has("pwd") && !q2.Has("pass") {
|
||||
q2.Set("password", ctx.Password)
|
||||
}
|
||||
u.RawQuery = q2.Encode()
|
||||
addURL(u.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Basic Auth + Query parameters (combined)
|
||||
if strings.Contains(entry.URL, "[USERNAME]") || strings.Contains(entry.URL, "[PASSWORD]") {
|
||||
// URL has placeholders - add Basic Auth to the URL with replaced params
|
||||
if u, err := url.Parse(urlWithParams); err == nil {
|
||||
u.User = url.UserPassword(ctx.Username, ctx.Password)
|
||||
addURL(u.String())
|
||||
}
|
||||
} else {
|
||||
// No placeholders - add both Basic Auth and query params (without overwriting existing)
|
||||
if u, err := url.Parse(urlNoAuth); err == nil {
|
||||
u.User = url.UserPassword(ctx.Username, ctx.Password)
|
||||
q := u.Query()
|
||||
|
||||
// Add auth params only if not already present
|
||||
if !q.Has("user") && !q.Has("usr") && !q.Has("username") {
|
||||
q.Set("user", ctx.Username)
|
||||
}
|
||||
if !q.Has("pwd") && !q.Has("password") && !q.Has("pass") {
|
||||
q.Set("pwd", ctx.Password)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
addURL(u.String())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No credentials provided - just one URL
|
||||
addURL(b.BuildURL(entry, ctx))
|
||||
}
|
||||
|
||||
default:
|
||||
// Other protocols - single URL
|
||||
addURL(b.BuildURL(entry, ctx))
|
||||
}
|
||||
|
||||
|
||||
b.logger.Debug("BuildURLsFromEntry complete",
|
||||
"entry_url_pattern", entry.URL,
|
||||
"entry_type", entry.Type,
|
||||
"entry_protocol", entry.Protocol,
|
||||
"total_urls_generated", len(urls),
|
||||
"urls", urls)
|
||||
|
||||
return urls
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// mockLogger implements the logger interface for testing
|
||||
type mockLogger struct{}
|
||||
|
||||
func (m *mockLogger) Debug(msg string, args ...any) {}
|
||||
func (m *mockLogger) Error(msg string, err error, args ...any) {}
|
||||
|
||||
// TestCurrentDeduplicationProblems демонстрирует проблемы текущей дедупликации
|
||||
func TestCurrentDeduplicationProblems(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entry models.CameraEntry
|
||||
ctx BuildContext
|
||||
expectedURLCount int // Сколько Builder генерирует
|
||||
realUniqueCount int // Сколько реально уникальных
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "HTTP auth variants - same endpoint, 4 different URLs",
|
||||
entry: models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.jpg",
|
||||
},
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 80,
|
||||
},
|
||||
expectedURLCount: 4, // Builder генерирует 4 варианта
|
||||
realUniqueCount: 1, // Но это ОДИН поток
|
||||
description: "PROBLEM: 4 authentication variants of the same HTTP endpoint",
|
||||
},
|
||||
{
|
||||
name: "HTTP with auth placeholders - generates duplicates",
|
||||
entry: models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]",
|
||||
},
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 80,
|
||||
},
|
||||
expectedURLCount: 4,
|
||||
realUniqueCount: 1,
|
||||
description: "PROBLEM: Placeholder replacement + auth variants = duplicates",
|
||||
},
|
||||
{
|
||||
name: "RTSP with credentials - now FIXED",
|
||||
entry: models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/main",
|
||||
},
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1, // FIXED: только с credentials
|
||||
realUniqueCount: 1, // Это один поток
|
||||
description: "FIXED: RTSP with credentials generates ONLY auth URL",
|
||||
},
|
||||
{
|
||||
name: "RTSP without credentials - only one URL",
|
||||
entry: models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/main",
|
||||
},
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1,
|
||||
realUniqueCount: 1,
|
||||
description: "OK: No credentials = only one URL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
urls := builder.BuildURLsFromEntry(tt.entry, tt.ctx)
|
||||
|
||||
t.Logf("\n=== %s ===", tt.description)
|
||||
t.Logf("Entry: %s://%s", tt.entry.Protocol, tt.entry.URL)
|
||||
t.Logf("Expected URL count: %d", tt.expectedURLCount)
|
||||
t.Logf("Real unique streams: %d", tt.realUniqueCount)
|
||||
t.Logf("Generated URLs:")
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
if len(urls) != tt.expectedURLCount {
|
||||
t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls))
|
||||
}
|
||||
|
||||
// Демонстрация проблемы
|
||||
if len(urls) > tt.realUniqueCount {
|
||||
duplicateCount := len(urls) - tt.realUniqueCount
|
||||
t.Logf("\n⚠️ PROBLEM: %d semantic duplicates generated", duplicateCount)
|
||||
t.Logf("These are different URL strings pointing to the SAME stream!")
|
||||
t.Logf("Waste: %d unnecessary tests", duplicateCount)
|
||||
} else if len(urls) == tt.realUniqueCount && tt.expectedURLCount == tt.realUniqueCount {
|
||||
t.Logf("\n✓ NO DUPLICATES: All URLs are unique (FIXED!)")
|
||||
}
|
||||
|
||||
// Показать канонические URL
|
||||
canonicalURLs := make(map[string][]string)
|
||||
for _, url := range urls {
|
||||
canonical := makeCanonical(url)
|
||||
canonicalURLs[canonical] = append(canonicalURLs[canonical], url)
|
||||
}
|
||||
|
||||
t.Logf("\nCanonical URL analysis:")
|
||||
for canonical, variants := range canonicalURLs {
|
||||
t.Logf(" Canonical: %s", canonical)
|
||||
if len(variants) > 1 {
|
||||
t.Logf(" ⚠️ Has %d variants (DUPLICATES!):", len(variants))
|
||||
for _, v := range variants {
|
||||
t.Logf(" - %s", v)
|
||||
}
|
||||
} else {
|
||||
t.Logf(" ✓ Unique")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleSourcesDuplication тестирует дубликаты от разных источников
|
||||
func TestMultipleSourcesDuplication(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
// Симуляция: один и тот же паттерн из двух источников
|
||||
entry1 := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/Streaming/Channels/101",
|
||||
}
|
||||
|
||||
entry2 := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/Streaming/Channels/101",
|
||||
}
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls1 := builder.BuildURLsFromEntry(entry1, ctx)
|
||||
urls2 := builder.BuildURLsFromEntry(entry2, ctx)
|
||||
|
||||
t.Logf("\n=== Multiple Sources Generate Same URLs ===")
|
||||
t.Logf("Source 1 (e.g., Popular Patterns):")
|
||||
for i, url := range urls1 {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
t.Logf("\nSource 2 (e.g., Model Patterns):")
|
||||
for i, url := range urls2 {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Симуляция текущей дедупликации (простое сравнение строк)
|
||||
urlMap := make(map[string]bool)
|
||||
var combined []string
|
||||
|
||||
for _, url := range urls1 {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
detectedDuplicates := 0
|
||||
for _, url := range urls2 {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
} else {
|
||||
detectedDuplicates++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\nCurrent deduplication results:")
|
||||
t.Logf(" Source 1 URLs: %d", len(urls1))
|
||||
t.Logf(" Source 2 URLs: %d", len(urls2))
|
||||
t.Logf(" Combined URLs: %d", len(combined))
|
||||
t.Logf(" Duplicates detected by string comparison: %d", detectedDuplicates)
|
||||
|
||||
// Канонический анализ
|
||||
canonicalMap := make(map[string][]string)
|
||||
for _, url := range combined {
|
||||
canonical := makeCanonical(url)
|
||||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||||
}
|
||||
|
||||
realUnique := len(canonicalMap)
|
||||
semanticDuplicates := len(combined) - realUnique
|
||||
|
||||
t.Logf("\nCanonical URL analysis:")
|
||||
t.Logf(" Real unique streams: %d", realUnique)
|
||||
t.Logf(" Semantic duplicates: %d", semanticDuplicates)
|
||||
t.Logf(" Current dedup effectiveness: %.1f%%",
|
||||
float64(detectedDuplicates)/float64(len(urls1)+len(urls2))*100)
|
||||
t.Logf(" Should be dedup effectiveness: %.1f%%",
|
||||
float64(semanticDuplicates+detectedDuplicates)/float64(len(urls1)+len(urls2))*100)
|
||||
|
||||
if semanticDuplicates > 0 {
|
||||
t.Logf("\n⚠️ PROBLEM: %d semantic duplicates NOT detected", semanticDuplicates)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorstCaseScenario показывает худший сценарий
|
||||
func TestWorstCaseScenario(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
// Паттерн, который есть везде: Popular + Model + ONVIF
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.jpg",
|
||||
}
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
// Симуляция 3 источников
|
||||
popularURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||||
modelURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
// ONVIF может вернуть URL без credentials
|
||||
onvifURL := "http://192.168.1.100/snapshot.jpg"
|
||||
|
||||
t.Logf("\n=== WORST CASE: Same pattern from 3 sources ===")
|
||||
t.Logf("Popular patterns generates: %d URLs", len(popularURLs))
|
||||
t.Logf("Model patterns generates: %d URLs", len(modelURLs))
|
||||
t.Logf("ONVIF returns: 1 URL")
|
||||
|
||||
// Текущая дедупликация
|
||||
urlMap := make(map[string]bool)
|
||||
var all []string
|
||||
|
||||
add := func(url string) {
|
||||
if !urlMap[url] {
|
||||
all = append(all, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, url := range popularURLs {
|
||||
add(url)
|
||||
}
|
||||
for _, url := range modelURLs {
|
||||
add(url)
|
||||
}
|
||||
add(onvifURL)
|
||||
|
||||
t.Logf("\nAfter current deduplication:")
|
||||
t.Logf(" Total URLs to test: %d", len(all))
|
||||
|
||||
for i, url := range all {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Канонический анализ
|
||||
canonicalMap := make(map[string][]string)
|
||||
for _, url := range all {
|
||||
canonical := makeCanonical(url)
|
||||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||||
}
|
||||
|
||||
t.Logf("\nCanonical analysis:")
|
||||
t.Logf(" Real unique streams: %d", len(canonicalMap))
|
||||
t.Logf(" URLs being tested: %d", len(all))
|
||||
t.Logf(" Waste: %d unnecessary tests (%.1f%%)",
|
||||
len(all)-len(canonicalMap),
|
||||
float64(len(all)-len(canonicalMap))/float64(len(all))*100)
|
||||
|
||||
if len(all) > 1 {
|
||||
t.Logf("\n⚠️ CRITICAL: Testing the same stream %d times!", len(all))
|
||||
t.Logf("Expected time waste: ~%d seconds (assuming 2s per test)", (len(all)-1)*2)
|
||||
}
|
||||
}
|
||||
|
||||
// makeCanonical - упрощенная нормализация URL для теста
|
||||
func makeCanonical(rawURL string) string {
|
||||
url := rawURL
|
||||
|
||||
// 1. Убрать credentials (user:pass@)
|
||||
if idx := strings.Index(url, "://"); idx >= 0 {
|
||||
protocol := url[:idx+3]
|
||||
rest := url[idx+3:]
|
||||
|
||||
if atIdx := strings.Index(rest, "@"); atIdx >= 0 {
|
||||
rest = rest[atIdx+1:]
|
||||
}
|
||||
|
||||
url = protocol + rest
|
||||
}
|
||||
|
||||
// 2. Убрать auth query параметры
|
||||
authParams := []string{
|
||||
"user=", "username=", "usr=",
|
||||
"pwd=", "password=", "pass=",
|
||||
}
|
||||
|
||||
for _, param := range authParams {
|
||||
if idx := strings.Index(url, "?"+param); idx >= 0 {
|
||||
// Найти конец параметра
|
||||
endIdx := strings.Index(url[idx+1:], "&")
|
||||
if endIdx >= 0 {
|
||||
url = url[:idx+1] + url[idx+1+endIdx+1:]
|
||||
} else {
|
||||
url = url[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
if idx := strings.Index(url, "&"+param); idx >= 0 {
|
||||
endIdx := strings.Index(url[idx+1:], "&")
|
||||
if endIdx >= 0 {
|
||||
url = url[:idx] + url[idx+1+endIdx:]
|
||||
} else {
|
||||
url = url[:idx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Убрать trailing ?
|
||||
url = strings.TrimSuffix(url, "?")
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// TestRealWorldDeduplication тестирует реальный сценарий:
|
||||
// 5 одинаковых URL из 3 разных источников (ONVIF, Model patterns, Popular patterns)
|
||||
func TestRealWorldDeduplication(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Channel: 1,
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
t.Log("\n========================================")
|
||||
t.Log("REAL WORLD SCENARIO: Same stream from 3 sources")
|
||||
t.Log("========================================\n")
|
||||
|
||||
// === SOURCE 1: ONVIF Discovery ===
|
||||
t.Log("=== SOURCE 1: ONVIF Discovery ===")
|
||||
onvifStreams := []models.DiscoveredStream{
|
||||
{
|
||||
URL: "rtsp://192.168.1.100:554/Streaming/Channels/101",
|
||||
Type: "ONVIF",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
Working: true, // ONVIF streams are pre-verified
|
||||
},
|
||||
}
|
||||
t.Logf("ONVIF discovered: %d URLs", len(onvifStreams))
|
||||
for i, s := range onvifStreams {
|
||||
t.Logf(" [ONVIF-%d] %s", i+1, s.URL)
|
||||
}
|
||||
|
||||
// === SOURCE 2: Model-specific patterns (Hikvision) ===
|
||||
t.Log("\n=== SOURCE 2: Model-specific patterns (Hikvision DS-2CD2086) ===")
|
||||
modelEntry := models.CameraEntry{
|
||||
Models: []string{"DS-2CD2086G2-I", "DS-2CD2042WD"},
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/Streaming/Channels/101",
|
||||
}
|
||||
modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx)
|
||||
t.Logf("Model patterns generated: %d URLs", len(modelURLs))
|
||||
for i, url := range modelURLs {
|
||||
t.Logf(" [MODEL-%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// === SOURCE 3: Popular patterns ===
|
||||
t.Log("\n=== SOURCE 3: Popular patterns (generic RTSP) ===")
|
||||
popularEntry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/Streaming/Channels/101",
|
||||
}
|
||||
popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx)
|
||||
t.Logf("Popular patterns generated: %d URLs", len(popularURLs))
|
||||
for i, url := range popularURLs {
|
||||
t.Logf(" [POPULAR-%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// === CURRENT DEDUPLICATION (как в scanner.go:235-395) ===
|
||||
t.Log("\n=== CURRENT DEDUPLICATION (string comparison) ===")
|
||||
urlMap := make(map[string]bool)
|
||||
var allStreams []models.DiscoveredStream
|
||||
|
||||
// Add ONVIF streams
|
||||
for _, stream := range onvifStreams {
|
||||
if !urlMap[stream.URL] {
|
||||
allStreams = append(allStreams, stream)
|
||||
urlMap[stream.URL] = true
|
||||
t.Logf("✓ Added: %s (from ONVIF)", stream.URL)
|
||||
} else {
|
||||
t.Logf("✗ Skipped: %s (duplicate from ONVIF)", stream.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Model URLs
|
||||
for _, url := range modelURLs {
|
||||
if !urlMap[url] {
|
||||
allStreams = append(allStreams, models.DiscoveredStream{
|
||||
URL: url,
|
||||
Type: modelEntry.Type,
|
||||
Protocol: modelEntry.Protocol,
|
||||
Port: modelEntry.Port,
|
||||
})
|
||||
urlMap[url] = true
|
||||
t.Logf("✓ Added: %s (from Model)", url)
|
||||
} else {
|
||||
t.Logf("✗ Skipped: %s (duplicate from Model)", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Add Popular URLs
|
||||
for _, url := range popularURLs {
|
||||
if !urlMap[url] {
|
||||
allStreams = append(allStreams, models.DiscoveredStream{
|
||||
URL: url,
|
||||
Type: popularEntry.Type,
|
||||
Protocol: popularEntry.Protocol,
|
||||
Port: popularEntry.Port,
|
||||
})
|
||||
urlMap[url] = true
|
||||
t.Logf("✓ Added: %s (from Popular)", url)
|
||||
} else {
|
||||
t.Logf("✗ Skipped: %s (duplicate from Popular)", url)
|
||||
}
|
||||
}
|
||||
|
||||
// === RESULTS ===
|
||||
t.Log("\n========================================")
|
||||
t.Log("DEDUPLICATION RESULTS")
|
||||
t.Log("========================================")
|
||||
|
||||
totalGenerated := len(onvifStreams) + len(modelURLs) + len(popularURLs)
|
||||
t.Logf("Total URLs generated: %d", totalGenerated)
|
||||
t.Logf(" - From ONVIF: %d", len(onvifStreams))
|
||||
t.Logf(" - From Model: %d", len(modelURLs))
|
||||
t.Logf(" - From Popular: %d", len(popularURLs))
|
||||
t.Logf("\nURLs after deduplication: %d", len(allStreams))
|
||||
t.Logf("Duplicates removed: %d", totalGenerated-len(allStreams))
|
||||
|
||||
// List final URLs
|
||||
t.Log("\nFinal URLs to test:")
|
||||
for i, stream := range allStreams {
|
||||
t.Logf(" [%d] %s (type: %s)", i+1, stream.URL, stream.Type)
|
||||
}
|
||||
|
||||
// === CANONICAL ANALYSIS (показывает реальные дубликаты) ===
|
||||
t.Log("\n========================================")
|
||||
t.Log("CANONICAL ANALYSIS (semantic duplicates)")
|
||||
t.Log("========================================")
|
||||
|
||||
canonicalMap := make(map[string][]string)
|
||||
for _, stream := range allStreams {
|
||||
canonical := normalizeURLForComparison(stream.URL)
|
||||
canonicalMap[canonical] = append(canonicalMap[canonical], stream.URL)
|
||||
}
|
||||
|
||||
realUnique := len(canonicalMap)
|
||||
semanticDuplicates := len(allStreams) - realUnique
|
||||
|
||||
t.Logf("Real unique streams: %d", realUnique)
|
||||
t.Logf("Semantic duplicates: %d", semanticDuplicates)
|
||||
|
||||
if semanticDuplicates > 0 {
|
||||
t.Log("\n⚠️ PROBLEM: Multiple URLs point to the SAME stream:")
|
||||
for canonical, variants := range canonicalMap {
|
||||
if len(variants) > 1 {
|
||||
t.Logf("\n Canonical: %s", canonical)
|
||||
t.Logf(" Variants (%d):", len(variants))
|
||||
for _, v := range variants {
|
||||
t.Logf(" - %s", v)
|
||||
}
|
||||
t.Logf(" ⚠️ This stream will be tested %d times!", len(variants))
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n⚠️ WASTE: %d unnecessary tests", semanticDuplicates)
|
||||
t.Logf("Time waste: ~%d seconds (assuming 2s per test)", semanticDuplicates*2)
|
||||
t.Logf("Bandwidth waste: ~%d KB (assuming 100KB per test)", semanticDuplicates*100)
|
||||
} else {
|
||||
t.Log("\n✓ No semantic duplicates found")
|
||||
}
|
||||
|
||||
// === ASSERTION ===
|
||||
if semanticDuplicates > 0 {
|
||||
t.Errorf("DEDUPLICATION FAILED: %d semantic duplicates not removed", semanticDuplicates)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPAuthVariantsDuplication проверяет дубликаты от HTTP auth вариантов
|
||||
func TestHTTPAuthVariantsDuplication(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
t.Log("\n========================================")
|
||||
t.Log("HTTP AUTHENTICATION VARIANTS TEST")
|
||||
t.Log("========================================\n")
|
||||
|
||||
// Один entry для HTTP
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi",
|
||||
}
|
||||
|
||||
t.Log("Entry: http://192.168.1.100/snapshot.cgi")
|
||||
t.Log("\nBuilder generates auth variants:")
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
t.Logf("\nTotal URLs generated: %d", len(urls))
|
||||
|
||||
// Canonical analysis
|
||||
canonicalMap := make(map[string][]string)
|
||||
for _, url := range urls {
|
||||
canonical := normalizeURLForComparison(url)
|
||||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||||
}
|
||||
|
||||
t.Logf("Real unique endpoints: %d", len(canonicalMap))
|
||||
semanticDuplicates := len(urls) - len(canonicalMap)
|
||||
t.Logf("Semantic duplicates: %d", semanticDuplicates)
|
||||
|
||||
if semanticDuplicates > 0 {
|
||||
t.Log("\n⚠️ PROBLEM: Multiple auth variants for the SAME endpoint:")
|
||||
for canonical, variants := range canonicalMap {
|
||||
if len(variants) > 1 {
|
||||
t.Logf("\n Endpoint: %s", canonical)
|
||||
t.Logf(" Auth variants (%d):", len(variants))
|
||||
for j, v := range variants {
|
||||
t.Logf(" [%d] %s", j+1, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n⚠️ All %d variants will be tested, but only 1 will likely work", len(urls))
|
||||
t.Logf("Expected success rate: ~25%% (1 out of 4)")
|
||||
t.Logf("Expected failures: ~%d", len(urls)-1)
|
||||
}
|
||||
|
||||
// Note: это НЕ ошибка - это feature для повышения шансов найти рабочий вариант auth
|
||||
t.Log("\nNOTE: This is intentional - trying multiple auth methods increases success rate")
|
||||
t.Log("But it does mean testing the same stream multiple times with different credentials")
|
||||
}
|
||||
|
||||
// TestFiveIdenticalURLsFromThreeSources - главный тест: ровно 5 одинаковых URL
|
||||
func TestFiveIdenticalURLsFromThreeSources(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "password123",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
t.Log("\n========================================")
|
||||
t.Log("TEST: 5 IDENTICAL URLs from 3 SOURCES")
|
||||
t.Log("========================================\n")
|
||||
|
||||
// SOURCE 1: ONVIF - returns 1 URL without auth
|
||||
onvifURL := "rtsp://192.168.1.100:554/live/ch0"
|
||||
t.Log("SOURCE 1 - ONVIF Discovery:")
|
||||
t.Logf(" Returns: %s", onvifURL)
|
||||
|
||||
// SOURCE 2: Model patterns - generates 2 URLs (with/without auth)
|
||||
modelEntry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/ch0",
|
||||
}
|
||||
modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx)
|
||||
t.Log("\nSOURCE 2 - Model Patterns (Hikvision):")
|
||||
t.Logf(" Generates: %d URLs", len(modelURLs))
|
||||
for i, url := range modelURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// SOURCE 3: Popular patterns - generates 2 URLs (with/without auth)
|
||||
popularEntry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/ch0",
|
||||
}
|
||||
popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx)
|
||||
t.Log("\nSOURCE 3 - Popular Patterns:")
|
||||
t.Logf(" Generates: %d URLs", len(popularURLs))
|
||||
for i, url := range popularURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Simulate current deduplication
|
||||
urlMap := make(map[string]bool)
|
||||
var combined []string
|
||||
|
||||
// Add ONVIF
|
||||
if !urlMap[onvifURL] {
|
||||
combined = append(combined, onvifURL)
|
||||
urlMap[onvifURL] = true
|
||||
}
|
||||
|
||||
// Add Model
|
||||
for _, url := range modelURLs {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add Popular
|
||||
for _, url := range popularURLs {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("\n========================================")
|
||||
t.Log("RESULTS")
|
||||
t.Log("========================================")
|
||||
|
||||
totalGenerated := 1 + len(modelURLs) + len(popularURLs)
|
||||
t.Logf("Total URLs from all sources: %d", totalGenerated)
|
||||
t.Logf(" ONVIF: 1")
|
||||
t.Logf(" Model: %d", len(modelURLs))
|
||||
t.Logf(" Popular: %d", len(popularURLs))
|
||||
|
||||
t.Logf("\nAfter string-based deduplication: %d URLs", len(combined))
|
||||
t.Logf("Removed by string comparison: %d", totalGenerated-len(combined))
|
||||
|
||||
t.Log("\nFinal URLs to test:")
|
||||
for i, url := range combined {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Canonical analysis
|
||||
canonicalMap := make(map[string][]string)
|
||||
for _, url := range combined {
|
||||
canonical := normalizeURLForComparison(url)
|
||||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||||
}
|
||||
|
||||
realUnique := len(canonicalMap)
|
||||
semanticDuplicates := len(combined) - realUnique
|
||||
|
||||
t.Log("\n========================================")
|
||||
t.Log("SEMANTIC ANALYSIS")
|
||||
t.Log("========================================")
|
||||
t.Logf("Real unique streams: %d", realUnique)
|
||||
t.Logf("Semantic duplicates: %d", semanticDuplicates)
|
||||
|
||||
if semanticDuplicates > 0 {
|
||||
t.Log("\n⚠️ CRITICAL ISSUE:")
|
||||
t.Logf("The same stream will be tested %d times!", len(combined))
|
||||
t.Log("\nBreakdown:")
|
||||
for canonical, variants := range canonicalMap {
|
||||
t.Logf("\n Stream: %s", canonical)
|
||||
t.Logf(" Will be tested %d times as:", len(variants))
|
||||
for i, v := range variants {
|
||||
t.Logf(" [%d] %s", i+1, v)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("\n⚠️ IMPACT:")
|
||||
t.Logf(" - Wasted tests: %d", semanticDuplicates)
|
||||
t.Logf(" - Wasted time: ~%d seconds", semanticDuplicates*2)
|
||||
t.Logf(" - Efficiency: %.1f%% (should be 100%%)",
|
||||
float64(realUnique)/float64(len(combined))*100)
|
||||
|
||||
t.Errorf("\nDEDUPLICATION FAILED: %d duplicates not detected", semanticDuplicates)
|
||||
} else {
|
||||
t.Log("\n✓ SUCCESS: All duplicates properly detected")
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeURLForComparison убирает различия в auth для сравнения
|
||||
func normalizeURLForComparison(rawURL string) string {
|
||||
// Простая нормализация: убираем user:pass@ из URL
|
||||
url := rawURL
|
||||
|
||||
// Найти protocol://
|
||||
protocolEnd := 0
|
||||
for i := 0; i < len(url)-3; i++ {
|
||||
if url[i:i+3] == "://" {
|
||||
protocolEnd = i + 3
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if protocolEnd == 0 {
|
||||
return url
|
||||
}
|
||||
|
||||
protocol := url[:protocolEnd]
|
||||
rest := url[protocolEnd:]
|
||||
|
||||
// Убрать user:pass@
|
||||
atIndex := -1
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == '@' {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
if rest[i] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIndex >= 0 {
|
||||
rest = rest[atIndex+1:]
|
||||
}
|
||||
|
||||
return protocol + rest
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// TestProtocolAuthBehaviorComparison проверяет разницу в генерации auth вариантов
|
||||
// между RTSP и HTTP протоколами
|
||||
func TestProtocolAuthBehaviorComparison(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 0, // Will use default for protocol
|
||||
}
|
||||
|
||||
t.Log("\n" + strings.Repeat("=", 80))
|
||||
t.Log("PROTOCOL AUTH BEHAVIOR COMPARISON")
|
||||
t.Log(strings.Repeat("=", 80))
|
||||
|
||||
// === RTSP ===
|
||||
t.Log("\n### RTSP Protocol ###")
|
||||
rtspEntry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/ch0",
|
||||
}
|
||||
|
||||
rtspURLs := builder.BuildURLsFromEntry(rtspEntry, ctx)
|
||||
|
||||
t.Logf("\nRTSP with credentials (user=%s, pass=%s):", "admin", "***")
|
||||
t.Logf("Generated: %d URL(s)", len(rtspURLs))
|
||||
for i, url := range rtspURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Check RTSP behavior
|
||||
if len(rtspURLs) != 1 {
|
||||
t.Errorf("❌ RTSP: Expected 1 URL, got %d", len(rtspURLs))
|
||||
}
|
||||
|
||||
hasRTSPAuth := false
|
||||
hasRTSPNoAuth := false
|
||||
for _, url := range rtspURLs {
|
||||
if strings.Contains(url, "@") {
|
||||
hasRTSPAuth = true
|
||||
} else {
|
||||
hasRTSPNoAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasRTSPAuth {
|
||||
t.Error("❌ RTSP: Should have URL WITH auth")
|
||||
}
|
||||
if hasRTSPNoAuth {
|
||||
t.Error("❌ RTSP: Should NOT have URL without auth when credentials provided")
|
||||
}
|
||||
|
||||
if len(rtspURLs) == 1 && hasRTSPAuth && !hasRTSPNoAuth {
|
||||
t.Log("✅ RTSP: Correctly generates ONLY auth URL")
|
||||
}
|
||||
|
||||
// === HTTP ===
|
||||
t.Log("\n### HTTP Protocol ###")
|
||||
httpEntry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi",
|
||||
}
|
||||
|
||||
httpURLs := builder.BuildURLsFromEntry(httpEntry, ctx)
|
||||
|
||||
t.Logf("\nHTTP with credentials (user=%s, pass=%s):", "admin", "***")
|
||||
t.Logf("Generated: %d URL(s)", len(httpURLs))
|
||||
for i, url := range httpURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Check HTTP behavior
|
||||
if len(httpURLs) != 4 {
|
||||
t.Errorf("❌ HTTP: Expected 4 URLs, got %d", len(httpURLs))
|
||||
}
|
||||
|
||||
// Analyze HTTP URLs
|
||||
type authVariant struct {
|
||||
name string
|
||||
found bool
|
||||
url string
|
||||
}
|
||||
|
||||
variants := []authVariant{
|
||||
{name: "No auth", found: false},
|
||||
{name: "Basic auth only", found: false},
|
||||
{name: "Query params only", found: false},
|
||||
{name: "Basic auth + Query params", found: false},
|
||||
}
|
||||
|
||||
for _, url := range httpURLs {
|
||||
hasBasicAuth := strings.Contains(url, "@")
|
||||
hasQueryParams := strings.Contains(url, "?")
|
||||
|
||||
if !hasBasicAuth && !hasQueryParams {
|
||||
variants[0].found = true
|
||||
variants[0].url = url
|
||||
} else if hasBasicAuth && !hasQueryParams {
|
||||
variants[1].found = true
|
||||
variants[1].url = url
|
||||
} else if !hasBasicAuth && hasQueryParams {
|
||||
variants[2].found = true
|
||||
variants[2].url = url
|
||||
} else if hasBasicAuth && hasQueryParams {
|
||||
variants[3].found = true
|
||||
variants[3].url = url
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("\nHTTP Auth variants breakdown:")
|
||||
allFound := true
|
||||
for i, v := range variants {
|
||||
if v.found {
|
||||
t.Logf(" ✅ [%d] %s: %s", i+1, v.name, v.url)
|
||||
} else {
|
||||
t.Errorf(" ❌ [%d] %s: MISSING", i+1, v.name)
|
||||
allFound = false
|
||||
}
|
||||
}
|
||||
|
||||
if allFound {
|
||||
t.Log("\n✅ HTTP: Correctly generates ALL 4 auth variants")
|
||||
} else {
|
||||
t.Error("\n❌ HTTP: Missing some auth variants")
|
||||
}
|
||||
|
||||
// === COMPARISON SUMMARY ===
|
||||
t.Log("\n" + strings.Repeat("=", 80))
|
||||
t.Log("SUMMARY")
|
||||
t.Log(strings.Repeat("=", 80))
|
||||
t.Log("\nRTSP behavior:")
|
||||
t.Log(" • With credentials → 1 URL (WITH auth only)")
|
||||
t.Log(" • Without credentials → 1 URL (NO auth only)")
|
||||
t.Log(" • Rationale: RTSP auth is binary (works or doesn't)")
|
||||
t.Log("")
|
||||
t.Log("HTTP behavior:")
|
||||
t.Log(" • With credentials → 4 URLs:")
|
||||
t.Log(" 1. No auth (try public access)")
|
||||
t.Log(" 2. Basic auth only (user:pass@host)")
|
||||
t.Log(" 3. Query params only (?user=X&pwd=Y)")
|
||||
t.Log(" 4. Both methods combined")
|
||||
t.Log(" • Rationale: Different cameras support different auth methods")
|
||||
t.Log(strings.Repeat("=", 80))
|
||||
}
|
||||
|
||||
// TestRTSPNoAuthWhenNoCredentials проверяет что RTSP без credentials НЕ генерирует auth URL
|
||||
func TestRTSPNoAuthWhenNoCredentials(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
rtspEntry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/main",
|
||||
}
|
||||
|
||||
// Without credentials
|
||||
ctxNoAuth := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(rtspEntry, ctxNoAuth)
|
||||
|
||||
t.Log("\n=== RTSP WITHOUT credentials ===")
|
||||
t.Logf("Generated: %d URL(s)", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
if len(urls) != 1 {
|
||||
t.Errorf("Expected 1 URL, got %d", len(urls))
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
if strings.Contains(urls[0], "@") {
|
||||
t.Error("❌ Should NOT have auth when no credentials provided")
|
||||
} else {
|
||||
t.Log("✅ Correctly generates URL without auth")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPNoAuthWhenNoCredentials проверяет что HTTP без credentials генерирует ТОЛЬКО 1 URL
|
||||
func TestHTTPNoAuthWhenNoCredentials(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
httpEntry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.jpg",
|
||||
}
|
||||
|
||||
// Without credentials
|
||||
ctxNoAuth := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "",
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(httpEntry, ctxNoAuth)
|
||||
|
||||
t.Log("\n=== HTTP WITHOUT credentials ===")
|
||||
t.Logf("Generated: %d URL(s)", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
if len(urls) != 1 {
|
||||
t.Errorf("Expected 1 URL, got %d", len(urls))
|
||||
}
|
||||
|
||||
if len(urls) > 0 {
|
||||
if strings.Contains(urls[0], "@") || strings.Contains(urls[0], "?") {
|
||||
t.Error("❌ Should NOT have auth when no credentials provided")
|
||||
} else {
|
||||
t.Log("✅ Correctly generates URL without auth")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompleteProtocolMatrix проверяет полную матрицу протоколов и credentials
|
||||
func TestCompleteProtocolMatrix(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
type testCase struct {
|
||||
protocol string
|
||||
port int
|
||||
url string
|
||||
withCreds bool
|
||||
expectedURLs int
|
||||
description string
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
// RTSP
|
||||
{
|
||||
protocol: "rtsp",
|
||||
port: 554,
|
||||
url: "/live/ch0",
|
||||
withCreds: true,
|
||||
expectedURLs: 1,
|
||||
description: "RTSP with credentials",
|
||||
},
|
||||
{
|
||||
protocol: "rtsp",
|
||||
port: 554,
|
||||
url: "/live/ch0",
|
||||
withCreds: false,
|
||||
expectedURLs: 1,
|
||||
description: "RTSP without credentials",
|
||||
},
|
||||
// HTTP
|
||||
{
|
||||
protocol: "http",
|
||||
port: 80,
|
||||
url: "snapshot.cgi",
|
||||
withCreds: true,
|
||||
expectedURLs: 4,
|
||||
description: "HTTP with credentials",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
port: 80,
|
||||
url: "snapshot.cgi",
|
||||
withCreds: false,
|
||||
expectedURLs: 1,
|
||||
description: "HTTP without credentials",
|
||||
},
|
||||
// HTTPS
|
||||
{
|
||||
protocol: "https",
|
||||
port: 443,
|
||||
url: "snapshot.cgi",
|
||||
withCreds: true,
|
||||
expectedURLs: 4,
|
||||
description: "HTTPS with credentials",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
port: 443,
|
||||
url: "snapshot.cgi",
|
||||
withCreds: false,
|
||||
expectedURLs: 1,
|
||||
description: "HTTPS without credentials",
|
||||
},
|
||||
}
|
||||
|
||||
t.Log("\n" + strings.Repeat("=", 80))
|
||||
t.Log("COMPLETE PROTOCOL MATRIX")
|
||||
t.Log(strings.Repeat("=", 80))
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: tc.protocol,
|
||||
Port: tc.port,
|
||||
URL: tc.url,
|
||||
}
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Port: tc.port,
|
||||
}
|
||||
|
||||
if tc.withCreds {
|
||||
ctx.Username = "admin"
|
||||
ctx.Password = "12345"
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
t.Logf("Protocol: %s, Creds: %v → Generated: %d URL(s)",
|
||||
tc.protocol, tc.withCreds, len(urls))
|
||||
|
||||
if len(urls) != tc.expectedURLs {
|
||||
t.Errorf("❌ Expected %d URLs, got %d", tc.expectedURLs, len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ Correct: %d URL(s)", len(urls))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Log(strings.Repeat("=", 80))
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// TestRTSPAuthLogic проверяет логику генерации RTSP URL с авторизацией
|
||||
func TestRTSPAuthLogic(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/ch0",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx BuildContext
|
||||
expectedURLCount int
|
||||
shouldHaveNoAuth bool
|
||||
shouldHaveAuth bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "RTSP with credentials - should generate ONLY with auth",
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1,
|
||||
shouldHaveNoAuth: false,
|
||||
shouldHaveAuth: true,
|
||||
description: "When credentials provided, generate ONLY URL with auth",
|
||||
},
|
||||
{
|
||||
name: "RTSP without credentials - should generate ONLY without auth",
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1,
|
||||
shouldHaveNoAuth: true,
|
||||
shouldHaveAuth: false,
|
||||
description: "When NO credentials provided, generate ONLY URL without auth",
|
||||
},
|
||||
{
|
||||
name: "RTSP with only username (no password) - should generate without auth",
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1,
|
||||
shouldHaveNoAuth: true,
|
||||
shouldHaveAuth: false,
|
||||
description: "Username without password = no credentials",
|
||||
},
|
||||
{
|
||||
name: "RTSP with only password (no username) - should generate without auth",
|
||||
ctx: BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "12345",
|
||||
Port: 554,
|
||||
},
|
||||
expectedURLCount: 1,
|
||||
shouldHaveNoAuth: true,
|
||||
shouldHaveAuth: false,
|
||||
description: "Password without username = no credentials",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
urls := builder.BuildURLsFromEntry(entry, tt.ctx)
|
||||
|
||||
t.Logf("\n=== %s ===", tt.description)
|
||||
t.Logf("Context: IP=%s, User=%s, Pass=%s",
|
||||
tt.ctx.IP,
|
||||
maskString(tt.ctx.Username),
|
||||
maskString(tt.ctx.Password))
|
||||
t.Logf("Generated URLs: %d", len(urls))
|
||||
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Check count
|
||||
if len(urls) != tt.expectedURLCount {
|
||||
t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls))
|
||||
}
|
||||
|
||||
// Check for auth presence
|
||||
hasNoAuth := false
|
||||
hasAuth := false
|
||||
|
||||
for _, url := range urls {
|
||||
if containsAuth(url) {
|
||||
hasAuth = true
|
||||
} else {
|
||||
hasNoAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
if tt.shouldHaveNoAuth && !hasNoAuth {
|
||||
t.Errorf("FAILED: Expected URL without auth, but none found")
|
||||
}
|
||||
if !tt.shouldHaveNoAuth && hasNoAuth {
|
||||
t.Errorf("FAILED: Expected NO URL without auth, but found one")
|
||||
}
|
||||
if tt.shouldHaveAuth && !hasAuth {
|
||||
t.Errorf("FAILED: Expected URL with auth, but none found")
|
||||
}
|
||||
if !tt.shouldHaveAuth && hasAuth {
|
||||
t.Errorf("FAILED: Expected NO URL with auth, but found one")
|
||||
}
|
||||
|
||||
t.Logf("✓ Test passed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPAuthLogic проверяет что HTTP НЕ изменился (все 4 варианта)
|
||||
func TestHTTPAuthLogic(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi",
|
||||
}
|
||||
|
||||
t.Log("\n=== HTTP should generate ALL 4 auth variants (unchanged behavior) ===")
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
t.Logf("Generated URLs: %d", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
expectedCount := 4
|
||||
if len(urls) != expectedCount {
|
||||
t.Errorf("FAILED: Expected %d URLs for HTTP, got %d", expectedCount, len(urls))
|
||||
t.Errorf("HTTP auth variant generation should NOT be changed!")
|
||||
} else {
|
||||
t.Log("✓ HTTP still generates 4 auth variants (correct)")
|
||||
}
|
||||
|
||||
// Verify we have different auth methods
|
||||
hasNoAuth := false
|
||||
hasBasicAuth := false
|
||||
hasQueryAuth := false
|
||||
hasBothAuth := false
|
||||
|
||||
for _, url := range urls {
|
||||
hasAuth := containsAuth(url)
|
||||
hasQuery := containsString(url, "?")
|
||||
|
||||
if !hasAuth && !hasQuery {
|
||||
hasNoAuth = true
|
||||
} else if hasAuth && !hasQuery {
|
||||
hasBasicAuth = true
|
||||
} else if !hasAuth && hasQuery {
|
||||
hasQueryAuth = true
|
||||
} else if hasAuth && hasQuery {
|
||||
hasBothAuth = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasNoAuth || !hasBasicAuth || !hasQueryAuth || !hasBothAuth {
|
||||
t.Error("FAILED: HTTP should have all 4 auth variants:")
|
||||
t.Logf(" No auth: %v", hasNoAuth)
|
||||
t.Logf(" Basic auth: %v", hasBasicAuth)
|
||||
t.Logf(" Query auth: %v", hasQueryAuth)
|
||||
t.Logf(" Both: %v", hasBothAuth)
|
||||
} else {
|
||||
t.Log("✓ All 4 HTTP auth variants present (correct)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPSAuthLogic проверяет что HTTPS работает как HTTP
|
||||
func TestHTTPSAuthLogic(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "https",
|
||||
Port: 443,
|
||||
URL: "snapshot.cgi",
|
||||
}
|
||||
|
||||
t.Log("\n=== HTTPS should generate ALL 4 auth variants (same as HTTP) ===")
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 443,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
t.Logf("Generated URLs: %d", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
expectedCount := 4
|
||||
if len(urls) != expectedCount {
|
||||
t.Errorf("FAILED: Expected %d URLs for HTTPS, got %d", expectedCount, len(urls))
|
||||
} else {
|
||||
t.Log("✓ HTTPS generates 4 auth variants (correct)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBUBBLEProtocolUnchanged проверяет что BUBBLE протокол не изменился
|
||||
func TestBUBBLEProtocolUnchanged(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "BUBBLE",
|
||||
Protocol: "bubble",
|
||||
Port: 34567,
|
||||
URL: "/{channel}?stream=0",
|
||||
}
|
||||
|
||||
t.Log("\n=== BUBBLE protocol should remain unchanged ===")
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 34567,
|
||||
Channel: 1,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
t.Logf("Generated URLs: %d", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
if len(urls) < 1 {
|
||||
t.Error("FAILED: BUBBLE should generate at least 1 URL")
|
||||
} else {
|
||||
t.Log("✓ BUBBLE protocol works")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSPDeduplicationAcrossSources проверяет дедупликацию между источниками
|
||||
func TestRTSPDeduplicationAcrossSources(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "12345",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/ch0",
|
||||
}
|
||||
|
||||
t.Log("\n=== RTSP Deduplication: Each source generates ONLY auth URL ===")
|
||||
|
||||
// Source 1: Model patterns
|
||||
modelURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||||
t.Logf("Model patterns: %d URLs", len(modelURLs))
|
||||
for i, url := range modelURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Source 2: Popular patterns
|
||||
popularURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||||
t.Logf("Popular patterns: %d URLs", len(popularURLs))
|
||||
for i, url := range popularURLs {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Source 3: ONVIF (manual simulation - without auth)
|
||||
onvifURL := "rtsp://192.168.1.100:554/live/ch0"
|
||||
t.Logf("ONVIF: 1 URL")
|
||||
t.Logf(" [1] %s", onvifURL)
|
||||
|
||||
// Current deduplication
|
||||
urlMap := make(map[string]bool)
|
||||
var combined []string
|
||||
|
||||
for _, url := range modelURLs {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, url := range popularURLs {
|
||||
if !urlMap[url] {
|
||||
combined = append(combined, url)
|
||||
urlMap[url] = true
|
||||
}
|
||||
}
|
||||
|
||||
if !urlMap[onvifURL] {
|
||||
combined = append(combined, onvifURL)
|
||||
urlMap[onvifURL] = true
|
||||
}
|
||||
|
||||
t.Logf("\nAfter deduplication: %d URLs", len(combined))
|
||||
for i, url := range combined {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
// Verify: should have exactly 2 URLs
|
||||
// 1. From Model/Popular (with auth): rtsp://admin:12345@192.168.1.100/live/ch0
|
||||
// 2. From ONVIF (without auth, with port): rtsp://192.168.1.100:554/live/ch0
|
||||
expectedCount := 2
|
||||
if len(combined) != expectedCount {
|
||||
t.Errorf("FAILED: Expected %d unique URLs, got %d", expectedCount, len(combined))
|
||||
t.Log("Expected:")
|
||||
t.Log(" 1. rtsp://admin:12345@192.168.1.100/live/ch0 (from Model/Popular)")
|
||||
t.Log(" 2. rtsp://192.168.1.100:554/live/ch0 (from ONVIF)")
|
||||
} else {
|
||||
t.Log("✓ Deduplication works correctly")
|
||||
t.Log(" Model/Popular URLs are identical → deduplicated to 1")
|
||||
t.Log(" ONVIF URL is different (has :554 port) → kept as separate")
|
||||
t.Log(" Total: 2 unique URLs (correct!)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSPWithoutCredentialsSingleURL проверяет что без credentials генерируется 1 URL
|
||||
func TestRTSPWithoutCredentialsSingleURL(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/main",
|
||||
}
|
||||
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "",
|
||||
Password: "",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
t.Log("\n=== RTSP without credentials should generate SINGLE URL ===")
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
t.Logf("Generated URLs: %d", len(urls))
|
||||
for i, url := range urls {
|
||||
t.Logf(" [%d] %s", i+1, url)
|
||||
}
|
||||
|
||||
if len(urls) != 1 {
|
||||
t.Errorf("FAILED: Expected 1 URL without credentials, got %d", len(urls))
|
||||
}
|
||||
|
||||
if len(urls) > 0 && containsAuth(urls[0]) {
|
||||
t.Error("FAILED: URL should NOT contain auth when no credentials provided")
|
||||
}
|
||||
|
||||
t.Log("✓ Single URL without auth generated (correct)")
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func containsAuth(url string) bool {
|
||||
// Check for user:pass@ pattern
|
||||
for i := 0; i < len(url)-3; i++ {
|
||||
if url[i:i+3] == "://" {
|
||||
// Found protocol, check for @
|
||||
for j := i + 3; j < len(url); j++ {
|
||||
if url[j] == '@' {
|
||||
return true
|
||||
}
|
||||
if url[j] == '/' {
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && findSubstring(s, substr)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
if len(substr) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(s) < len(substr) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(substr); j++ {
|
||||
if s[i+j] != substr[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maskString(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eduard256/Strix/internal/models"
|
||||
)
|
||||
|
||||
// Passwords with various special characters that real users might use.
|
||||
// Each one exercises a different URL-parsing edge case.
|
||||
var specialPasswords = []struct {
|
||||
name string
|
||||
password string
|
||||
breaking string // which URL component this character breaks without escaping
|
||||
}{
|
||||
{"at sign", "p@ssword", "userinfo delimiter — splits user:pass from host"},
|
||||
{"colon", "p:ssword", "userinfo separator — splits username from password"},
|
||||
{"hash", "p#ssword", "fragment delimiter — truncates everything after it"},
|
||||
{"ampersand", "p&ssword", "query param separator — splits password into two params"},
|
||||
{"equals", "p=ssword", "query value delimiter — corrupts key=value parsing"},
|
||||
{"question mark", "p?ssword", "query start — creates phantom query string"},
|
||||
{"slash", "p/ssword", "path separator — changes URL path structure"},
|
||||
{"percent", "p%ssword", "escape prefix — creates invalid percent-encoding"},
|
||||
{"space", "p ssword", "whitespace — breaks URL parsing entirely"},
|
||||
{"plus", "p+ssword", "query space encoding — decoded as space in query strings"},
|
||||
{"dollar", "p$ssword", "shell/URI special character"},
|
||||
{"exclamation", "p!ssword", "sub-delimiter in RFC 3986"},
|
||||
{"mixed special", "p@ss:w#rd$1&2", "multiple special characters combined"},
|
||||
{"all dangerous", "P@:?#&=+$ !", "all URL-breaking characters at once"},
|
||||
{"url-like", "http://evil", "password that looks like a URL"},
|
||||
{"chinese", "密码test", "unicode characters in password"},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RTSP URL tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRTSP_SpecialCharsInPassword_URLMustBeParseable verifies that RTSP URLs
|
||||
// built with special-character passwords can be parsed back by url.Parse
|
||||
// without losing or corrupting the host, scheme, or userinfo.
|
||||
func TestRTSP_SpecialCharsInPassword_URLMustBeParseable(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/live/main",
|
||||
}
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for i, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
// Scheme must be rtsp
|
||||
if u.Scheme != "rtsp" {
|
||||
t.Errorf("[%d] wrong scheme %q, want \"rtsp\"\n raw URL: %s", i, u.Scheme, rawURL)
|
||||
}
|
||||
|
||||
// Host must be the camera IP, not garbage from a mis-parsed password
|
||||
host := u.Hostname()
|
||||
if host != "192.168.1.100" {
|
||||
t.Errorf("[%d] wrong host %q, want \"192.168.1.100\"\n raw URL: %s", i, host, rawURL)
|
||||
}
|
||||
|
||||
// Password must round-trip correctly
|
||||
if u.User != nil {
|
||||
got, ok := u.User.Password()
|
||||
if !ok {
|
||||
t.Errorf("[%d] password not present in parsed URL\n raw URL: %s", i, rawURL)
|
||||
} else if got != sp.password {
|
||||
t.Errorf("[%d] password mismatch: got %q, want %q\n raw URL: %s", i, got, sp.password, rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Path must start with /live/main
|
||||
if !strings.HasPrefix(u.Path, "/live/main") {
|
||||
t.Errorf("[%d] wrong path %q, want prefix \"/live/main\"\n raw URL: %s", i, u.Path, rawURL)
|
||||
}
|
||||
|
||||
// Fragment must be empty (# in password must not leak)
|
||||
if u.Fragment != "" {
|
||||
t.Errorf("[%d] unexpected fragment %q — '#' in password leaked\n raw URL: %s", i, u.Fragment, rawURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSP_SpecialCharsInPassword_CountUnchanged verifies that the number
|
||||
// of generated URLs does not change based on password content.
|
||||
// A simple password and a complex one should produce the same URL count.
|
||||
func TestRTSP_SpecialCharsInPassword_CountUnchanged(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/stream1",
|
||||
}
|
||||
|
||||
// Baseline: simple password
|
||||
baseCtx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "simple123",
|
||||
Port: 554,
|
||||
}
|
||||
baseURLs := builder.BuildURLsFromEntry(entry, baseCtx)
|
||||
baseCount := len(baseURLs)
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) != baseCount {
|
||||
t.Errorf("URL count changed: simple password produces %d, %q produces %d",
|
||||
baseCount, sp.password, len(urls))
|
||||
t.Logf(" simple URLs: %v", baseURLs)
|
||||
t.Logf(" special URLs: %v", urls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTSP_NormalPassword_NoChange ensures that encoding does not alter URLs
|
||||
// when the password contains only safe characters (letters, digits, - . _ ~).
|
||||
func TestRTSP_NormalPassword_NoChange(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/Streaming/Channels/101",
|
||||
}
|
||||
|
||||
normalPasswords := []string{
|
||||
"admin",
|
||||
"Admin123",
|
||||
"test-password",
|
||||
"hello_world",
|
||||
"dots.in.password",
|
||||
"tilde~ok",
|
||||
"UPPERCASE",
|
||||
"1234567890",
|
||||
}
|
||||
|
||||
for _, pass := range normalPasswords {
|
||||
t.Run(pass, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: pass,
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for _, rawURL := range urls {
|
||||
// Normal passwords must NOT contain any percent-encoding
|
||||
// because all their characters are unreserved.
|
||||
if strings.Contains(rawURL, "%") {
|
||||
t.Errorf("normal password %q was percent-encoded in URL: %s", pass, rawURL)
|
||||
}
|
||||
|
||||
// Must contain the literal password string
|
||||
if !strings.Contains(rawURL, pass) {
|
||||
t.Errorf("URL does not contain literal password %q: %s", pass, rawURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP query string tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHTTP_SpecialCharsInPassword_QueryPlaceholders tests URLs where
|
||||
// the password goes into a query parameter via [PASSWORD] placeholder.
|
||||
// These are patterns like "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]".
|
||||
func TestHTTP_SpecialCharsInPassword_QueryPlaceholders(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]",
|
||||
}
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for i, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
// Host must be correct
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL)
|
||||
}
|
||||
|
||||
// Fragment must be empty
|
||||
if u.Fragment != "" {
|
||||
t.Errorf("[%d] fragment leak %q — '#' in password broke URL\n raw URL: %s",
|
||||
i, u.Fragment, rawURL)
|
||||
}
|
||||
|
||||
// If URL has query params, check pwd round-trips
|
||||
q := u.Query()
|
||||
if pwd := q.Get("pwd"); pwd != "" {
|
||||
if pwd != sp.password {
|
||||
t.Errorf("[%d] pwd param mismatch: got %q, want %q\n raw URL: %s",
|
||||
i, pwd, sp.password, rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Ampersand in password must NOT create extra query params
|
||||
// e.g. password "p&ssword" must not produce key "ssword"
|
||||
if strings.Contains(sp.password, "&") {
|
||||
// Extract the part after & as potential rogue key
|
||||
parts := strings.SplitN(sp.password, "&", 2)
|
||||
rogueKey := strings.SplitN(parts[1], "&", 2)[0]
|
||||
rogueKey = strings.SplitN(rogueKey, "=", 2)[0]
|
||||
if rogueKey != "" && q.Has(rogueKey) {
|
||||
t.Errorf("[%d] ampersand in password created rogue query param %q\n raw URL: %s",
|
||||
i, rogueKey, rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP_SpecialCharsInPassword_PathPlaceholders tests patterns where
|
||||
// credentials appear in the URL path, e.g.
|
||||
// "/user=[USERNAME]_password=[PASSWORD]_channel=1_stream=0.sdp"
|
||||
func TestHTTP_SpecialCharsInPassword_PathPlaceholders(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/user=[USERNAME]_password=[PASSWORD]_channel=1_stream=0.sdp",
|
||||
}
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for i, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
// Host must be correct
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Errorf("[%d] wrong host %q, want \"192.168.1.100\"\n raw URL: %s",
|
||||
i, u.Hostname(), rawURL)
|
||||
}
|
||||
|
||||
// Scheme must be rtsp
|
||||
if u.Scheme != "rtsp" {
|
||||
t.Errorf("[%d] wrong scheme %q, want \"rtsp\"\n raw URL: %s",
|
||||
i, u.Scheme, rawURL)
|
||||
}
|
||||
|
||||
// Fragment must be empty
|
||||
if u.Fragment != "" {
|
||||
t.Errorf("[%d] fragment leak %q\n raw URL: %s", i, u.Fragment, rawURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP_SpecialCharsInPassword_UserInfo tests HTTP URLs where
|
||||
// credentials are embedded in the userinfo part (user:pass@host).
|
||||
func TestHTTP_SpecialCharsInPassword_UserInfo(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.jpg",
|
||||
}
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for i, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
// Host must be correct
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL)
|
||||
}
|
||||
|
||||
// If userinfo present, password must round-trip
|
||||
if u.User != nil {
|
||||
if got, ok := u.User.Password(); ok {
|
||||
if got != sp.password {
|
||||
t.Errorf("[%d] userinfo password mismatch: got %q, want %q\n raw URL: %s",
|
||||
i, got, sp.password, rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fragment must be empty
|
||||
if u.Fragment != "" {
|
||||
t.Errorf("[%d] fragment leak %q\n raw URL: %s", i, u.Fragment, rawURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTP_SpecialCharsInPassword_CountUnchanged ensures HTTP URL count
|
||||
// stays the same regardless of password content.
|
||||
func TestHTTP_SpecialCharsInPassword_CountUnchanged(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.jpg",
|
||||
}
|
||||
|
||||
baseCtx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: "simple123",
|
||||
Port: 80,
|
||||
}
|
||||
baseURLs := builder.BuildURLsFromEntry(entry, baseCtx)
|
||||
baseCount := len(baseURLs)
|
||||
|
||||
for _, sp := range specialPasswords {
|
||||
t.Run(sp.name, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: sp.password,
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) != baseCount {
|
||||
t.Errorf("URL count changed: simple=%d, special(%q)=%d",
|
||||
baseCount, sp.password, len(urls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Username special char tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSpecialCharsInUsername verifies that usernames with special characters
|
||||
// are also handled correctly (less common but possible).
|
||||
func TestSpecialCharsInUsername(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "FFMPEG",
|
||||
Protocol: "rtsp",
|
||||
Port: 554,
|
||||
URL: "/stream1",
|
||||
}
|
||||
|
||||
specialUsernames := []string{
|
||||
"user@domain",
|
||||
"user:name",
|
||||
"user#1",
|
||||
"admin&root",
|
||||
}
|
||||
|
||||
for _, username := range specialUsernames {
|
||||
t.Run(username, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: username,
|
||||
Password: "password123",
|
||||
Port: 554,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
if len(urls) == 0 {
|
||||
t.Fatal("no URLs generated")
|
||||
}
|
||||
|
||||
for i, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] url.Parse failed: %v\n raw URL: %s", i, err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Errorf("[%d] wrong host %q\n raw URL: %s", i, u.Hostname(), rawURL)
|
||||
}
|
||||
|
||||
if u.User != nil {
|
||||
if got := u.User.Username(); got != username {
|
||||
t.Errorf("[%d] username mismatch: got %q, want %q\n raw URL: %s",
|
||||
i, got, username, rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: normal passwords must not be affected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHTTP_NormalPassword_NoPercentEncoding ensures that simple passwords
|
||||
// do not get percent-encoded in the userinfo part, so we don't break
|
||||
// cameras that might do byte-level comparison.
|
||||
func TestHTTP_NormalPassword_NoPercentEncoding(t *testing.T) {
|
||||
logger := &mockLogger{}
|
||||
builder := NewBuilder([]string{}, logger)
|
||||
|
||||
entry := models.CameraEntry{
|
||||
Type: "JPEG",
|
||||
Protocol: "http",
|
||||
Port: 80,
|
||||
URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]",
|
||||
}
|
||||
|
||||
normalPasswords := []string{
|
||||
"admin123",
|
||||
"Password",
|
||||
"test-pass",
|
||||
"hello_world",
|
||||
"dots.dots",
|
||||
"tilde~ok",
|
||||
}
|
||||
|
||||
for _, pass := range normalPasswords {
|
||||
t.Run(pass, func(t *testing.T) {
|
||||
ctx := BuildContext{
|
||||
IP: "192.168.1.100",
|
||||
Username: "admin",
|
||||
Password: pass,
|
||||
Port: 80,
|
||||
}
|
||||
|
||||
urls := builder.BuildURLsFromEntry(entry, ctx)
|
||||
|
||||
for _, rawURL := range urls {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Errorf("url.Parse failed: %v\n URL: %s", err, rawURL)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check query params: value must match exactly.
|
||||
// Skip URLs where pwd is empty (the no-auth variant).
|
||||
q := u.Query()
|
||||
if pwd := q.Get("pwd"); pwd != "" && pwd != pass {
|
||||
t.Errorf("pwd param %q != expected %q\n URL: %s", pwd, pass, rawURL)
|
||||
}
|
||||
|
||||
// Only check raw query encoding on URLs that actually have
|
||||
// the password in query params (skip no-auth and userinfo-only variants).
|
||||
if q.Get("pwd") != "" && !strings.Contains(u.RawQuery, pass) {
|
||||
t.Errorf("safe password %q was percent-encoded in query: %s\n URL: %s",
|
||||
pass, u.RawQuery, rawURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
// Tester validates stream URLs
|
||||
type Tester struct {
|
||||
httpClient *http.Client
|
||||
ffprobeTimeout time.Duration
|
||||
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
||||
}
|
||||
|
||||
// NewTester creates a new stream tester
|
||||
func NewTester(ffprobeTimeout time.Duration, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Tester {
|
||||
return &Tester{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
ffprobeTimeout: ffprobeTimeout,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// TestResult contains the test results for a stream
|
||||
type TestResult struct {
|
||||
URL string
|
||||
Working bool
|
||||
Protocol string
|
||||
Type string
|
||||
Resolution string
|
||||
Codec string
|
||||
FPS int
|
||||
Bitrate int
|
||||
HasAudio bool
|
||||
Error string
|
||||
TestTime time.Duration
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// validateHTTPStream validates the HTTP response as a valid stream
|
||||
func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
result.Metadata["content_type"] = contentType
|
||||
urlPath := strings.ToLower(resp.Request.URL.Path)
|
||||
|
||||
t.logger.Debug("validating HTTP stream",
|
||||
"url", resp.Request.URL.String(),
|
||||
"content_type", contentType,
|
||||
"status_code", resp.StatusCode)
|
||||
|
||||
// Read first bytes to check magic bytes (up to 512 bytes for MJPEG boundary detection)
|
||||
buffer := make([]byte, 512)
|
||||
n, _ := resp.Body.Read(buffer)
|
||||
|
||||
// Check for JPEG magic bytes (FF D8 FF)
|
||||
hasJPEGMagic := n >= 3 && buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF
|
||||
// Check for MJPEG boundary
|
||||
hasMJPEGBoundary := n > 0 && bytes.Contains(buffer[:n], []byte("--"))
|
||||
|
||||
t.logger.Debug("stream content analysis",
|
||||
"bytes_read", n,
|
||||
"has_jpeg_magic", hasJPEGMagic,
|
||||
"has_mjpeg_boundary", hasMJPEGBoundary)
|
||||
|
||||
// 1. Check for BUBBLE protocol (highest priority for this specific type)
|
||||
if contentType == "video/bubble" {
|
||||
result.Type = "BUBBLE"
|
||||
result.Working = true
|
||||
|
||||
// Extract stream type from full URL (not just path) for metadata
|
||||
fullURL := resp.Request.URL.String()
|
||||
if strings.Contains(fullURL, "stream=1") {
|
||||
result.Metadata["stream_type"] = "sub"
|
||||
} else {
|
||||
result.Metadata["stream_type"] = "main"
|
||||
}
|
||||
|
||||
t.logger.Debug("detected BUBBLE stream", "stream_type", result.Metadata["stream_type"], "url", fullURL)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check Content-Type for multipart (MJPEG)
|
||||
if strings.Contains(contentType, "multipart") {
|
||||
result.Type = "MJPEG"
|
||||
result.Working = hasMJPEGBoundary
|
||||
if !hasMJPEGBoundary {
|
||||
result.Error = "no MJPEG boundary found"
|
||||
}
|
||||
t.logger.Debug("detected MJPEG by content-type", "working", result.Working)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Check for JPEG by magic bytes (most reliable)
|
||||
if hasJPEGMagic {
|
||||
// Verify it's not MJPEG
|
||||
if hasMJPEGBoundary {
|
||||
result.Type = "MJPEG"
|
||||
result.Working = true
|
||||
t.logger.Debug("detected MJPEG by magic bytes and boundary")
|
||||
} else {
|
||||
result.Type = "JPEG"
|
||||
result.Working = true
|
||||
t.logger.Debug("detected JPEG by magic bytes")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Check Content-Type for image/jpeg
|
||||
if strings.Contains(contentType, "image/jpeg") || strings.Contains(contentType, "image/jpg") {
|
||||
result.Type = "JPEG"
|
||||
result.Working = true
|
||||
t.logger.Debug("detected JPEG by content-type")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Check URL patterns for JPEG (fallback for cameras with wrong Content-Type)
|
||||
jpegPatterns := []string{".jpg", ".jpeg", "snapshot", "image", "picture", "snap", "photo", "capture"}
|
||||
for _, pattern := range jpegPatterns {
|
||||
if strings.Contains(urlPath, pattern) {
|
||||
result.Type = "JPEG"
|
||||
result.Working = true
|
||||
t.logger.Debug("detected JPEG by URL pattern", "pattern", pattern, "url", urlPath)
|
||||
result.Metadata["detection_method"] = "url_pattern"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for MJPEG by extension
|
||||
if strings.Contains(urlPath, ".mjpg") || strings.Contains(urlPath, ".mjpeg") {
|
||||
result.Type = "MJPEG"
|
||||
result.Working = true
|
||||
t.logger.Debug("detected MJPEG by URL extension")
|
||||
return
|
||||
}
|
||||
|
||||
// 7. Check for HLS
|
||||
if strings.Contains(urlPath, ".m3u8") ||
|
||||
strings.Contains(contentType, "application/vnd.apple.mpegurl") ||
|
||||
strings.Contains(contentType, "application/x-mpegurl") {
|
||||
result.Type = "HLS"
|
||||
result.Working = true
|
||||
return
|
||||
}
|
||||
|
||||
// 8. Check for MPEG-DASH
|
||||
if strings.Contains(urlPath, ".mpd") || strings.Contains(contentType, "application/dash+xml") {
|
||||
result.Type = "MPEG-DASH"
|
||||
result.Working = true
|
||||
return
|
||||
}
|
||||
|
||||
// 9. Check for video content type
|
||||
if strings.Contains(contentType, "video") {
|
||||
result.Type = "HTTP_VIDEO"
|
||||
result.Working = true
|
||||
return
|
||||
}
|
||||
|
||||
// 10. Check for web interface
|
||||
if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "text/plain") {
|
||||
result.Working = false
|
||||
result.Error = "web interface, not a video stream"
|
||||
return
|
||||
}
|
||||
|
||||
// 11. Unknown - but still working if we got 200 OK
|
||||
result.Type = "HTTP_UNKNOWN"
|
||||
result.Working = true
|
||||
result.Metadata["note"] = "unknown content type, may still be valid"
|
||||
}
|
||||
|
||||
// TestStream tests if a stream URL is working
|
||||
func (t *Tester) TestStream(ctx context.Context, streamURL string) TestResult {
|
||||
startTime := time.Now()
|
||||
|
||||
result := TestResult{
|
||||
URL: streamURL,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
// Parse URL to determine protocol
|
||||
u, err := url.Parse(streamURL)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("invalid URL: %v", err)
|
||||
result.TestTime = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Protocol = u.Scheme
|
||||
|
||||
// Test based on protocol
|
||||
switch u.Scheme {
|
||||
case "rtsp", "rtsps":
|
||||
t.testRTSP(ctx, streamURL, &result)
|
||||
case "http", "https":
|
||||
t.testHTTP(ctx, streamURL, &result)
|
||||
default:
|
||||
result.Error = fmt.Sprintf("unsupported protocol: %s", u.Scheme)
|
||||
}
|
||||
|
||||
result.TestTime = time.Since(startTime)
|
||||
return result
|
||||
}
|
||||
|
||||
// testRTSP tests an RTSP stream using ffprobe
|
||||
func (t *Tester) testRTSP(ctx context.Context, streamURL string, result *TestResult) {
|
||||
// Build ffprobe command
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Use URL as-is - credentials already embedded if needed
|
||||
args := []string{
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-rtsp_transport", "tcp",
|
||||
streamURL,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
|
||||
|
||||
// Capture output
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
t.logger.Debug("testing RTSP stream", "url", streamURL)
|
||||
|
||||
// Execute command
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
result.Error = "timeout while testing stream"
|
||||
} else {
|
||||
result.Error = fmt.Sprintf("ffprobe failed: %v", err)
|
||||
if stderr.Len() > 0 {
|
||||
result.Error += fmt.Sprintf(" (stderr: %s)", stderr.String())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse ffprobe output
|
||||
var probeResult struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(stdout.Bytes(), &probeResult); err != nil {
|
||||
result.Error = fmt.Sprintf("failed to parse ffprobe output: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract stream information
|
||||
result.Working = len(probeResult.Streams) > 0
|
||||
result.Type = "FFMPEG"
|
||||
|
||||
for _, stream := range probeResult.Streams {
|
||||
switch stream.CodecType {
|
||||
case "video":
|
||||
result.Codec = stream.CodecName
|
||||
result.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
|
||||
|
||||
// Parse frame rate
|
||||
if stream.AvgFrameRate != "" {
|
||||
parts := strings.Split(stream.AvgFrameRate, "/")
|
||||
if len(parts) == 2 {
|
||||
// Calculate FPS from fraction
|
||||
var num, den int
|
||||
if n, _ := fmt.Sscanf(parts[0], "%d", &num); n == 1 {
|
||||
if n, _ := fmt.Sscanf(parts[1], "%d", &den); n == 1 && den > 0 {
|
||||
result.FPS = num / den
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse bitrate
|
||||
if stream.BitRate != "" {
|
||||
_, _ = fmt.Sscanf(stream.BitRate, "%d", &result.Bitrate)
|
||||
}
|
||||
case "audio":
|
||||
result.HasAudio = true
|
||||
}
|
||||
}
|
||||
|
||||
// Use format bitrate if stream bitrate not available
|
||||
if result.Bitrate == 0 && probeResult.Format.BitRate != "" {
|
||||
_, _ = fmt.Sscanf(probeResult.Format.BitRate, "%d", &result.Bitrate)
|
||||
}
|
||||
|
||||
if !result.Working {
|
||||
result.Error = "no streams found"
|
||||
}
|
||||
}
|
||||
|
||||
// testHTTP tests an HTTP stream
|
||||
func (t *Tester) testHTTP(ctx context.Context, streamURL string, result *TestResult) {
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract credentials from URL if present
|
||||
u, _ := url.Parse(streamURL)
|
||||
if u.User != nil {
|
||||
username := u.User.Username()
|
||||
password, _ := u.User.Password()
|
||||
if username != "" && password != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
// Remove credentials from URL for logging
|
||||
u.User = nil
|
||||
streamURL = u.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Add headers
|
||||
req.Header.Set("User-Agent", "Strix/1.0")
|
||||
|
||||
t.logger.Debug("testing HTTP stream", "url", streamURL)
|
||||
|
||||
// Send request
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("HTTP request failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
|
||||
// Special handling for 401
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
result.Error = "authentication required"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use validateHTTPStream to determine stream type
|
||||
t.validateHTTPStream(resp, result)
|
||||
|
||||
// Try to probe with ffprobe for HTTP_VIDEO type for more details
|
||||
if result.Type == "HTTP_VIDEO" && result.Working {
|
||||
t.probeHTTPVideo(ctx, streamURL, result)
|
||||
}
|
||||
}
|
||||
|
||||
// probeHTTPVideo uses ffprobe to get more details about HTTP video stream
|
||||
func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL string, result *TestResult) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Use URL as-is - credentials already in URL if needed
|
||||
|
||||
args := []string{
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
streamURL,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "ffprobe", args...)
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err == nil {
|
||||
var probeResult struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
if json.Unmarshal(stdout.Bytes(), &probeResult) == nil {
|
||||
for _, stream := range probeResult.Streams {
|
||||
if stream.CodecType == "video" {
|
||||
result.Codec = stream.CodecName
|
||||
result.Resolution = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiple tests multiple URLs concurrently
|
||||
func (t *Tester) TestMultiple(ctx context.Context, urls []string, maxConcurrent int) []TestResult {
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = 10
|
||||
}
|
||||
|
||||
results := make([]TestResult, len(urls))
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
for i, url := range urls {
|
||||
i, url := i, url // Capture for goroutine
|
||||
|
||||
sem <- struct{}{} // Acquire semaphore
|
||||
go func() {
|
||||
defer func() { <-sem }() // Release semaphore
|
||||
|
||||
results[i] = t.TestStream(ctx, url)
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
for i := 0; i < maxConcurrent; i++ {
|
||||
sem <- struct{}{}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// IsFFProbeAvailable checks if ffprobe is available
|
||||
func (t *Tester) IsFFProbeAvailable() bool {
|
||||
cmd := exec.Command("ffprobe", "-version")
|
||||
err := cmd.Run()
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/Strix/internal/utils/logger"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds application configuration
|
||||
type Config struct {
|
||||
Version string // Application version, set by caller after Load()
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Scanner ScannerConfig
|
||||
Logger LoggerConfig
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server settings
|
||||
type ServerConfig struct {
|
||||
Listen string // Address to listen on (e.g., ":4567" or "0.0.0.0:4567")
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
}
|
||||
|
||||
// DatabaseConfig contains database settings
|
||||
type DatabaseConfig struct {
|
||||
DataPath string
|
||||
BrandsPath string
|
||||
PatternsPath string
|
||||
ParametersPath string
|
||||
OUIPath string
|
||||
CacheEnabled bool
|
||||
CacheTTL time.Duration
|
||||
}
|
||||
|
||||
// ScannerConfig contains stream scanner settings
|
||||
type ScannerConfig struct {
|
||||
DefaultTimeout time.Duration
|
||||
MaxStreams int
|
||||
ModelSearchLimit int
|
||||
WorkerPoolSize int
|
||||
FFProbeTimeout time.Duration
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
// Validation settings
|
||||
StrictValidation bool // Enable strict validation mode
|
||||
MinImageSize int // Minimum bytes for valid image (JPEG/PNG)
|
||||
MinVideoStreams int // Minimum video streams required
|
||||
}
|
||||
|
||||
// LoggerConfig contains logging settings
|
||||
type LoggerConfig struct {
|
||||
Level string
|
||||
Format string // "text" or "json"
|
||||
}
|
||||
|
||||
// yamlConfig represents the structure of strix.yaml
|
||||
type yamlConfig struct {
|
||||
API struct {
|
||||
Listen string `yaml:"listen"`
|
||||
} `yaml:"api"`
|
||||
}
|
||||
|
||||
// Load returns configuration with defaults. The provided logger is used for
|
||||
// all startup messages so that output format stays consistent (JSON or text)
|
||||
// with the rest of the application logs.
|
||||
func Load(log *slog.Logger) *Config {
|
||||
dataPath := getEnv("STRIX_DATA_PATH", "./data")
|
||||
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{
|
||||
Listen: ":4567", // Default listen address
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute, // Increased for SSE long-polling
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
DataPath: dataPath,
|
||||
BrandsPath: filepath.Join(dataPath, "brands"),
|
||||
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
|
||||
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
|
||||
OUIPath: filepath.Join(dataPath, "camera_oui.json"),
|
||||
CacheEnabled: true,
|
||||
CacheTTL: 5 * time.Minute,
|
||||
},
|
||||
Scanner: ScannerConfig{
|
||||
DefaultTimeout: 4 * time.Minute,
|
||||
MaxStreams: 10,
|
||||
ModelSearchLimit: 6,
|
||||
WorkerPoolSize: 20,
|
||||
FFProbeTimeout: 30 * time.Second,
|
||||
RetryAttempts: 2,
|
||||
RetryDelay: 500 * time.Millisecond,
|
||||
// Strict validation enabled by default
|
||||
StrictValidation: true,
|
||||
MinImageSize: 5120, // 5KB minimum for valid images
|
||||
MinVideoStreams: 1, // At least 1 video stream required
|
||||
},
|
||||
Logger: LoggerConfig{
|
||||
Level: getEnv("STRIX_LOG_LEVEL", "info"),
|
||||
Format: getEnv("STRIX_LOG_FORMAT", "json"),
|
||||
},
|
||||
}
|
||||
|
||||
// Load from Home Assistant options.json if running as HA add-on
|
||||
// Priority: defaults < HA options < strix.yaml < ENV
|
||||
configSource := "default"
|
||||
if err := loadHAOptions(cfg); err == nil {
|
||||
configSource = "/data/options.json (Home Assistant)"
|
||||
}
|
||||
|
||||
// Load from strix.yaml if exists (overrides HA options)
|
||||
if err := loadYAML(cfg); err == nil {
|
||||
configSource = "strix.yaml"
|
||||
}
|
||||
|
||||
// Environment variable overrides everything
|
||||
if envListen := os.Getenv("STRIX_API_LISTEN"); envListen != "" {
|
||||
cfg.Server.Listen = envListen
|
||||
configSource = "environment variable STRIX_API_LISTEN"
|
||||
}
|
||||
|
||||
// Validate listen address
|
||||
if err := validateListen(cfg.Server.Listen); err != nil {
|
||||
log.Error("invalid listen address, using default :4567",
|
||||
slog.String("address", cfg.Server.Listen),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
cfg.Server.Listen = ":4567"
|
||||
configSource = "default (validation failed)"
|
||||
}
|
||||
|
||||
// Log configuration source
|
||||
log.Info("configuration loaded",
|
||||
slog.String("listen", cfg.Server.Listen),
|
||||
slog.String("source", configSource),
|
||||
)
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// loadYAML attempts to load configuration from strix.yaml
|
||||
func loadYAML(cfg *Config) error {
|
||||
data, err := os.ReadFile("./strix.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var yamlCfg yamlConfig
|
||||
if err := yaml.Unmarshal(data, &yamlCfg); err != nil {
|
||||
return fmt.Errorf("failed to parse strix.yaml: %w", err)
|
||||
}
|
||||
|
||||
// Apply yaml configuration
|
||||
if yamlCfg.API.Listen != "" {
|
||||
cfg.Server.Listen = yamlCfg.API.Listen
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// haOptions represents the structure of Home Assistant /data/options.json.
|
||||
// When Strix runs as a Home Assistant add-on, HA creates this file from the
|
||||
// add-on configuration UI. Fields are optional -- zero values are ignored.
|
||||
type haOptions struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
// loadHAOptions loads configuration from Home Assistant's /data/options.json.
|
||||
// This file only exists when running inside the HA add-on environment.
|
||||
// Returns an error if the file doesn't exist or can't be parsed (callers
|
||||
// should treat errors as "not running in HA" and silently continue).
|
||||
func loadHAOptions(cfg *Config) error {
|
||||
data, err := os.ReadFile("/data/options.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var opts haOptions
|
||||
if err := json.Unmarshal(data, &opts); err != nil {
|
||||
return fmt.Errorf("failed to parse /data/options.json: %w", err)
|
||||
}
|
||||
|
||||
if opts.LogLevel != "" {
|
||||
cfg.Logger.Level = opts.LogLevel
|
||||
}
|
||||
if opts.Port > 0 {
|
||||
cfg.Server.Listen = fmt.Sprintf(":%d", opts.Port)
|
||||
}
|
||||
|
||||
// Home Assistant add-on always uses JSON logging for the HA log viewer
|
||||
cfg.Logger.Format = "json"
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateListen validates the listen address format and port range
|
||||
func validateListen(listen string) error {
|
||||
if listen == "" {
|
||||
return fmt.Errorf("listen address cannot be empty")
|
||||
}
|
||||
|
||||
// Parse the listen address
|
||||
parts := strings.Split(listen, ":")
|
||||
if len(parts) < 2 {
|
||||
return fmt.Errorf("invalid format, expected ':port' or 'host:port', got '%s'", listen)
|
||||
}
|
||||
|
||||
// Get port (last part)
|
||||
portStr := parts[len(parts)-1]
|
||||
if portStr == "" {
|
||||
return fmt.Errorf("port cannot be empty")
|
||||
}
|
||||
|
||||
// Validate port number
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port number '%s': %w", portStr, err)
|
||||
}
|
||||
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("port %d out of valid range (1-65535)", port)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupLogger creates the application logger by reading log configuration
|
||||
// from environment variables and Home Assistant options. It must be called
|
||||
// before Load() so that all startup messages use a consistent output format.
|
||||
//
|
||||
// Configuration priority: defaults < HA options < environment variables.
|
||||
func SetupLogger() (*slog.Logger, *logger.SecretStore) {
|
||||
// Read log settings from environment (same defaults as Config)
|
||||
logLevel := getEnv("STRIX_LOG_LEVEL", "info")
|
||||
logFormat := getEnv("STRIX_LOG_FORMAT", "json")
|
||||
|
||||
// Apply Home Assistant overrides if running as HA add-on
|
||||
if data, err := os.ReadFile("/data/options.json"); err == nil {
|
||||
var opts haOptions
|
||||
if err := json.Unmarshal(data, &opts); err == nil {
|
||||
if opts.LogLevel != "" {
|
||||
logLevel = opts.LogLevel
|
||||
}
|
||||
// Home Assistant add-on always uses JSON logging for the HA log viewer
|
||||
logFormat = "json"
|
||||
}
|
||||
}
|
||||
|
||||
var level slog.Level
|
||||
switch logLevel {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
default:
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
handlerOpts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if logFormat == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, handlerOpts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(os.Stdout, handlerOpts)
|
||||
}
|
||||
|
||||
secrets := logger.NewSecretStore()
|
||||
maskedHandler := logger.NewSecretMaskingHandler(handler, secrets)
|
||||
|
||||
return slog.New(maskedHandler), secrets
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/eduard256/strix/internal/api"
|
||||
"github.com/eduard256/strix/internal/app"
|
||||
gen "github.com/eduard256/strix/pkg/generate"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("generate")
|
||||
|
||||
api.HandleFunc("api/generate", apiGenerate)
|
||||
}
|
||||
|
||||
func apiGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req gen.Request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := gen.Generate(&req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponseJSON(w, resp)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Camera represents a camera model from the database
|
||||
type Camera struct {
|
||||
Brand string `json:"brand"`
|
||||
BrandID string `json:"brand_id"`
|
||||
Model string `json:"model"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
Source string `json:"source"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Entries []CameraEntry `json:"entries"`
|
||||
MatchScore float64 `json:"match_score,omitempty"`
|
||||
}
|
||||
|
||||
// CameraEntry represents a URL pattern entry for a camera
|
||||
type CameraEntry struct {
|
||||
Models []string `json:"models"`
|
||||
Type string `json:"type"` // FFMPEG, MJPEG, JPEG, VLC, H264
|
||||
Protocol string `json:"protocol"` // rtsp, http, https
|
||||
Port int `json:"port"`
|
||||
URL string `json:"url"`
|
||||
AuthRequired bool `json:"auth_required,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// StreamPattern represents a popular stream pattern
|
||||
type StreamPattern struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
Notes string `json:"notes"`
|
||||
ModelCount int `json:"model_count"`
|
||||
}
|
||||
|
||||
// CameraSearchRequest represents a search request for cameras
|
||||
type CameraSearchRequest struct {
|
||||
Query string `json:"query" validate:"required,min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
// CameraSearchResponse represents the response for camera search
|
||||
type CameraSearchResponse struct {
|
||||
Cameras []Camera `json:"cameras"`
|
||||
Total int `json:"total"`
|
||||
Returned int `json:"returned"`
|
||||
}
|
||||
|
||||
// StreamDiscoveryRequest represents a request to discover streams
|
||||
type StreamDiscoveryRequest struct {
|
||||
Model string `json:"model"` // Camera model name
|
||||
ModelLimit int `json:"model_limit" validate:"min=1,max=20"` // Max models to search
|
||||
Timeout int `json:"timeout" validate:"min=10,max=600"` // Timeout in seconds
|
||||
MaxStreams int `json:"max_streams" validate:"min=1,max=50"` // Max streams to find
|
||||
Target string `json:"target" validate:"required"` // IP or stream URL
|
||||
Channel int `json:"channel" validate:"min=0,max=255"` // Channel number
|
||||
Username string `json:"username"` // Optional username
|
||||
Password string `json:"password"` // Optional password
|
||||
}
|
||||
|
||||
// DiscoveredStream represents a discovered stream
|
||||
type DiscoveredStream struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"` // RTSP, HTTP, MJPEG, etc
|
||||
Protocol string `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
Working bool `json:"working"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
FPS int `json:"fps,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"`
|
||||
HasAudio bool `json:"has_audio"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TestTime time.Duration `json:"test_time_ms"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// SSEMessage represents a Server-Sent Event message
|
||||
type SSEMessage struct {
|
||||
Type string `json:"type"` // stream_found, progress, error, complete
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Stream *DiscoveredStream `json:"stream,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ProgressMessage for SSE progress updates
|
||||
type ProgressMessage struct {
|
||||
Tested int `json:"tested"`
|
||||
Found int `json:"found"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
|
||||
// CompleteMessage for SSE completion
|
||||
type CompleteMessage struct {
|
||||
TotalTested int `json:"total_tested"`
|
||||
TotalFound int `json:"total_found"`
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package models
|
||||
|
||||
// ProbeResponse represents the result of probing an IP address.
|
||||
// The Type field determines which UI flow the frontend should use:
|
||||
// - "unreachable" -- device did not respond to ping
|
||||
// - "standard" -- normal IP camera (RTSP/HTTP/ONVIF)
|
||||
// - "homekit" -- Apple HomeKit camera (needs PIN pairing)
|
||||
type ProbeResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Reachable bool `json:"reachable"`
|
||||
LatencyMs float64 `json:"latency_ms,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Probes ProbeResults `json:"probes"`
|
||||
}
|
||||
|
||||
// ProbeResults contains results from all parallel probers.
|
||||
// Nil fields mean the prober did not find anything or timed out.
|
||||
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.
|
||||
type DNSProbeResult struct {
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// ARPProbeResult contains ARP table lookup + OUI vendor identification.
|
||||
type ARPProbeResult struct {
|
||||
MAC string `json:"mac"`
|
||||
Vendor string `json:"vendor"`
|
||||
}
|
||||
|
||||
// MDNSProbeResult contains mDNS service discovery result (HomeKit).
|
||||
type MDNSProbeResult 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"`
|
||||
Feature string `json:"feature"`
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/strix/internal/api"
|
||||
"github.com/eduard256/strix/internal/app"
|
||||
"github.com/eduard256/strix/pkg/probe"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const probeTimeout = 100 * time.Millisecond
|
||||
|
||||
var log zerolog.Logger
|
||||
var db *sql.DB
|
||||
var ports []int
|
||||
var hasICMP bool
|
||||
|
||||
var detectors []func(*probe.Response) string
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("probe")
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", app.DB+"?mode=ro")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("[probe] db open")
|
||||
}
|
||||
|
||||
ports = loadPorts()
|
||||
hasICMP = probe.CanICMP()
|
||||
|
||||
if hasICMP {
|
||||
log.Info().Msg("[probe] ICMP available")
|
||||
} else {
|
||||
log.Info().Msg("[probe] ICMP not available, using port scan only")
|
||||
}
|
||||
|
||||
// HomeKit detector
|
||||
detectors = append(detectors, func(r *probe.Response) string {
|
||||
if r.Probes.MDNS != nil && !r.Probes.MDNS.Paired {
|
||||
if r.Probes.MDNS.Category == "camera" || r.Probes.MDNS.Category == "doorbell" {
|
||||
return "homekit"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
api.HandleFunc("api/probe", apiProbe)
|
||||
}
|
||||
|
||||
func apiProbe(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
if ip == "" {
|
||||
http.Error(w, "missing ip parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if net.ParseIP(ip) == nil {
|
||||
http.Error(w, "invalid ip: "+ip, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result := runProbe(r.Context(), ip)
|
||||
api.ResponseJSON(w, result)
|
||||
}
|
||||
|
||||
func runProbe(parent context.Context, ip string) *probe.Response {
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp := &probe.Response{IP: ip}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
run := func(fn func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
run(func() {
|
||||
r, _ := probe.ScanPorts(ctx, ip, ports)
|
||||
mu.Lock()
|
||||
resp.Probes.Ports = r
|
||||
mu.Unlock()
|
||||
})
|
||||
run(func() {
|
||||
r, _ := probe.ReverseDNS(ctx, ip)
|
||||
mu.Lock()
|
||||
resp.Probes.DNS = r
|
||||
mu.Unlock()
|
||||
})
|
||||
run(func() {
|
||||
mac := probe.LookupARP(ip)
|
||||
if mac == "" {
|
||||
return
|
||||
}
|
||||
vendor := probe.LookupOUI(db, mac)
|
||||
mu.Lock()
|
||||
resp.Probes.ARP = &probe.ARPResult{MAC: mac, Vendor: vendor}
|
||||
mu.Unlock()
|
||||
})
|
||||
run(func() {
|
||||
r, _ := probe.QueryHAP(ctx, ip)
|
||||
mu.Lock()
|
||||
resp.Probes.MDNS = r
|
||||
mu.Unlock()
|
||||
})
|
||||
run(func() {
|
||||
r, _ := probe.ProbeHTTP(ctx, ip, nil)
|
||||
mu.Lock()
|
||||
resp.Probes.HTTP = r
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
if hasICMP {
|
||||
run(func() {
|
||||
r, _ := probe.Ping(ctx, ip)
|
||||
mu.Lock()
|
||||
resp.Probes.Ping = r
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// determine reachable
|
||||
resp.Reachable = resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0
|
||||
if !resp.Reachable && resp.Probes.Ping != nil {
|
||||
resp.Reachable = true
|
||||
}
|
||||
|
||||
if resp.Reachable && resp.Probes.Ping != nil {
|
||||
resp.LatencyMs = resp.Probes.Ping.LatencyMs
|
||||
}
|
||||
|
||||
// determine type
|
||||
resp.Type = "standard"
|
||||
if !resp.Reachable {
|
||||
resp.Type = "unreachable"
|
||||
} else {
|
||||
for _, detect := range detectors {
|
||||
if t := detect(resp); t != "" {
|
||||
resp.Type = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func loadPorts() []int {
|
||||
if db == nil {
|
||||
return defaultPorts()
|
||||
}
|
||||
|
||||
rows, err := db.Query("SELECT DISTINCT port FROM streams WHERE port > 0 UNION SELECT DISTINCT port FROM preset_streams WHERE port > 0")
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("[probe] failed to load ports from db, using defaults")
|
||||
return defaultPorts()
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []int
|
||||
for rows.Next() {
|
||||
var port int
|
||||
if err = rows.Scan(&port); err == nil {
|
||||
result = append(result, port)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return defaultPorts()
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(result)).Msg("[probe] loaded ports from db")
|
||||
return result
|
||||
}
|
||||
|
||||
func defaultPorts() []int {
|
||||
return []int{554, 80, 8080, 443, 8554, 5544, 10554, 1935, 81, 88, 8090, 8001, 8081, 7070, 7447, 34567}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/eduard256/strix/internal/api"
|
||||
"github.com/eduard256/strix/internal/app"
|
||||
"github.com/eduard256/strix/pkg/camdb"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var log zerolog.Logger
|
||||
var db *sql.DB
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("search")
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", app.DB+"?mode=ro")
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("[search] db open")
|
||||
}
|
||||
|
||||
// verify DB is readable
|
||||
var count int
|
||||
if err = db.QueryRow("SELECT COUNT(*) FROM brands").Scan(&count); err != nil {
|
||||
log.Fatal().Err(err).Msg("[search] db verify")
|
||||
}
|
||||
log.Info().Int("brands", count).Msg("[search] loaded")
|
||||
|
||||
api.HandleFunc("api/search", apiSearch)
|
||||
api.HandleFunc("api/streams", apiStreams)
|
||||
}
|
||||
|
||||
func apiSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
var results []camdb.Result
|
||||
var err error
|
||||
|
||||
if q == "" {
|
||||
results, err = camdb.SearchAll(db)
|
||||
} else {
|
||||
results, err = camdb.SearchQuery(db, q)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
api.Error(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponseJSON(w, map[string]any{"results": results})
|
||||
}
|
||||
|
||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
ids := q.Get("ids")
|
||||
if ids == "" {
|
||||
http.Error(w, "ids required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ip := q.Get("ip")
|
||||
if ip == "" {
|
||||
http.Error(w, "ip required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
channel, _ := strconv.Atoi(q.Get("channel"))
|
||||
|
||||
var portFilter map[int]bool
|
||||
if ps := q.Get("ports"); ps != "" {
|
||||
portFilter = map[int]bool{}
|
||||
for _, p := range strings.Split(ps, ",") {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
portFilter[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streams, err := camdb.BuildStreams(db, &camdb.StreamParams{
|
||||
IDs: ids,
|
||||
IP: ip,
|
||||
User: q.Get("user"),
|
||||
Pass: q.Get("pass"),
|
||||
Channel: channel,
|
||||
Ports: portFilter,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
} else if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "unknown") {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
|
||||
api.ResponseJSON(w, map[string]any{"streams": streams})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/eduard256/strix/internal/api"
|
||||
"github.com/eduard256/strix/internal/app"
|
||||
"github.com/eduard256/strix/pkg/tester"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var log zerolog.Logger
|
||||
|
||||
var sessions = map[string]*tester.Session{}
|
||||
var sessionsMu sync.Mutex
|
||||
|
||||
func Init() {
|
||||
log = app.GetLogger("test")
|
||||
|
||||
api.HandleFunc("api/test", apiTest)
|
||||
api.HandleFunc("api/test/screenshot", apiScreenshot)
|
||||
|
||||
// cleanup expired sessions
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(time.Minute)
|
||||
|
||||
sessionsMu.Lock()
|
||||
for id, s := range sessions {
|
||||
s.Lock()
|
||||
expired := s.Status == "done" && time.Since(s.ExpiresAt) > 0
|
||||
s.Unlock()
|
||||
if expired {
|
||||
delete(sessions, id)
|
||||
}
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func apiTest(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
apiTestList(w)
|
||||
return
|
||||
}
|
||||
apiTestGet(w, id)
|
||||
|
||||
case "POST":
|
||||
apiTestCreate(w, r)
|
||||
|
||||
case "DELETE":
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
http.Error(w, "id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
apiTestDelete(w, id)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func apiTestList(w http.ResponseWriter) {
|
||||
type summary struct {
|
||||
ID string `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
Tested int `json:"tested"`
|
||||
Alive int `json:"alive"`
|
||||
WithScreen int `json:"with_screenshot"`
|
||||
}
|
||||
|
||||
sessionsMu.Lock()
|
||||
items := make([]summary, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
s.Lock()
|
||||
items = append(items, summary{
|
||||
ID: s.ID,
|
||||
Status: s.Status,
|
||||
Total: s.Total,
|
||||
Tested: s.Tested,
|
||||
Alive: s.Alive,
|
||||
WithScreen: s.WithScreen,
|
||||
})
|
||||
s.Unlock()
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
|
||||
api.ResponseJSON(w, map[string]any{"sessions": items})
|
||||
}
|
||||
|
||||
func apiTestGet(w http.ResponseWriter, id string) {
|
||||
sessionsMu.Lock()
|
||||
s := sessions[id]
|
||||
sessionsMu.Unlock()
|
||||
|
||||
if s == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.Lock()
|
||||
api.ResponseJSON(w, s)
|
||||
s.Unlock()
|
||||
}
|
||||
|
||||
func apiTestCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Sources struct {
|
||||
Streams []string `json:"streams"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Sources.Streams) == 0 {
|
||||
http.Error(w, "sources.streams required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := randID()
|
||||
s := tester.NewSession(id, len(req.Sources.Streams))
|
||||
|
||||
sessionsMu.Lock()
|
||||
sessions[id] = s
|
||||
sessionsMu.Unlock()
|
||||
|
||||
log.Debug().Str("id", id).Int("urls", len(req.Sources.Streams)).Msg("[test] session created")
|
||||
|
||||
go tester.RunWorkers(s, req.Sources.Streams)
|
||||
|
||||
api.ResponseJSON(w, map[string]string{"session_id": id})
|
||||
}
|
||||
|
||||
func apiTestDelete(w http.ResponseWriter, id string) {
|
||||
sessionsMu.Lock()
|
||||
if s, ok := sessions[id]; ok {
|
||||
s.Cancel()
|
||||
delete(sessions, id)
|
||||
}
|
||||
sessionsMu.Unlock()
|
||||
|
||||
api.ResponseJSON(w, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func apiScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
id := q.Get("id")
|
||||
idx, err := strconv.Atoi(q.Get("i"))
|
||||
if id == "" || err != nil {
|
||||
http.Error(w, "id and i required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionsMu.Lock()
|
||||
s := sessions[id]
|
||||
sessionsMu.Unlock()
|
||||
|
||||
if s == nil {
|
||||
http.Error(w, "session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := s.GetScreenshot(idx)
|
||||
if data == nil {
|
||||
http.Error(w, "screenshot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func randID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package logger
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// Adapter wraps slog.Logger to match our interface
|
||||
type Adapter struct {
|
||||
*slog.Logger
|
||||
Secrets *SecretStore
|
||||
}
|
||||
|
||||
// NewAdapter creates a new logger adapter
|
||||
func NewAdapter(logger *slog.Logger, secrets *SecretStore) *Adapter {
|
||||
return &Adapter{Logger: logger, Secrets: secrets}
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (a *Adapter) Debug(msg string, args ...any) {
|
||||
a.Logger.Debug(msg, args...)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (a *Adapter) Info(msg string, args ...any) {
|
||||
a.Logger.Info(msg, args...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (a *Adapter) Error(msg string, err error, args ...any) {
|
||||
allArgs := append([]any{"error", err}, args...)
|
||||
a.Logger.Error(msg, allArgs...)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func (a *Adapter) Warn(msg string, args ...any) {
|
||||
a.Logger.Warn(msg, args...)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SecretStore holds a set of secret strings that should be masked in log output.
|
||||
// It is safe for concurrent use by multiple goroutines. Multiple concurrent scans
|
||||
// can register different passwords; all are masked simultaneously.
|
||||
type SecretStore struct {
|
||||
mu sync.RWMutex
|
||||
secrets map[string]struct{}
|
||||
}
|
||||
|
||||
// NewSecretStore creates a new empty secret store.
|
||||
func NewSecretStore() *SecretStore {
|
||||
return &SecretStore{
|
||||
secrets: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a secret string to be masked in all future log output.
|
||||
// Empty strings are ignored. Both the plain text and URL-encoded forms
|
||||
// are registered, because credentials may appear percent-encoded in URLs
|
||||
// (e.g. "p@ss" becomes "p%40ss" via url.QueryEscape or url.UserPassword).
|
||||
func (s *SecretStore) Add(secret string) {
|
||||
if secret == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.secrets[secret] = struct{}{}
|
||||
encoded := url.QueryEscape(secret)
|
||||
if encoded != secret {
|
||||
s.secrets[encoded] = struct{}{}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Remove unregisters a secret string so it is no longer masked.
|
||||
func (s *SecretStore) Remove(secret string) {
|
||||
if secret == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.secrets, secret)
|
||||
encoded := url.QueryEscape(secret)
|
||||
if encoded != secret {
|
||||
delete(s.secrets, encoded)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Mask replaces all registered secret strings in text with "***".
|
||||
// Returns the original string unchanged if no secrets are registered.
|
||||
func (s *SecretStore) Mask(text string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if len(s.secrets) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
for secret := range s.secrets {
|
||||
if strings.Contains(text, secret) {
|
||||
text = strings.ReplaceAll(text, secret, "***")
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// SecretMaskingHandler wraps a slog.Handler and replaces registered secrets
|
||||
// with "***" in all log record messages and attribute values before passing
|
||||
// them to the inner handler. This ensures credentials never appear in log
|
||||
// output regardless of where they originate in the code.
|
||||
type SecretMaskingHandler struct {
|
||||
inner slog.Handler
|
||||
secrets *SecretStore
|
||||
}
|
||||
|
||||
// NewSecretMaskingHandler creates a handler that masks secrets in log output.
|
||||
func NewSecretMaskingHandler(inner slog.Handler, secrets *SecretStore) *SecretMaskingHandler {
|
||||
return &SecretMaskingHandler{
|
||||
inner: inner,
|
||||
secrets: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the inner handler handles records at the given level.
|
||||
func (h *SecretMaskingHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle masks secrets in the record message and all attributes, then
|
||||
// delegates to the inner handler.
|
||||
func (h *SecretMaskingHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
// Fast path: no secrets registered
|
||||
h.secrets.mu.RLock()
|
||||
hasSecrets := len(h.secrets.secrets) > 0
|
||||
h.secrets.mu.RUnlock()
|
||||
|
||||
if !hasSecrets {
|
||||
return h.inner.Handle(ctx, record)
|
||||
}
|
||||
|
||||
// Mask the message
|
||||
record.Message = h.secrets.Mask(record.Message)
|
||||
|
||||
// Mask all attributes by collecting, masking, and replacing them
|
||||
maskedAttrs := make([]slog.Attr, 0, record.NumAttrs())
|
||||
record.Attrs(func(a slog.Attr) bool {
|
||||
maskedAttrs = append(maskedAttrs, h.maskAttr(a))
|
||||
return true
|
||||
})
|
||||
|
||||
// Create a new record without the old attrs and add the masked ones.
|
||||
// slog.Record doesn't have a method to clear attrs, so we build a new one.
|
||||
newRecord := slog.NewRecord(record.Time, record.Level, record.Message, record.PC)
|
||||
newRecord.AddAttrs(maskedAttrs...)
|
||||
|
||||
return h.inner.Handle(ctx, newRecord)
|
||||
}
|
||||
|
||||
// WithAttrs returns a new handler with the given pre-masked attributes.
|
||||
func (h *SecretMaskingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
masked := make([]slog.Attr, len(attrs))
|
||||
for i, a := range attrs {
|
||||
masked[i] = h.maskAttr(a)
|
||||
}
|
||||
return &SecretMaskingHandler{
|
||||
inner: h.inner.WithAttrs(masked),
|
||||
secrets: h.secrets,
|
||||
}
|
||||
}
|
||||
|
||||
// WithGroup returns a new handler with the given group name.
|
||||
func (h *SecretMaskingHandler) WithGroup(name string) slog.Handler {
|
||||
return &SecretMaskingHandler{
|
||||
inner: h.inner.WithGroup(name),
|
||||
secrets: h.secrets,
|
||||
}
|
||||
}
|
||||
|
||||
// maskAttr masks secrets in an attribute value. Handles string values,
|
||||
// error values, and recursively handles group attributes.
|
||||
func (h *SecretMaskingHandler) maskAttr(a slog.Attr) slog.Attr {
|
||||
switch a.Value.Kind() {
|
||||
case slog.KindString:
|
||||
a.Value = slog.StringValue(h.secrets.Mask(a.Value.String()))
|
||||
|
||||
case slog.KindGroup:
|
||||
attrs := a.Value.Group()
|
||||
masked := make([]slog.Attr, len(attrs))
|
||||
for i, ga := range attrs {
|
||||
masked[i] = h.maskAttr(ga)
|
||||
}
|
||||
a.Value = slog.GroupValue(masked...)
|
||||
|
||||
case slog.KindAny:
|
||||
v := a.Value.Any()
|
||||
|
||||
// Handle error values (Go's http.Client embeds full URLs in errors)
|
||||
if err, ok := v.(error); ok {
|
||||
masked := h.secrets.Mask(err.Error())
|
||||
a.Value = slog.StringValue(masked)
|
||||
return a
|
||||
}
|
||||
|
||||
// Handle fmt.Stringer (e.g. time.Duration, url.URL, etc.)
|
||||
if stringer, ok := v.(fmt.Stringer); ok {
|
||||
masked := h.secrets.Mask(stringer.String())
|
||||
a.Value = slog.StringValue(masked)
|
||||
return a
|
||||
}
|
||||
|
||||
// Handle string slices (used in BuildURLsFromEntry logging)
|
||||
if ss, ok := v.([]string); ok {
|
||||
maskedSlice := make([]string, len(ss))
|
||||
for i, s := range ss {
|
||||
maskedSlice[i] = h.secrets.Mask(s)
|
||||
}
|
||||
a.Value = slog.AnyValue(maskedSlice)
|
||||
return a
|
||||
}
|
||||
|
||||
// For other Any values, convert to string and mask
|
||||
str := fmt.Sprintf("%v", v)
|
||||
masked := h.secrets.Mask(str)
|
||||
if masked != str {
|
||||
a.Value = slog.StringValue(masked)
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecretStore_AddRemoveMask(t *testing.T) {
|
||||
store := NewSecretStore()
|
||||
|
||||
// No secrets: text unchanged
|
||||
if got := store.Mask("password=secret123"); got != "password=secret123" {
|
||||
t.Errorf("expected unchanged text, got %q", got)
|
||||
}
|
||||
|
||||
// Add a secret
|
||||
store.Add("secret123")
|
||||
if got := store.Mask("password=secret123"); got != "password=***" {
|
||||
t.Errorf("expected masked, got %q", got)
|
||||
}
|
||||
|
||||
// Remove the secret
|
||||
store.Remove("secret123")
|
||||
if got := store.Mask("password=secret123"); got != "password=secret123" {
|
||||
t.Errorf("expected unmasked after remove, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStore_EmptyString(t *testing.T) {
|
||||
store := NewSecretStore()
|
||||
store.Add("")
|
||||
if got := store.Mask("test"); got != "test" {
|
||||
t.Errorf("empty secret should be ignored, got %q", got)
|
||||
}
|
||||
store.Remove("") // should not panic
|
||||
}
|
||||
|
||||
func TestSecretStore_MultipleSecrets(t *testing.T) {
|
||||
store := NewSecretStore()
|
||||
store.Add("pass1")
|
||||
store.Add("pass2")
|
||||
|
||||
got := store.Mask("url=rtsp://user:pass1@host and also pwd=pass2&rate=0")
|
||||
if strings.Contains(got, "pass1") || strings.Contains(got, "pass2") {
|
||||
t.Errorf("both passwords should be masked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretStore_ConcurrentAccess(t *testing.T) {
|
||||
store := NewSecretStore()
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Simulate concurrent scans adding/removing/masking
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(3)
|
||||
secret := "secret" + string(rune('A'+i%26))
|
||||
|
||||
go func(s string) {
|
||||
defer wg.Done()
|
||||
store.Add(s)
|
||||
}(secret)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = store.Mask("some text with secretA in it")
|
||||
}()
|
||||
|
||||
go func(s string) {
|
||||
defer wg.Done()
|
||||
store.Remove(s)
|
||||
}(secret)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_MasksStringAttrs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("mypassword")
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
log.Debug("testing stream", "url", "rtsp://admin:mypassword@192.168.1.10/stream")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "mypassword") {
|
||||
t.Errorf("password should be masked in output: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "***") {
|
||||
t.Errorf("expected *** in output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_MasksMessage(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("secretpwd")
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
log.Debug("failed with secretpwd in message")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "secretpwd") {
|
||||
t.Errorf("password should be masked in message: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_MasksErrorValues(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("r6wnm0wlix")
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
err := errors.New(`Get "http://10.0.20.111/cgi-bin/encoder?PWD=r6wnm0wlix&USER=admin": dial tcp`)
|
||||
log.Debug("request failed", "error", err)
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "r6wnm0wlix") {
|
||||
t.Errorf("password should be masked in error: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_NoSecretsPassthrough(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
log.Debug("normal message", "key", "value")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "normal message") || !strings.Contains(output, "value") {
|
||||
t.Errorf("output should pass through unchanged: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_MasksMultipleOccurrences(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("secret123")
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
log.Debug("test",
|
||||
"url1", "rtsp://user:secret123@host1/stream",
|
||||
"url2", "http://host2/snap?pwd=secret123",
|
||||
"path", "/user=admin_password=secret123_channel=1",
|
||||
)
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "secret123") {
|
||||
t.Errorf("all occurrences should be masked: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_Enabled(t *testing.T) {
|
||||
store := NewSecretStore()
|
||||
inner := slog.NewTextHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
|
||||
if handler.Enabled(context.Background(), slog.LevelDebug) {
|
||||
t.Error("debug should be disabled when level is info")
|
||||
}
|
||||
if !handler.Enabled(context.Background(), slog.LevelInfo) {
|
||||
t.Error("info should be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_SpecialCharsPassword(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("p@ss:w0rd#1")
|
||||
|
||||
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
// Simulate URLs built by builder.go and onvif_simple.go
|
||||
// 1. RTSP with url.QueryEscape (onvif_simple.go:395)
|
||||
log.Debug("testing RTSP stream", "url", "rtsp://admin:p%40ss%3Aw0rd%231@192.168.1.10:554/stream1")
|
||||
|
||||
// 2. HTTP with url.UserPassword (builder.go:355) -- Go encodes special chars
|
||||
log.Debug("testing HTTP stream", "url", "http://admin:p%40ss%3Aw0rd%231@192.168.1.10/snap.jpg")
|
||||
|
||||
// 3. Query params with url.Values.Encode (builder.go:377)
|
||||
log.Debug("testing HTTP stream", "url", "http://192.168.1.10/snap.jpg?pwd=p%40ss%3Aw0rd%231&user=admin")
|
||||
|
||||
// 4. Error from Go http.Client (contains encoded URL)
|
||||
log.Debug("stream test failed",
|
||||
"url", "http://admin:p%40ss%3Aw0rd%231@192.168.1.10/camera",
|
||||
"error", `HTTP request failed: Get "http://admin:***@192.168.1.10/camera": connection refused`)
|
||||
|
||||
output := buf.String()
|
||||
t.Logf("Output:\n%s", output)
|
||||
|
||||
if strings.Contains(output, "p@ss:w0rd#1") {
|
||||
t.Errorf("plain text password should be masked: %s", output)
|
||||
}
|
||||
if strings.Contains(output, "p%40ss%3Aw0rd%231") {
|
||||
t.Errorf("URL-encoded password should be masked: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_PlainPassword(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("simplepass123")
|
||||
|
||||
inner := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
log := slog.New(handler)
|
||||
|
||||
// Plain password without special chars -- no encoding difference
|
||||
log.Debug("testing RTSP stream", "url", "rtsp://admin:simplepass123@192.168.1.10:554/stream")
|
||||
log.Debug("testing HTTP stream", "url", "http://192.168.1.10/snap.jpg?pwd=simplepass123&user=admin")
|
||||
log.Debug("stream test failed",
|
||||
"url", "http://admin:simplepass123@192.168.1.10/camera",
|
||||
"error", `HTTP request failed: Get "http://192.168.1.10/snap.jpg?pwd=simplepass123&user=admin": connection refused`)
|
||||
|
||||
output := buf.String()
|
||||
t.Logf("Output:\n%s", output)
|
||||
|
||||
if strings.Contains(output, "simplepass123") {
|
||||
t.Errorf("password should be masked everywhere: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretMaskingHandler_WithAttrs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
store := NewSecretStore()
|
||||
store.Add("secretval")
|
||||
|
||||
inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
handler := NewSecretMaskingHandler(inner, store)
|
||||
child := handler.WithAttrs([]slog.Attr{slog.String("static", "has secretval inside")})
|
||||
log := slog.New(child)
|
||||
|
||||
log.Debug("test")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "secretval") {
|
||||
t.Errorf("pre-set attr should be masked: %s", output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user