diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c962e..0f3c04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Full URL: `"http://192.168.1.100/onvif/device_service"` (backward compatible) - Automatically adds `http://` scheme and `/onvif/device_service` path when needed - See `docs/SIMPLIFIED_ENDPOINT.md` for details +- **Localhost URL Fix**: Automatic handling of cameras that report localhost addresses + - Detects and fixes localhost/127.0.0.1/0.0.0.0/::1 in GetCapabilities response + - Replaces with actual camera IP address + - Preserves service-specific ports when specified + - Handles common camera firmware bugs transparently - Comprehensive test coverage for endpoint normalization (12 test cases) +- Comprehensive test coverage for localhost URL handling (10 test cases) - New example: `examples/simplified-endpoint/` demonstrating all endpoint formats - Documentation: `docs/PROJECT_STRUCTURE.md` explaining project organization - Initial release of go-onvif library diff --git a/client.go b/client.go index f392c79..e6554df 100644 --- a/client.go +++ b/client.go @@ -116,6 +116,46 @@ func normalizeEndpoint(endpoint string) (string, error) { return fullURL, nil } +// fixLocalhostURL replaces localhost/loopback addresses in service URLs with the actual camera host +// Some cameras incorrectly report localhost (127.0.0.1, 0.0.0.0, localhost) in their capability URLs +func (c *Client) fixLocalhostURL(serviceURL string) string { + if serviceURL == "" { + return serviceURL + } + + // Parse the service URL + parsedService, err := url.Parse(serviceURL) + if err != nil { + return serviceURL // Return original if parsing fails + } + + // Check if the service URL has a localhost/loopback address + host := parsedService.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" { + // Parse the client's endpoint to get the actual camera address + parsedClient, err := url.Parse(c.endpoint) + if err != nil { + return serviceURL // Return original if parsing fails + } + + // Replace the host but keep the port from service URL if specified + servicePort := parsedService.Port() + if servicePort != "" { + parsedService.Host = parsedClient.Hostname() + ":" + servicePort + } else { + parsedService.Host = parsedClient.Hostname() + // Use client's port if service doesn't specify one + if clientPort := parsedClient.Port(); clientPort != "" { + parsedService.Host = parsedClient.Hostname() + ":" + clientPort + } + } + + return parsedService.String() + } + + return serviceURL +} + // Initialize discovers and initializes service endpoints func (c *Client) Initialize(ctx context.Context) error { // Get device information and capabilities @@ -124,18 +164,19 @@ func (c *Client) Initialize(ctx context.Context) error { return fmt.Errorf("failed to get capabilities: %w", err) } - // Extract service endpoints + // Extract service endpoints and fix any localhost addresses + // Some cameras incorrectly report localhost instead of their actual IP if capabilities.Media != nil && capabilities.Media.XAddr != "" { - c.mediaEndpoint = capabilities.Media.XAddr + c.mediaEndpoint = c.fixLocalhostURL(capabilities.Media.XAddr) } if capabilities.PTZ != nil && capabilities.PTZ.XAddr != "" { - c.ptzEndpoint = capabilities.PTZ.XAddr + c.ptzEndpoint = c.fixLocalhostURL(capabilities.PTZ.XAddr) } if capabilities.Imaging != nil && capabilities.Imaging.XAddr != "" { - c.imagingEndpoint = capabilities.Imaging.XAddr + c.imagingEndpoint = c.fixLocalhostURL(capabilities.Imaging.XAddr) } if capabilities.Events != nil && capabilities.Events.XAddr != "" { - c.eventEndpoint = capabilities.Events.XAddr + c.eventEndpoint = c.fixLocalhostURL(capabilities.Events.XAddr) } return nil diff --git a/client_test.go b/client_test.go index 2a3feae..6d3b902 100644 --- a/client_test.go +++ b/client_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -639,3 +640,157 @@ func ExampleClient_GetDeviceInformation() { fmt.Printf("Camera: %s %s\n", info.Manufacturer, info.Model) fmt.Printf("Firmware: %s\n", info.FirmwareVersion) } + +func TestFixLocalhostURL(t *testing.T) { + tests := []struct { + name string + clientURL string + serviceURL string + expectedURL string + }{ + { + name: "localhost hostname", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://localhost/onvif/media_service", + expectedURL: "http://192.168.1.100/onvif/media_service", + }, + { + name: "127.0.0.1 loopback", + clientURL: "http://192.168.1.100:8080/onvif/device_service", + serviceURL: "http://127.0.0.1/onvif/ptz_service", + expectedURL: "http://192.168.1.100:8080/onvif/ptz_service", + }, + { + name: "0.0.0.0 address", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://0.0.0.0/onvif/imaging_service", + expectedURL: "http://192.168.1.100/onvif/imaging_service", + }, + { + name: "IPv6 loopback", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://[::1]/onvif/events_service", + expectedURL: "http://192.168.1.100/onvif/events_service", + }, + { + name: "localhost with different port", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://localhost:8080/onvif/media_service", + expectedURL: "http://192.168.1.100:8080/onvif/media_service", + }, + { + name: "valid IP address unchanged", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://192.168.1.100/onvif/media_service", + expectedURL: "http://192.168.1.100/onvif/media_service", + }, + { + name: "different valid IP unchanged", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "http://192.168.1.50/onvif/media_service", + expectedURL: "http://192.168.1.50/onvif/media_service", + }, + { + name: "HTTPS localhost", + clientURL: "https://192.168.1.100/onvif/device_service", + serviceURL: "https://localhost/onvif/media_service", + expectedURL: "https://192.168.1.100/onvif/media_service", + }, + { + name: "client with port, service localhost no port", + clientURL: "http://192.168.1.100:80/onvif/device_service", + serviceURL: "http://localhost/onvif/media_service", + expectedURL: "http://192.168.1.100:80/onvif/media_service", + }, + { + name: "empty service URL", + clientURL: "http://192.168.1.100/onvif/device_service", + serviceURL: "", + expectedURL: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &Client{ + endpoint: tt.clientURL, + } + + result := client.fixLocalhostURL(tt.serviceURL) + if result != tt.expectedURL { + t.Errorf("fixLocalhostURL() = %v, want %v", result, tt.expectedURL) + } + }) + } +} + +func TestInitializeWithLocalhostURLs(t *testing.T) { + // Create a mock server + mock := NewMockONVIFServer() + defer mock.Close() + + // Set a GetCapabilities response with localhost URLs + capabilitiesResponse := ` + + + + + + http://localhost:8080/onvif/media_service + + + http://127.0.0.1/onvif/ptz_service + + + http://0.0.0.0/onvif/imaging_service + + + + +` + + mock.SetResponse("GetCapabilities", capabilitiesResponse) + + // Create client pointing to mock server + client, err := NewClient( + mock.URL()+"/onvif/device_service", + WithCredentials("admin", "admin"), + ) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Initialize should fix localhost URLs + ctx := context.Background() + err = client.Initialize(ctx) + if err != nil { + t.Fatalf("Initialize() failed: %v", err) + } + + // Parse the mock server URL to get host + mockURL, _ := url.Parse(mock.URL()) + expectedHost := mockURL.Host + + // Verify media endpoint was fixed (localhost:8080 should be replaced with mock host) + if strings.Contains(client.mediaEndpoint, "localhost") { + t.Errorf("Media endpoint still contains localhost: %v", client.mediaEndpoint) + } + if !strings.Contains(client.mediaEndpoint, expectedHost) { + t.Logf("Media endpoint: %v, Expected to contain: %v", client.mediaEndpoint, expectedHost) + // The port 8080 from service URL should be preserved + expectedMediaURL := "http://" + mockURL.Hostname() + ":8080/onvif/media_service" + if client.mediaEndpoint != expectedMediaURL { + t.Errorf("Media endpoint = %v, want %v", client.mediaEndpoint, expectedMediaURL) + } + } + + // Verify PTZ endpoint was fixed (127.0.0.1 should be replaced with mock host) + if strings.Contains(client.ptzEndpoint, "127.0.0.1") && !strings.Contains(expectedHost, "127.0.0.1") { + t.Errorf("PTZ endpoint still contains 127.0.0.1: %v", client.ptzEndpoint) + } + + // Verify Imaging endpoint was fixed (0.0.0.0 should be replaced with mock host) + if strings.Contains(client.imagingEndpoint, "0.0.0.0") { + t.Errorf("Imaging endpoint still contains 0.0.0.0: %v", client.imagingEndpoint) + } +}