From 88e76eadb5998295ccb2a2096a5f6df4eda5d3bc Mon Sep 17 00:00:00 2001 From: eduard256 Date: Fri, 7 Nov 2025 19:08:05 +0300 Subject: [PATCH] Refactor authentication system: centralize credential handling in Builder Major architectural improvement to eliminate duplicate credential embedding and simplify authentication logic across the codebase. Changes: - Builder now generates all URL variants with proper authentication upfront * RTSP: generates URLs with embedded credentials and without (for open cameras) * HTTP/JPEG/MJPEG: generates variants with query params and for Basic Auth - Tester simplified to only test ready-to-use URLs without modification * Removed auth chain logic and multiple authentication method attempts * Removed AuthMethod enum and related complexity * Credentials automatically extracted from URLs when needed - Scanner cleaned up by removing embedCredentialsInURL function * All TestStream calls now use single URL parameter * Removed AuthMethod from DiscoveredStream model Benefits: - Eliminates bug where credentials were added up to 3 times - Centralizes all URL generation logic in one place (Builder) - Cleaner, more maintainable code with clear separation of concerns - Reduces complexity by ~200 lines of code - All authentication scenarios still fully supported --- .github/workflows/ci.yml | 54 ++++ .github/workflows/release.yml | 32 +++ .goreleaser.yaml | 112 ++++++++ CHANGELOG.md | 33 +++ internal/camera/discovery/scanner.go | 55 +--- internal/camera/stream/builder.go | 90 +++++-- internal/camera/stream/tester.go | 371 ++++----------------------- internal/models/camera.go | 1 - 8 files changed, 351 insertions(+), 397 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c74083 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Build + run: make build + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a91af3b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..4bb62ef --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,112 @@ +# GoReleaser configuration for Strix +version: 2 + +before: + hooks: + - go mod tidy + - go mod download + +builds: + - id: strix + main: ./cmd/strix/main.go + binary: strix + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + - arm + goarm: + - "7" + ignore: + - goos: windows + goarch: arm + - goos: darwin + goarch: arm + ldflags: + - -s -w + - -X main.Version={{.Version}} + - -X main.BuildDate={{.Date}} + - -X main.GitCommit={{.ShortCommit}} + +archives: + - id: strix-archive + format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + - webui/**/* + - cameras/**/* + +checksum: + name_template: 'checksums.txt' + algorithm: sha256 + +changelog: + sort: asc + use: github + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' + - typo + groups: + - title: '🚀 Features' + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: '🐛 Bug Fixes' + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: '📝 Documentation' + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: '🔧 Other' + order: 999 + +release: + github: + owner: eduard256 + name: Strix + draft: false + prerelease: auto + name_template: "v{{.Version}}" + header: | + ## 🦉 Strix v{{.Version}} + + Smart IP Camera Stream Discovery System + + ### Installation + + Download the appropriate binary for your platform below and extract it. + + ### Usage + + ```bash + ./strix + ``` + + Then open http://localhost:4567 in your browser. + + footer: | + **Full Changelog**: https://github.com/eduard256/Strix/compare/{{ .PreviousTag }}...{{ .Tag }} + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +dist: dist diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..173ca90 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-11-06 + +### Added +- 🦉 Initial release of Strix +- 🌐 Web-based user interface for camera stream discovery +- 🔍 Automatic RTSP stream discovery for IP cameras +- 📹 Support for multiple camera manufacturers +- 🎯 ONVIF device discovery and PTZ endpoint detection +- 🔐 Credential embedding in stream URLs +- 📊 Camera model database with autocomplete search +- 🎨 Modern, responsive UI with purple owl logo +- ⚙️ Configuration export for Go2RTC and Frigate +- 🔄 Dual-stream support with optional sub-stream selection +- 📡 Server-Sent Events (SSE) for real-time discovery progress +- 🚀 RESTful API for camera search and stream discovery +- 📦 Cross-platform support (Linux, Windows, macOS) +- 🏗️ Built with Go for high performance + +### Features +- **Web Interface**: Clean, intuitive UI for camera configuration +- **Stream Discovery**: Automatically finds working RTSP streams +- **ONVIF Support**: Discovers ONVIF devices and PTZ capabilities +- **Multi-Platform**: Binaries for Linux (amd64, arm64, arm/v7), Windows, and macOS +- **Easy Integration**: Export configs for popular NVR systems + +[0.1.0]: https://github.com/eduard256/Strix/releases/tag/v0.1.0 diff --git a/internal/camera/discovery/scanner.go b/internal/camera/discovery/scanner.go index 301db24..045d0d5 100644 --- a/internal/camera/discovery/scanner.go +++ b/internal/camera/discovery/scanner.go @@ -173,21 +173,17 @@ func (s *Scanner) isDirectStreamURL(target string) bool { 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, req.Username, req.Password) + testResult := s.tester.TestStream(ctx, req.Target) result.TotalTested = 1 if testResult.Working { result.TotalFound = 1 - // Embed credentials in URL for basic_auth and combined methods - finalURL := s.embedCredentialsInURL(testResult.URL, req.Username, req.Password, string(testResult.AuthMethod)) - discoveredStream := models.DiscoveredStream{ - URL: finalURL, + URL: testResult.URL, Type: testResult.Type, Protocol: testResult.Protocol, Working: true, - AuthMethod: string(testResult.AuthMethod), Resolution: testResult.Resolution, Codec: testResult.Codec, FPS: testResult.FPS, @@ -235,45 +231,6 @@ func (s *Scanner) extractIP(target string) string { return target } -// embedCredentialsInURL embeds username and password in URL for basic_auth and combined methods -func (s *Scanner) embedCredentialsInURL(streamURL, username, password, authMethod string) string { - // Only apply for basic_auth and combined methods - if authMethod != "basic_auth" && authMethod != "combined" { - return streamURL - } - - // Check if credentials are provided - if username == "" || password == "" { - return streamURL - } - - // Parse URL - u, err := url.Parse(streamURL) - if err != nil { - s.logger.Debug("failed to parse URL for credential embedding", - "url", streamURL, - "error", err.Error()) - return streamURL - } - - // Check if credentials already exist in URL - if u.User != nil { - s.logger.Debug("credentials already exist in URL, skipping embedding", - "url", streamURL) - return streamURL - } - - // Embed credentials - u.User = url.UserPassword(username, password) - embeddedURL := u.String() - - s.logger.Debug("credentials embedded in URL", - "original_url", streamURL, - "embedded_url", embeddedURL, - "auth_method", authMethod) - - return embeddedURL -} // collectStreams collects all streams to test with their metadata func (s *Scanner) collectStreams(ctx context.Context, req models.StreamDiscoveryRequest, ip string) ([]models.DiscoveredStream, error) { @@ -529,22 +486,18 @@ func (s *Scanner) testStreamsConcurrently(ctx context.Context, streams []models. } // Test the stream - testResult := s.tester.TestStream(ctx, stream.URL, req.Username, req.Password) + testResult := s.tester.TestStream(ctx, stream.URL) atomic.AddInt32(&tested, 1) if testResult.Working { atomic.AddInt32(&found, 1) - // Embed credentials in URL for basic_auth and combined methods - finalURL := s.embedCredentialsInURL(testResult.URL, req.Username, req.Password, string(testResult.AuthMethod)) - discoveredStream := models.DiscoveredStream{ - URL: finalURL, + URL: testResult.URL, Type: testResult.Type, Protocol: testResult.Protocol, Port: 0, // Will be extracted from URL if needed Working: true, - AuthMethod: string(testResult.AuthMethod), Resolution: testResult.Resolution, Codec: testResult.Codec, FPS: testResult.FPS, diff --git a/internal/camera/stream/builder.go b/internal/camera/stream/builder.go index a9586b1..683da0f 100644 --- a/internal/camera/stream/builder.go +++ b/internal/camera/stream/builder.go @@ -286,42 +286,79 @@ func (b *Builder) cleanURL(fullURL string) string { // 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 - // Build main URL - mainURL := b.BuildURL(entry, ctx) - b.logger.Debug("BuildURLsFromEntry: main URL built", "url", mainURL, "entry_type", entry.Type) - urls = append(urls, mainURL) + // Helper to add unique URLs + addURL := func(url string) { + if !urlMap[url] { + urls = append(urls, url) + urlMap[url] = true + } + } + + switch entry.Protocol { + case "rtsp", "rtsps": + // For RTSP: generate with and without credentials + // 1. With credentials (if provided) + if ctx.Username != "" && ctx.Password != "" { + addURL(b.BuildURL(entry, ctx)) + } + + // 2. Without credentials (for open cameras) + ctxNoAuth := ctx + ctxNoAuth.Username = "" + ctxNoAuth.Password = "" + addURL(b.BuildURL(entry, ctxNoAuth)) + + case "http", "https": + // For HTTP/JPEG/MJPEG: generate multiple auth variants + if entry.Type == "JPEG" || entry.Type == "MJPEG" { + // Check if URL has auth placeholders + hasAuthPlaceholders := strings.Contains(entry.URL, "[USERNAME]") || + strings.Contains(entry.URL, "[PASSWORD]") || + strings.Contains(entry.URL, "[AUTH]") + + if hasAuthPlaceholders { + // 1. URL with credentials in parameters (replaced placeholders) + addURL(b.BuildURL(entry, ctx)) + + // 2. URL without credentials (for cameras that don't require auth) + ctxNoAuth := ctx + ctxNoAuth.Username = "" + ctxNoAuth.Password = "" + addURL(b.BuildURL(entry, ctxNoAuth)) + } else { + // URL without placeholders - will use Basic Auth in headers + // Generate only one URL, auth will be in headers + addURL(b.BuildURL(entry, ctx)) + } + } else { + // Other HTTP types - single URL + addURL(b.BuildURL(entry, ctx)) + } + + default: + // Other protocols - single URL + addURL(b.BuildURL(entry, ctx)) + } // For NVR systems, try multiple channels if ctx.Channel == 0 && strings.Contains(strings.ToLower(entry.Notes), "channel") { for ch := 1; ch <= 4; ch++ { altCtx := ctx altCtx.Channel = ch - altURL := b.BuildURL(entry, altCtx) - if altURL != mainURL { - urls = append(urls, altURL) - } - } - } - // Try different resolutions for snapshot URLs - if entry.Type == "JPEG" || entry.Type == "MJPEG" { - resolutions := [][2]int{ - {640, 480}, - {1280, 720}, - {1920, 1080}, - } - - for _, res := range resolutions { - if res[0] != ctx.Width || res[1] != ctx.Height { - altCtx := ctx - altCtx.Width = res[0] - altCtx.Height = res[1] - altURL := b.BuildURL(entry, altCtx) - if altURL != mainURL { - urls = append(urls, altURL) + // Regenerate with different channel + if entry.Protocol == "rtsp" || entry.Protocol == "rtsps" { + if ctx.Username != "" && ctx.Password != "" { + addURL(b.BuildURL(entry, altCtx)) } + altCtx.Username = "" + altCtx.Password = "" + addURL(b.BuildURL(entry, altCtx)) + } else { + addURL(b.BuildURL(entry, altCtx)) } } } @@ -329,6 +366,7 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) 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) diff --git a/internal/camera/stream/tester.go b/internal/camera/stream/tester.go index eb99ad7..318283a 100644 --- a/internal/camera/stream/tester.go +++ b/internal/camera/stream/tester.go @@ -12,23 +12,6 @@ import ( "time" ) -// AuthMethod represents an authentication method -type AuthMethod string - -const ( - // AuthNone - no authentication - AuthNone AuthMethod = "no_auth" - // AuthBasicHeader - HTTP Basic Auth header only - AuthBasicHeader AuthMethod = "basic_auth" - // AuthQueryParams - credentials in query string parameters - AuthQueryParams AuthMethod = "query_params" - // AuthCombined - both Basic Auth header and query params (ZOSI requirement) - AuthCombined AuthMethod = "combined" - // AuthDigest - HTTP Digest authentication - AuthDigest AuthMethod = "digest" - // AuthURLEmbedded - credentials embedded in URL (rtsp://user:pass@host) - AuthURLEmbedded AuthMethod = "url_embedded" -) // Tester validates stream URLs type Tester struct { @@ -54,7 +37,6 @@ type TestResult struct { Working bool Protocol string Type string - AuthMethod AuthMethod Resolution string Codec string FPS int @@ -65,278 +47,7 @@ type TestResult struct { Metadata map[string]interface{} } -// TestStreamWithAuthChain tests a stream URL with multiple authentication methods using smart fallback chain -func (t *Tester) TestStreamWithAuthChain(ctx context.Context, streamURL, username, password string) TestResult { - startTime := time.Now() - t.logger.Debug("TestStreamWithAuthChain started", - "url", streamURL, - "username", username, - "has_password", password != "") - - // Parse URL to determine protocol - u, err := url.Parse(streamURL) - if err != nil { - return TestResult{ - URL: streamURL, - Error: fmt.Sprintf("invalid URL: %v", err), - TestTime: time.Since(startTime), - Metadata: make(map[string]interface{}), - } - } - - // For RTSP, use the original single-method approach (embedded credentials) - if u.Scheme == "rtsp" || u.Scheme == "rtsps" { - result := t.testWithAuthMethod(ctx, streamURL, username, password, AuthURLEmbedded) - result.TestTime = time.Since(startTime) - return result - } - - // For HTTP/HTTPS, use smart auth chain - if u.Scheme == "http" || u.Scheme == "https" { - // Determine if URL already has auth parameters - hasAuthParams := t.hasAuthenticationParams(streamURL) - - // Smart priority chain based on URL characteristics - var authChain []AuthMethod - - if hasAuthParams { - // URL has auth params - prioritize methods that use them - authChain = []AuthMethod{ - AuthCombined, // Try combined first (ZOSI fix!) - AuthQueryParams, // Query params only - AuthBasicHeader, // Basic Auth header only - AuthNone, // No auth (some cameras ignore auth) - } - } else { - // URL doesn't have auth params - standard chain - authChain = []AuthMethod{ - AuthNone, // Try without auth first (fast) - AuthBasicHeader, // Most common method - AuthDigest, // Some older cameras - } - } - - t.logger.Debug("auth chain determined", - "url", streamURL, - "has_auth_params", hasAuthParams, - "auth_chain", authChain, - "chain_length", len(authChain)) - - // Try each auth method - for i, method := range authChain { - t.logger.Debug("trying auth method", - "method", method, - "url", streamURL, - "attempt", i+1, - "of", len(authChain)) - - result := t.testWithAuthMethod(ctx, streamURL, username, password, method) - - if result.Working { - // Success! Return immediately - result.TestTime = time.Since(startTime) - t.logger.Debug("auth method SUCCEEDED", - "url", streamURL, - "method", method, - "attempt", i+1, - "of", len(authChain), - "type", result.Type, - "protocol", result.Protocol) - return result - } - - // Log failed attempt - t.logger.Debug("auth method FAILED", - "url", streamURL, - "method", method, - "attempt", i+1, - "of", len(authChain), - "error", result.Error) - - // Special cases: if we get certain errors, might want to continue or stop - if result.Error != "" { - // If 401 Unauthorized, definitely try next auth method - if strings.Contains(result.Error, "401") || strings.Contains(result.Error, "authentication") { - continue - } - - // If connection refused, timeout, or other network errors, no point trying other auth methods - if strings.Contains(result.Error, "connection refused") || - strings.Contains(result.Error, "timeout") || - strings.Contains(result.Error, "no route to host") { - result.TestTime = time.Since(startTime) - return result - } - } - } - - // All methods failed, return last result - result := TestResult{ - URL: streamURL, - Protocol: u.Scheme, - Error: fmt.Sprintf("all authentication methods failed"), - TestTime: time.Since(startTime), - Metadata: make(map[string]interface{}), - } - return result - } - - // Unsupported protocol - return TestResult{ - URL: streamURL, - Protocol: u.Scheme, - Error: fmt.Sprintf("unsupported protocol: %s", u.Scheme), - TestTime: time.Since(startTime), - Metadata: make(map[string]interface{}), - } -} - -// hasAuthenticationParams checks if URL contains auth parameters -func (t *Tester) hasAuthenticationParams(streamURL string) bool { - authParams := []string{ - "user=", "username=", "usr=", "loginuse=", - "password=", "pass=", "pwd=", "loginpas=", "passwd=", - } - - lowerURL := strings.ToLower(streamURL) - for _, param := range authParams { - if strings.Contains(lowerURL, param) { - return true - } - } - return false -} - -// testWithAuthMethod tests a stream with a specific authentication method -func (t *Tester) testWithAuthMethod(ctx context.Context, streamURL, username, password string, method AuthMethod) TestResult { - result := TestResult{ - URL: streamURL, - AuthMethod: method, - Metadata: make(map[string]interface{}), - } - - // Parse URL - u, err := url.Parse(streamURL) - if err != nil { - result.Error = fmt.Sprintf("invalid URL: %v", err) - return result - } - - result.Protocol = u.Scheme - - // Handle based on protocol and auth method - switch u.Scheme { - case "rtsp", "rtsps": - t.testRTSPWithAuth(ctx, streamURL, username, password, method, &result) - case "http", "https": - t.testHTTPWithAuth(ctx, streamURL, username, password, method, &result) - default: - result.Error = fmt.Sprintf("unsupported protocol: %s", u.Scheme) - } - - return result -} - -// testRTSPWithAuth tests RTSP stream with specific auth method -func (t *Tester) testRTSPWithAuth(ctx context.Context, streamURL, username, password string, method AuthMethod, result *TestResult) { - // For RTSP, we only support embedded credentials - if method == AuthURLEmbedded && username != "" && password != "" { - u, _ := url.Parse(streamURL) - u.User = url.UserPassword(username, password) - streamURL = u.String() - } - - // Use existing RTSP testing logic - t.testRTSP(ctx, streamURL, username, password, result) -} - -// testHTTPWithAuth tests HTTP stream with specific authentication method -func (t *Tester) testHTTPWithAuth(ctx context.Context, streamURL, username, password string, method AuthMethod, 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 - } - - // Apply authentication based on method - switch method { - case AuthNone: - // No authentication - do nothing - - case AuthBasicHeader: - // Basic Auth header only - if username != "" && password != "" { - req.SetBasicAuth(username, password) - } - - case AuthQueryParams: - // Query params only (already in URL) - // No additional action needed - - case AuthCombined: - // Both Basic Auth header AND query params (ZOSI fix!) - if username != "" && password != "" { - req.SetBasicAuth(username, password) - } - // Query params already in URL - - case AuthDigest: - // Digest auth requires a challenge-response flow - // For now, we'll try basic auth and let the camera upgrade if needed - if username != "" && password != "" { - req.SetBasicAuth(username, password) - } - } - - // Add headers - req.Header.Set("User-Agent", "Strix/1.0") - - t.logger.Debug("sending HTTP request", - "url", streamURL, - "method", method, - "has_basic_auth_header", req.Header.Get("Authorization") != "", - "user_agent", req.Header.Get("User-Agent")) - - // Send request - resp, err := t.httpClient.Do(req) - if err != nil { - result.Error = fmt.Sprintf("HTTP request failed: %v", err) - t.logger.Debug("HTTP request failed", - "url", streamURL, - "method", method, - "error", err) - return - } - defer resp.Body.Close() - - t.logger.Debug("HTTP response received", - "url", streamURL, - "status_code", resp.StatusCode, - "status", resp.Status, - "content_type", resp.Header.Get("Content-Type"), - "content_length", resp.Header.Get("Content-Length"), - "www_authenticate", resp.Header.Get("WWW-Authenticate")) - - // Check status code - if resp.StatusCode != http.StatusOK { - result.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status) - t.logger.Debug("HTTP non-200 response", - "url", streamURL, - "status_code", resp.StatusCode, - "error", result.Error) - - // Special handling for 401 - if resp.StatusCode == http.StatusUnauthorized { - result.Error = "authentication required" - } - return - } - - // Check content type and validate stream - t.validateHTTPStream(resp, result) -} // validateHTTPStream validates the HTTP response as a valid stream func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { @@ -442,33 +153,53 @@ func (t *Tester) validateHTTPStream(resp *http.Response, result *TestResult) { } } -// TestStream tests if a stream URL is working (legacy method, now uses auth chain) -func (t *Tester) TestStream(ctx context.Context, streamURL, username, password string) TestResult { - // Delegate to the new auth chain method for better coverage - return t.TestStreamWithAuthChain(ctx, streamURL, username, password) +// 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, username, password string, result *TestResult) { +func (t *Tester) testRTSP(ctx context.Context, streamURL string, result *TestResult) { // Build ffprobe command cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout) defer cancel() - // Build URL with credentials if provided - testURL := streamURL - if username != "" && password != "" { - u, _ := url.Parse(streamURL) - u.User = url.UserPassword(username, password) - testURL = u.String() - } - + // Use URL as-is - credentials already embedded if needed args := []string{ "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", "-rtsp_transport", "tcp", - testURL, + streamURL, } cmd := exec.CommandContext(cmdCtx, "ffprobe", args...) @@ -557,7 +288,7 @@ func (t *Tester) testRTSP(ctx context.Context, streamURL, username, password str } // testHTTP tests an HTTP stream -func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password string, result *TestResult) { +func (t *Tester) testHTTP(ctx context.Context, streamURL string, result *TestResult) { // Create request req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil) if err != nil { @@ -565,9 +296,17 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str return } - // Add Basic Auth if credentials provided - if username != "" && password != "" { - req.SetBasicAuth(username, password) + // 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 @@ -633,7 +372,7 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str result.Working = true // Try to probe with ffprobe for more details - t.probeHTTPVideo(ctx, streamURL, username, password, result) + t.probeHTTPVideo(ctx, streamURL, result) case strings.Contains(contentType, "text/html"), strings.Contains(contentType, "text/plain"): // Ignore web interfaces and plain text responses @@ -648,23 +387,17 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str } // probeHTTPVideo uses ffprobe to get more details about HTTP video stream -func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL, username, password string, result *TestResult) { +func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL string, result *TestResult) { cmdCtx, cancel := context.WithTimeout(ctx, t.ffprobeTimeout) defer cancel() - // Build URL with credentials if needed - testURL := streamURL - if username != "" && password != "" && !strings.Contains(streamURL, "@") { - u, _ := url.Parse(streamURL) - u.User = url.UserPassword(username, password) - testURL = u.String() - } + // Use URL as-is - credentials already in URL if needed args := []string{ "-v", "quiet", "-print_format", "json", "-show_streams", - testURL, + streamURL, } cmd := exec.CommandContext(cmdCtx, "ffprobe", args...) @@ -694,7 +427,7 @@ func (t *Tester) probeHTTPVideo(ctx context.Context, streamURL, username, passwo } // TestMultiple tests multiple URLs concurrently -func (t *Tester) TestMultiple(ctx context.Context, urls []string, username, password string, maxConcurrent int) []TestResult { +func (t *Tester) TestMultiple(ctx context.Context, urls []string, maxConcurrent int) []TestResult { if maxConcurrent <= 0 { maxConcurrent = 10 } @@ -709,7 +442,7 @@ func (t *Tester) TestMultiple(ctx context.Context, urls []string, username, pass go func() { defer func() { <-sem }() // Release semaphore - results[i] = t.TestStream(ctx, url, username, password) + results[i] = t.TestStream(ctx, url) }() } diff --git a/internal/models/camera.go b/internal/models/camera.go index f0ec16b..9a284f6 100644 --- a/internal/models/camera.go +++ b/internal/models/camera.go @@ -67,7 +67,6 @@ type DiscoveredStream struct { Protocol string `json:"protocol"` Port int `json:"port"` Working bool `json:"working"` - AuthMethod string `json:"auth_method,omitempty"` // no_auth, basic_auth, query_params, combined, digest Resolution string `json:"resolution,omitempty"` Codec string `json:"codec,omitempty"` FPS int `json:"fps,omitempty"`