diff --git a/.golangci.yml b/.golangci.yml index 7aa57aa..a2ad293 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -46,6 +46,8 @@ linters: exclusions: generated: lax rules: + - path: (.+)\.go$ + text: 'string `none` has (.+) occurrences, make it a constant' - path: (.+)\.go$ text: 'ST1000: at least one file in a package should have a package comment' - path: (.+)\.go$ diff --git a/internal/attack/attacker.go b/internal/attack/attacker.go index 2c35a51..3dc781c 100644 --- a/internal/attack/attacker.go +++ b/internal/attack/attacker.go @@ -291,33 +291,26 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St } func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) { - u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password) + stream.Routes = []string{route} + code, err := a.describeStatus(stream) if err != nil { - return false, fmt.Errorf("building rtsp url: %w", err) + return false, fmt.Errorf("performing describe request at %q: %w", stream, err) } - code, err := a.describeStatus(u) - if err != nil { - return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err) - } - - a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code)) + a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", stream, code)) access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden return access, nil } func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) { - u, urlStr, err := buildRTSPURL(stream, stream.Route(), username, password) + stream.Username = username + stream.Password = password + code, err := a.describeStatus(stream) if err != nil { - return false, fmt.Errorf("building rtsp url: %w", err) + return false, fmt.Errorf("performing describe request at %q: %w", stream, err) } - code, err := a.describeStatus(u) - if err != nil { - return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err) - } - - a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code)) + a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", stream, code)) return code == base.StatusOK || code == base.StatusNotFound, nil } @@ -330,32 +323,27 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e return stream, ctx.Err() } - u, urlStr, err := buildRTSPURL(stream, stream.Route(), stream.Username, stream.Password) - if err != nil { - return stream, fmt.Errorf("building rtsp url: %w", err) - } - - client, err := a.newRTSPClient(u) + client, err := a.newRTSPClient(stream) if err != nil { return stream, fmt.Errorf("starting rtsp client: %w", err) } defer client.Close() - desc, res, err := a.describeWithRetry(ctx, client, u, urlStr) + desc, res, err := a.describeWithRetry(ctx, client, stream) if err != nil { - return a.handleDescribeError(stream, urlStr, err) + return a.handleDescribeError(stream, err) } - a.logDescribeResponse(urlStr, res) + a.logDescribeResponse(stream.String(), res) if desc == nil || len(desc.Medias) == 0 { - return stream, fmt.Errorf("no media tracks found for %q", urlStr) + return stream, fmt.Errorf("no media tracks found for %q", stream) } res, err = client.Setup(desc.BaseURL, desc.Medias[0], 0, 0) if err != nil { - return a.handleSetupError(stream, urlStr, err) + return a.handleSetupError(stream, err) } - a.logSetupResponse(urlStr, res) + a.logSetupResponse(stream.String(), res) stream.Available = res != nil && res.StatusCode == base.StatusOK if stream.Available { @@ -365,11 +353,15 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e return stream, nil } -func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, u *base.URL, urlStr string) (*description.Session, *base.Response, error) { +func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, stream cameradar.Stream) (*description.Session, *base.Response, error) { + u, err := stream.URL() + if err != nil { + return nil, nil, fmt.Errorf("building rtsp url: %w", err) + } + var ( desc *description.Session res *base.Response - err error ) for range 5 { desc, res, err = client.Describe(u) @@ -379,7 +371,7 @@ func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Clien var badStatus liberrors.ErrClientBadStatusCode if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable { - a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", urlStr, badStatus.Code)) + a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", stream, badStatus.Code)) select { case <-ctx.Done(): return nil, nil, ctx.Err() @@ -391,13 +383,13 @@ func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Clien return nil, nil, err } - return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err) + return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", stream, err) } -func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) { +func (a Attacker) handleDescribeError(stream cameradar.Stream, err error) (cameradar.Stream, error) { var badStatus liberrors.ErrClientBadStatusCode if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable { - a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code)) + a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", stream, badStatus.Code)) a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)", stream.Address.String(), stream.Port, @@ -407,20 +399,20 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er return stream, nil } - a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err)) + a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", stream, err)) - return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err) + return stream, fmt.Errorf("performing describe request at %q: %w", stream, err) } -func (a Attacker) handleSetupError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) { +func (a Attacker) handleSetupError(stream cameradar.Stream, err error) (cameradar.Stream, error) { var badStatus liberrors.ErrClientBadStatusCode if errors.As(err, &badStatus) { - a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", urlStr, badStatus.Code)) + a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", stream, badStatus.Code)) stream.Available = badStatus.Code == base.StatusOK return stream, nil } - return stream, fmt.Errorf("performing setup request at %q: %w", urlStr, err) + return stream, fmt.Errorf("performing setup request at %q: %w", stream, err) } func (a Attacker) logDescribeResponse(urlStr string, res *base.Response) { diff --git a/internal/attack/detect_auth.go b/internal/attack/detect_auth.go index 12cbdef..cd84b21 100644 --- a/internal/attack/detect_auth.go +++ b/internal/attack/detect_auth.go @@ -41,28 +41,44 @@ func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) if ctx.Err() != nil { return stream, ctx.Err() } - u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "") + u, err := stream.URL() if err != nil { return stream, fmt.Errorf("building rtsp url: %w", err) } - statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr) + statusCode, headers, err := a.probeDescribeHeaders(ctx, u) if err != nil { - a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err)) + a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", u, err)) + if stream.Scheme == schemeHTTP || stream.Scheme == schemeHTTPS { + statusCode, statusErr := a.describeStatus(stream) + if statusErr == nil { + a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (fallback)", u, statusCode)) + stream.AuthenticationType = authTypeFromStatus(statusCode, nil) + return stream, nil + } + + stream.AuthenticationType = cameradar.AuthUnknown + return stream, nil + } + stream.AuthenticationType = cameradar.AuthUnknown - return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err) + return stream, fmt.Errorf("performing describe request at %q: %w", u, err) } - a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, statusCode)) + a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", u, statusCode)) values := headerValues(headers, "WWW-Authenticate") - switch statusCode { - case base.StatusOK: - stream.AuthenticationType = cameradar.AuthNone - case base.StatusUnauthorized: - stream.AuthenticationType = authTypeFromHeaders(values) - default: - stream.AuthenticationType = cameradar.AuthUnknown - } + stream.AuthenticationType = authTypeFromStatus(statusCode, values) return stream, nil } + +func authTypeFromStatus(statusCode base.StatusCode, wwwAuthenticate base.HeaderValue) cameradar.AuthType { + switch statusCode { + case base.StatusOK: + return cameradar.AuthNone + case base.StatusUnauthorized: + return authTypeFromHeaders(wwwAuthenticate) + default: + return cameradar.AuthUnknown + } +} diff --git a/internal/attack/detect_auth_test.go b/internal/attack/detect_auth_test.go index 495b21b..b6cfe10 100644 --- a/internal/attack/detect_auth_test.go +++ b/internal/attack/detect_auth_test.go @@ -2,7 +2,13 @@ package attack import ( "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" "fmt" + "math/big" "net" "net/netip" "strings" @@ -78,6 +84,49 @@ func TestAuthTypeFromHeaders(t *testing.T) { } } +func TestAuthTypeFromStatus(t *testing.T) { + tests := []struct { + name string + statusCode base.StatusCode + headers base.HeaderValue + wantAuthType cameradar.AuthType + }{ + { + name: "status ok means no auth", + statusCode: base.StatusOK, + wantAuthType: cameradar.AuthNone, + }, + { + name: "status unauthorized with basic", + statusCode: base.StatusUnauthorized, + headers: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(), + wantAuthType: cameradar.AuthBasic, + }, + { + name: "status unauthorized with digest", + statusCode: base.StatusUnauthorized, + headers: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(), + wantAuthType: cameradar.AuthDigest, + }, + { + name: "status unauthorized without auth headers", + statusCode: base.StatusUnauthorized, + wantAuthType: cameradar.AuthUnknown, + }, + { + name: "status not found is unknown", + statusCode: base.StatusNotFound, + wantAuthType: cameradar.AuthUnknown, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.wantAuthType, authTypeFromStatus(test.statusCode, test.headers)) + }) + } +} + func TestDetectAuthMethod(t *testing.T) { tests := []struct { name string @@ -142,6 +191,52 @@ func TestDetectAuthMethod(t *testing.T) { } } +func TestDetectAuthMethod_HTTPTunnel_NonFatal(t *testing.T) { + tests := []struct { + name string + scheme string + }{ + {name: "http tunnel", scheme: "http"}, + {name: "https tunnel", scheme: "https"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{}) + require.NoError(t, err) + + stream := cameradar.Stream{ + Address: netip.MustParseAddr("127.0.0.1"), + Port: 1, + Scheme: test.scheme, + } + + got, err := attacker.detectAuthMethod(t.Context(), stream) + require.NoError(t, err) + assert.Equal(t, cameradar.AuthUnknown, got.AuthenticationType) + }) + } +} + +func TestDetectAuthMethod_RTSPS(t *testing.T) { + addr, port := startRTSPTLSProbeServer(t, base.StatusUnauthorized, base.Header{ + "WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(), + }) + + attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{}) + require.NoError(t, err) + + stream := cameradar.Stream{ + Address: addr, + Port: port, + Scheme: "rtsps", + } + + got, err := attacker.detectAuthMethod(t.Context(), stream) + require.NoError(t, err) + assert.Equal(t, cameradar.AuthBasic, got.AuthenticationType) +} + func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) { t.Helper() @@ -193,6 +288,83 @@ func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port) } +func startRTSPTLSProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) { + t.Helper() + + listener, err := tls.Listen("tcp", "127.0.0.1:0", testTLSConfig(t)) + require.NoError(t, err) + + t.Cleanup(func() { + _ = listener.Close() + }) + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(time.Second)) + + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + if strings.TrimSpace(line) == "" { + break + } + } + + statusText := statusTextFromCode(statusCode) + + var builder strings.Builder + _, _ = fmt.Fprintf(&builder, "RTSP/1.0 %d %s\r\n", statusCode, statusText) + builder.WriteString("CSeq: 1\r\n") + for key, values := range headers { + for _, value := range values { + _, _ = fmt.Fprintf(&builder, "%s: %s\r\n", key, value) + } + } + builder.WriteString("Content-Length: 0\r\n\r\n") + + _, _ = conn.Write([]byte(builder.String())) + }() + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + require.True(t, ok) + + return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port) +} + +func testTLSConfig(t *testing.T) *tls.Config { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{der}, + PrivateKey: key, + }}, + } +} + func statusTextFromCode(code base.StatusCode) string { switch code { case base.StatusOK: diff --git a/internal/attack/rtsp.go b/internal/attack/rtsp.go index 7686205..f7fb0f5 100644 --- a/internal/attack/rtsp.go +++ b/internal/attack/rtsp.go @@ -3,11 +3,11 @@ package attack import ( "bufio" "context" + "crypto/tls" "errors" "fmt" "net" "net/textproto" - "net/url" "strconv" "strings" "time" @@ -19,15 +19,46 @@ import ( "github.com/bluenviron/gortsplib/v5/pkg/liberrors" ) -func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) { +const ( + schemeRTSP = "rtsp" + schemeRTSPS = "rtsps" + schemeHTTP = "http" + schemeHTTPS = "https" +) + +func (a Attacker) newRTSPClient(stream cameradar.Stream) (*gortsplib.Client, error) { + u, err := stream.URL() + if err != nil { + return nil, fmt.Errorf("building rtsp url: %w", err) + } + if u.Scheme != schemeRTSP && u.Scheme != schemeRTSPS { + return nil, fmt.Errorf("unsupported rtsp url scheme: %q", u.Scheme) + } + client := &gortsplib.Client{ ReadTimeout: a.timeout, WriteTimeout: a.timeout, + Scheme: u.Scheme, + Host: u.Host, } - client.Scheme = u.Scheme - client.Host = u.Host - err := client.Start() + switch stream.Scheme { + case "": + // No explicit transport was requested. Use plain RTSP/RTSPS from the URL. + case schemeRTSP, schemeRTSPS: + // Nothing to do. + case schemeHTTP: + client.Scheme = schemeRTSP + client.Tunnel = gortsplib.TunnelHTTP + case schemeHTTPS: + client.Scheme = schemeRTSPS + client.Tunnel = gortsplib.TunnelHTTP + client.TLSConfig = &tls.Config{InsecureSkipVerify: true} + default: + return nil, fmt.Errorf("unsupported stream transport scheme: %q", stream.Scheme) + } + + err = client.Start() if err != nil { return nil, err } @@ -35,8 +66,13 @@ func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) { return client, nil } -func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) { - client, err := a.newRTSPClient(u) +func (a Attacker) describeStatus(stream cameradar.Stream) (base.StatusCode, error) { + u, err := stream.URL() + if err != nil { + return 0, fmt.Errorf("building rtsp url: %w", err) + } + + client, err := a.newRTSPClient(stream) if err != nil { return 0, err } @@ -61,9 +97,25 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) { // // NOTE: We do not use gortsplib here because it does not expose response headers when the status code is 401 Unauthorized, // which is exactly what we need in order to detect authentication methods. -func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) { +func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL) (base.StatusCode, base.Header, error) { dialer := &net.Dialer{Timeout: a.timeout} - conn, err := dialer.DialContext(ctx, "tcp", u.Host) + + var ( + conn net.Conn + err error + ) + switch u.Scheme { + case schemeRTSP: + conn, err = dialer.DialContext(ctx, "tcp", u.Host) + case schemeRTSPS: + tlsDialer := &tls.Dialer{ + NetDialer: dialer, + Config: &tls.Config{InsecureSkipVerify: true}, + } + conn, err = tlsDialer.DialContext(ctx, "tcp", u.Host) + default: + return 0, nil, fmt.Errorf("unsupported rtsp url scheme: %q", u.Scheme) + } if err != nil { return 0, nil, err } @@ -81,7 +133,7 @@ func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr request := fmt.Sprintf( "DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: cameradar\r\nAccept: application/sdp\r\nHost: %s\r\n\r\n", - urlStr, + u, u.Host, ) _, err = conn.Write([]byte(request)) @@ -163,25 +215,3 @@ func headerValues(header base.Header, name string) base.HeaderValue { } return nil } - -func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) { - host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port))) - path := "/" + strings.TrimLeft(strings.TrimSpace(route), "/") // Ensure path starts with a single "/" - - u := &url.URL{ - Scheme: "rtsp", - Host: host, - Path: path, - } - if username != "" || password != "" { - u.User = url.UserPassword(username, password) - } - - urlStr := u.String() - parsed, err := base.ParseURL(urlStr) - if err != nil { - return nil, "", err - } - - return parsed, urlStr, nil -} diff --git a/internal/attack/rtsp_url_test.go b/internal/attack/rtsp_url_test.go index ebd13d1..5cb9414 100644 --- a/internal/attack/rtsp_url_test.go +++ b/internal/attack/rtsp_url_test.go @@ -2,85 +2,163 @@ package attack import ( "net/netip" + "net/url" "testing" "github.com/Ullaakut/cameradar/v6" "github.com/stretchr/testify/require" ) -func TestBuildRTSPURL(t *testing.T) { - stream := cameradar.Stream{ - Address: netip.MustParseAddr("192.168.0.10"), - Port: 554, - } - +func TestStreamURL(t *testing.T) { tests := []struct { - name string - route string - username string - password string - wantURL string + name string + stream cameradar.Stream + wantURL string + wantParsedScheme string }{ { - name: "empty route", + name: "empty route", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + }, wantURL: "rtsp://192.168.0.10:554/", }, { - name: "root route", - route: "/", + name: "root route", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"/"}, + }, wantURL: "rtsp://192.168.0.10:554/", }, { - name: "multiple leading slashes", - route: "////", + name: "multiple leading slashes", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"////"}, + }, wantURL: "rtsp://192.168.0.10:554/", }, { - name: "route with no leading slash", - route: "stream", + name: "route with no leading slash", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + }, wantURL: "rtsp://192.168.0.10:554/stream", }, { - name: "route with leading slash", - route: "/stream", + name: "route with leading slash", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"/stream"}, + }, wantURL: "rtsp://192.168.0.10:554/stream", }, { - name: "route with trailing slash", - route: "stream/", + name: "route with trailing slash", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream/"}, + }, wantURL: "rtsp://192.168.0.10:554/stream/", }, { - name: "route with spaces", - route: " /stream ", + name: "route with spaces", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{" /stream "}, + }, wantURL: "rtsp://192.168.0.10:554/stream", }, { - name: "username and password", - route: "stream", - username: "admin", - password: "admin123", - wantURL: "rtsp://admin:admin123@192.168.0.10:554/stream", + name: "username and password", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Username: "admin", + Password: "admin123", + }, + wantURL: "rtsp://admin:admin123@192.168.0.10:554/stream", }, { - name: "empty username with password", - route: "stream", - password: "pass", - wantURL: "rtsp://:pass@192.168.0.10:554/stream", + name: "empty username with password", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Password: "pass", + }, + wantURL: "rtsp://:pass@192.168.0.10:554/stream", }, { - name: "username only", - route: "stream", - username: "user", - wantURL: "rtsp://user:@192.168.0.10:554/stream", + name: "username only", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Username: "user", + }, + wantURL: "rtsp://user:@192.168.0.10:554/stream", + }, + { + name: "http scheme", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Scheme: "http", + }, + wantURL: "http://192.168.0.10:554/stream", + wantParsedScheme: "rtsp", + }, + { + name: "https scheme", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Scheme: "https", + }, + wantURL: "https://192.168.0.10:554/stream", + wantParsedScheme: "rtsps", + }, + { + name: "rtsps scheme", + stream: cameradar.Stream{ + Address: netip.MustParseAddr("192.168.0.10"), + Port: 554, + Routes: []string{"stream"}, + Scheme: "rtsps", + }, + wantURL: "rtsps://192.168.0.10:554/stream", + wantParsedScheme: "rtsps", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - _, gotURL, err := buildRTSPURL(stream, test.route, test.username, test.password) - require.NoError(t, err) + gotURL := test.stream.String() require.Equal(t, test.wantURL, gotURL) + + parsedURL, err := test.stream.URL() + require.NoError(t, err) + + expectedURL, err := url.Parse(test.wantURL) + require.NoError(t, err) + wantParsedScheme := test.wantParsedScheme + if wantParsedScheme == "" { + wantParsedScheme = expectedURL.Scheme + } + require.Equal(t, wantParsedScheme, parsedURL.Scheme) }) } } diff --git a/internal/scan/masscan/scanner.go b/internal/scan/masscan/scanner.go index 9668db5..6f5877b 100644 --- a/internal/scan/masscan/scanner.go +++ b/internal/scan/masscan/scanner.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Ullaakut/cameradar/v6" + "github.com/Ullaakut/cameradar/v6/pkg/ports" masscanlib "github.com/Ullaakut/masscan" ) @@ -83,9 +84,12 @@ func runScan(ctx context.Context, runner Runner, reporter Reporter) ([]cameradar continue } + scheme := ports.InferTunnelScheme(uint16(port.Number), "") + streams = append(streams, cameradar.Stream{ Address: addr, Port: uint16(port.Number), + Scheme: scheme, }) } } diff --git a/internal/scan/masscan/scanner_test.go b/internal/scan/masscan/scanner_test.go index 7f912a6..d37c3ac 100644 --- a/internal/scan/masscan/scanner_test.go +++ b/internal/scan/masscan/scanner_test.go @@ -63,6 +63,31 @@ func TestRunScan(t *testing.T) { }, wantProgress: []string{"Found 2 RTSP streams"}, }, + { + name: "sets scheme for common HTTP ports", + result: &masscanlib.Run{ + Hosts: []masscanlib.Host{ + { + Address: "192.0.2.10", + Ports: []masscanlib.Port{ + {Number: 554, Status: "open"}, + {Number: 80, Status: "open"}, + {Number: 443, Status: "open"}, + {Number: 8080, Status: "open"}, + {Number: 8443, Status: "open"}, + }, + }, + }, + }, + wantStreams: []cameradar.Stream{ + {Address: netip.MustParseAddr("192.0.2.10"), Port: 554}, + {Address: netip.MustParseAddr("192.0.2.10"), Port: 80, Scheme: "http"}, + {Address: netip.MustParseAddr("192.0.2.10"), Port: 443, Scheme: "https"}, + {Address: netip.MustParseAddr("192.0.2.10"), Port: 8080, Scheme: "http"}, + {Address: netip.MustParseAddr("192.0.2.10"), Port: 8443, Scheme: "https"}, + }, + wantProgress: []string{"Found 5 RTSP streams"}, + }, { name: "returns error when scan fails", err: errors.New("scan failed"), diff --git a/internal/scan/nmap/scanner.go b/internal/scan/nmap/scanner.go index 4226a6e..1e66f55 100644 --- a/internal/scan/nmap/scanner.go +++ b/internal/scan/nmap/scanner.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/Ullaakut/cameradar/v6" + "github.com/Ullaakut/cameradar/v6/pkg/ports" nmaplib "github.com/Ullaakut/nmap/v4" ) @@ -67,7 +68,8 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S continue } - if !strings.Contains(port.Service.Name, "rtsp") { + isCandidate := streamCandidate(port.Service.Name, port.ID) + if !isCandidate { continue } @@ -78,10 +80,12 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S continue } + scheme := ports.InferTunnelScheme(port.ID, port.Service.Name) streams = append(streams, cameradar.Stream{ Device: port.Service.Product, Address: addr, Port: port.ID, + Scheme: scheme, }) } } @@ -104,3 +108,17 @@ func updateSummary(reporter Reporter, streams []cameradar.Stream) { } updater.UpdateSummary(streams) } + +// Extracting the classifying logic to an external function to avoid nesting if loops. +func streamCandidate(serviceName string, port uint16) bool { + serviceName = strings.ToLower(strings.TrimSpace(serviceName)) + if strings.Contains(serviceName, "rtsp") { + return true + } + + if ports.InferTunnelScheme(port, serviceName) != "" { + return true + } + + return false +} diff --git a/internal/scan/nmap/scanner_test.go b/internal/scan/nmap/scanner_test.go index 94ad0bb..85d0061 100644 --- a/internal/scan/nmap/scanner_test.go +++ b/internal/scan/nmap/scanner_test.go @@ -44,8 +44,56 @@ func TestScanner_Scan(t *testing.T) { Address: netip.MustParseAddr("127.0.0.1"), Port: 8554, }, + { + Device: "ACME", + Address: netip.MustParseAddr("127.0.0.1"), + Port: 80, + Scheme: "http", + }, }, - wantProgress: "Found 1 RTSP streams", + wantProgress: "Found 2 RTSP streams", + }, + { + name: "keeps rtsp and http candidates while filtering closed ports", + result: buildRun(nmaplib.Host{ + Addresses: []nmaplib.Address{ + {Addr: "127.0.0.1"}, + {Addr: "not-an-ip"}, + }, + Ports: []nmaplib.Port{ + openPort(8554, "rtsp", "ACME"), + closedPort(554, "rtsp", "ACME"), + openPort(80, "http", "ACME"), + openPort(9443, "https", "ACME"), + openPort(8443, "", "ACME"), + }, + }), + wantStreams: []cameradar.Stream{ + { + Device: "ACME", + Address: netip.MustParseAddr("127.0.0.1"), + Port: 8554, + }, + { + Device: "ACME", + Address: netip.MustParseAddr("127.0.0.1"), + Port: 80, + Scheme: "http", + }, + { + Device: "ACME", + Address: netip.MustParseAddr("127.0.0.1"), + Port: 9443, + Scheme: "https", + }, + { + Device: "ACME", + Address: netip.MustParseAddr("127.0.0.1"), + Port: 8443, + Scheme: "https", + }, + }, + wantProgress: "Found 4 RTSP streams", }, { name: "collects multiple hosts", diff --git a/internal/ui/summary.go b/internal/ui/summary.go index 9278146..c060e44 100644 --- a/internal/ui/summary.go +++ b/internal/ui/summary.go @@ -88,11 +88,9 @@ func formatStream(stream cameradar.Stream) string { builder.WriteString(" Routes: not found\n") } - if stream.CredentialsFound { + if stream.CredentialsFound || stream.AuthenticationType == cameradar.AuthNone { builder.WriteString(" Credentials: ") - builder.WriteString(stream.Username) - builder.WriteString(":") - builder.WriteString(stream.Password) + builder.WriteString(formatCredentials(stream)) builder.WriteString("\n") } else { builder.WriteString(" Credentials: not found\n") @@ -105,7 +103,7 @@ func formatStream(stream cameradar.Stream) string { builder.WriteString("no\n") } - if stream.RouteFound && stream.CredentialsFound { + if stream.RouteFound && (stream.CredentialsFound || stream.AuthenticationType == cameradar.AuthNone) { builder.WriteString(" RTSP URL: ") builder.WriteString(formatRTSPURL(stream)) builder.WriteString("\n") @@ -133,6 +131,14 @@ func formatAdminPanelURL(stream cameradar.Stream) string { return fmt.Sprintf("http://%s/", stream.Address.String()) } +func formatCredentials(stream cameradar.Stream) string { + if stream.Username == "" && stream.Password == "" { + return "none" + } + + return stream.Username + ":" + stream.Password +} + func authTypeLabel(auth cameradar.AuthType) string { switch auth { case cameradar.AuthNone: diff --git a/internal/ui/summary_test.go b/internal/ui/summary_test.go index 0b284fc..37e7e88 100644 --- a/internal/ui/summary_test.go +++ b/internal/ui/summary_test.go @@ -73,18 +73,41 @@ func TestFormatSummary(t *testing.T) { "Authentication: digest", "Routes: stream1, stream2", "Credentials: user:pass", + "Credentials: none", "RTSP URL: rtsp://user:pass@10.0.0.1:8554/stream1", "Admin panel: http://10.0.0.1/", "Admin panel: http://10.0.0.2/", }, wantNotContains: []string{ - "RTSP URL: rtsp://10.0.0.2", "Error:", }, orderedPairs: [][2]string{ {"• 10.0.0.1:8554", "• 10.0.0.2:554"}, }, }, + { + name: "empty discovered credentials render as none", + streams: []cameradar.Stream{ + { + Address: netip.MustParseAddr("10.0.0.4"), + Port: 554, + Available: true, + RouteFound: true, + Routes: []string{"stream"}, + CredentialsFound: true, + AuthenticationType: cameradar.AuthNone, + }, + }, + wantContains: []string{ + "Accessible streams: 1", + "Credentials: none", + "RTSP URL: rtsp://10.0.0.4:554/stream", + }, + wantNotContains: []string{ + "Credentials: :", + "rtsp://:@10.0.0.4:554/stream", + }, + }, } for _, test := range tests { diff --git a/internal/ui/tui.go b/internal/ui/tui.go index 27119ed..91a5a9a 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -542,7 +542,7 @@ func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilitySt credentials := emptyEntry if visibility.showCredentials && stream.CredentialsFound { - credentials = fmt.Sprintf("%s:%s", stream.Username, stream.Password) + credentials = formatCredentials(stream) } available := emptyEntry diff --git a/pkg/ports/ports.go b/pkg/ports/ports.go new file mode 100644 index 0000000..f375ff8 --- /dev/null +++ b/pkg/ports/ports.go @@ -0,0 +1,25 @@ +package ports + +import ( + "strings" +) + +// InferTunnelScheme returns the likely scheme for a given port and optional service name. +func InferTunnelScheme(port uint16, serviceName string) string { + if len(serviceName) > 0 { + name := strings.ToLower(strings.TrimSpace(serviceName)) + switch name { + case "rtsps", "https", "http": + return name + } + } + + switch port { + case 443, 8443: + return "https" + case 80, 8080: + return "http" + } + + return "" +} diff --git a/stream.go b/stream.go index c0142b9..d1f8964 100644 --- a/stream.go +++ b/stream.go @@ -1,7 +1,13 @@ package cameradar import ( + "net" "net/netip" + "net/url" + "strconv" + "strings" + + "github.com/bluenviron/gortsplib/v5/pkg/base" ) // AuthType represents the RTSP authentication method. @@ -15,7 +21,7 @@ const ( AuthDigest ) -// Stream represents a camera's RTSP stream. +// Stream represents a camera's stream, typically accessed over RTSP/RTSPS. type Stream struct { Device string `json:"device"` Username string `json:"username"` @@ -24,13 +30,33 @@ type Stream struct { Address netip.Addr `json:"address" validate:"required"` Port uint16 `json:"port" validate:"required"` - CredentialsFound bool `json:"credentials_found"` - RouteFound bool `json:"route_found"` - Available bool `json:"available"` + CredentialsFound bool `json:"credentials_found"` + RouteFound bool `json:"route_found"` + Available bool `json:"available"` + Scheme string `json:"scheme"` AuthenticationType AuthType `json:"authentication_type"` } +func (s Stream) resolvedScheme() string { + scheme := s.Scheme + if scheme == "" { + return "rtsp" + } + return scheme +} + +func parseScheme(scheme string) string { + switch scheme { + case "http": + return "rtsp" + case "https": + return "rtsps" + default: + return scheme + } +} + // Route returns this stream's route if there is one. func (s Stream) Route() string { if len(s.Routes) > 0 { @@ -38,3 +64,28 @@ func (s Stream) Route() string { } return "" } + +// String builds the stream URL using the configured scheme, defaulting to rtsp. +func (s Stream) String() string { + scheme := s.resolvedScheme() + + host := net.JoinHostPort(s.Address.String(), strconv.Itoa(int(s.Port))) + path := "/" + strings.TrimLeft(strings.TrimSpace(s.Route()), "/") + + u := &url.URL{ + Scheme: scheme, + Host: host, + Path: path, + } + if s.Username != "" || s.Password != "" { + u.User = url.UserPassword(s.Username, s.Password) + } + + return u.String() +} + +// URL parses the stream URL into a *base.URL, normalizing http/https to rtsp/rtsps. +func (s Stream) URL() (*base.URL, error) { + s.Scheme = parseScheme(s.resolvedScheme()) + return base.ParseURL(s.String()) +}