feat: support http tunneled rtsp (#419)
* enhancement: supporting http tunneled rtsp * refactor: simplify HTTP tunnel support per review feedback - Extract streamCandidate() for nmap port classification - Add isCommonHTTPPort() for masscan and nmap fallback - Move URL building to Stream.String() and Stream.URL() - Pass Stream directly to attack methods instead of individual args - Add TLS config for HTTPS tunnel support - Make auth detection non-fatal for tunneled streams - Rename HTTPTunnel to UseHTTPTunnel * - Testing the auth workflow for the tunneled streams is not blocking the rest of the pipeline since I changed the return values to Auth unknown and nil - added extra ports in the test according to suggestions * fixing some lint errors * removing the unused buildrtspurl * delayed the urlstream call for clarity removed error messages refactored the test that used the deprecated buildTRSPurl to use stream.URL and stream.String() methods * extracting iscommonHTTP port to pkg/ports (package ports) switching on u.scheme to create proper schemes for http and https * refactor: replace HTTP tunnel bool with scheme-based detection; enable TLS only for HTTPS-tunneled streams * chore: simnplify InferTunnelScheme and newRTSPClient * fix: remove rendundant check in streamCandidate * fix: typo in parseScheme * tests: coverage for new schemes * fix: use RTSP and not RTSPS for HTTPS URLs * fix: tunneled RTSP scheme handling and auth detection fallback * ui: render empty credentials as none in summary and TUI * chore: ignore duplicate-string warning for none literal * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: rtsps probe headers --------- Co-authored-by: Brendan Le Glaunec <brendan@glaulabs.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
14dcb74e89
commit
8531c006d4
@@ -46,6 +46,8 @@ linters:
|
|||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
rules:
|
rules:
|
||||||
|
- path: (.+)\.go$
|
||||||
|
text: 'string `none` has (.+) occurrences, make it a constant'
|
||||||
- path: (.+)\.go$
|
- path: (.+)\.go$
|
||||||
text: 'ST1000: at least one file in a package should have a package comment'
|
text: 'ST1000: at least one file in a package should have a package comment'
|
||||||
- path: (.+)\.go$
|
- path: (.+)\.go$
|
||||||
|
|||||||
+31
-39
@@ -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) {
|
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 {
|
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)
|
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", stream, code))
|
||||||
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))
|
|
||||||
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
|
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
|
||||||
return access, nil
|
return access, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
|
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 {
|
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)
|
a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", stream, code))
|
||||||
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))
|
|
||||||
return code == base.StatusOK || code == base.StatusNotFound, nil
|
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()
|
return stream, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
u, urlStr, err := buildRTSPURL(stream, stream.Route(), stream.Username, stream.Password)
|
client, err := a.newRTSPClient(stream)
|
||||||
if err != nil {
|
|
||||||
return stream, fmt.Errorf("building rtsp url: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := a.newRTSPClient(u)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stream, fmt.Errorf("starting rtsp client: %w", err)
|
return stream, fmt.Errorf("starting rtsp client: %w", err)
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
|
desc, res, err := a.describeWithRetry(ctx, client, stream)
|
||||||
if err != nil {
|
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 {
|
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)
|
res, err = client.Setup(desc.BaseURL, desc.Medias[0], 0, 0)
|
||||||
if err != nil {
|
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
|
stream.Available = res != nil && res.StatusCode == base.StatusOK
|
||||||
if stream.Available {
|
if stream.Available {
|
||||||
@@ -365,11 +353,15 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
|
|||||||
return stream, nil
|
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 (
|
var (
|
||||||
desc *description.Session
|
desc *description.Session
|
||||||
res *base.Response
|
res *base.Response
|
||||||
err error
|
|
||||||
)
|
)
|
||||||
for range 5 {
|
for range 5 {
|
||||||
desc, res, err = client.Describe(u)
|
desc, res, err = client.Describe(u)
|
||||||
@@ -379,7 +371,7 @@ func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Clien
|
|||||||
|
|
||||||
var badStatus liberrors.ErrClientBadStatusCode
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
|
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 {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, nil, ctx.Err()
|
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, 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
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
|
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)",
|
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
|
||||||
stream.Address.String(),
|
stream.Address.String(),
|
||||||
stream.Port,
|
stream.Port,
|
||||||
@@ -407,20 +399,20 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er
|
|||||||
return stream, nil
|
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
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
if errors.As(err, &badStatus) {
|
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
|
stream.Available = badStatus.Code == base.StatusOK
|
||||||
return stream, nil
|
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) {
|
func (a Attacker) logDescribeResponse(urlStr string, res *base.Response) {
|
||||||
|
|||||||
@@ -41,28 +41,44 @@ func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream)
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return stream, ctx.Err()
|
return stream, ctx.Err()
|
||||||
}
|
}
|
||||||
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
|
u, err := stream.URL()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stream, fmt.Errorf("building rtsp url: %w", err)
|
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 {
|
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
|
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")
|
values := headerValues(headers, "WWW-Authenticate")
|
||||||
switch statusCode {
|
stream.AuthenticationType = authTypeFromStatus(statusCode, values)
|
||||||
case base.StatusOK:
|
|
||||||
stream.AuthenticationType = cameradar.AuthNone
|
|
||||||
case base.StatusUnauthorized:
|
|
||||||
stream.AuthenticationType = authTypeFromHeaders(values)
|
|
||||||
default:
|
|
||||||
stream.AuthenticationType = cameradar.AuthUnknown
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream, nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ package attack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"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) {
|
func TestDetectAuthMethod(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
|
||||||
t.Helper()
|
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)
|
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 {
|
func statusTextFromCode(code base.StatusCode) string {
|
||||||
switch code {
|
switch code {
|
||||||
case base.StatusOK:
|
case base.StatusOK:
|
||||||
|
|||||||
+62
-32
@@ -3,11 +3,11 @@ package attack
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,15 +19,46 @@ import (
|
|||||||
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
|
"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{
|
client := &gortsplib.Client{
|
||||||
ReadTimeout: a.timeout,
|
ReadTimeout: a.timeout,
|
||||||
WriteTimeout: 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -35,8 +66,13 @@ func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) {
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
|
func (a Attacker) describeStatus(stream cameradar.Stream) (base.StatusCode, error) {
|
||||||
client, err := a.newRTSPClient(u)
|
u, err := stream.URL()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("building rtsp url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := a.newRTSPClient(stream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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,
|
// 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.
|
// 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}
|
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 {
|
if err != nil {
|
||||||
return 0, nil, err
|
return 0, nil, err
|
||||||
}
|
}
|
||||||
@@ -81,7 +133,7 @@ func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr
|
|||||||
|
|
||||||
request := fmt.Sprintf(
|
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",
|
"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,
|
u.Host,
|
||||||
)
|
)
|
||||||
_, err = conn.Write([]byte(request))
|
_, err = conn.Write([]byte(request))
|
||||||
@@ -163,25 +215,3 @@ func headerValues(header base.Header, name string) base.HeaderValue {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,85 +2,163 @@ package attack
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildRTSPURL(t *testing.T) {
|
func TestStreamURL(t *testing.T) {
|
||||||
stream := cameradar.Stream{
|
|
||||||
Address: netip.MustParseAddr("192.168.0.10"),
|
|
||||||
Port: 554,
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
route string
|
stream cameradar.Stream
|
||||||
username string
|
wantURL string
|
||||||
password string
|
wantParsedScheme string
|
||||||
wantURL 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/",
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "root route",
|
name: "root route",
|
||||||
route: "/",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{"/"},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/",
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple leading slashes",
|
name: "multiple leading slashes",
|
||||||
route: "////",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{"////"},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/",
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "route with no leading slash",
|
name: "route with no leading slash",
|
||||||
route: "stream",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{"stream"},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/stream",
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "route with leading slash",
|
name: "route with leading slash",
|
||||||
route: "/stream",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{"/stream"},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/stream",
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "route with trailing slash",
|
name: "route with trailing slash",
|
||||||
route: "stream/",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{"stream/"},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/stream/",
|
wantURL: "rtsp://192.168.0.10:554/stream/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "route with spaces",
|
name: "route with spaces",
|
||||||
route: " /stream ",
|
stream: cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
Routes: []string{" /stream "},
|
||||||
|
},
|
||||||
wantURL: "rtsp://192.168.0.10:554/stream",
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "username and password",
|
name: "username and password",
|
||||||
route: "stream",
|
stream: cameradar.Stream{
|
||||||
username: "admin",
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
password: "admin123",
|
Port: 554,
|
||||||
wantURL: "rtsp://admin:admin123@192.168.0.10:554/stream",
|
Routes: []string{"stream"},
|
||||||
|
Username: "admin",
|
||||||
|
Password: "admin123",
|
||||||
|
},
|
||||||
|
wantURL: "rtsp://admin:admin123@192.168.0.10:554/stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty username with password",
|
name: "empty username with password",
|
||||||
route: "stream",
|
stream: cameradar.Stream{
|
||||||
password: "pass",
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
wantURL: "rtsp://:pass@192.168.0.10:554/stream",
|
Port: 554,
|
||||||
|
Routes: []string{"stream"},
|
||||||
|
Password: "pass",
|
||||||
|
},
|
||||||
|
wantURL: "rtsp://:pass@192.168.0.10:554/stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "username only",
|
name: "username only",
|
||||||
route: "stream",
|
stream: cameradar.Stream{
|
||||||
username: "user",
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
wantURL: "rtsp://user:@192.168.0.10:554/stream",
|
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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, gotURL, err := buildRTSPURL(stream, test.route, test.username, test.password)
|
gotURL := test.stream.String()
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, test.wantURL, gotURL)
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/Ullaakut/cameradar/v6/pkg/ports"
|
||||||
masscanlib "github.com/Ullaakut/masscan"
|
masscanlib "github.com/Ullaakut/masscan"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,9 +84,12 @@ func runScan(ctx context.Context, runner Runner, reporter Reporter) ([]cameradar
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheme := ports.InferTunnelScheme(uint16(port.Number), "")
|
||||||
|
|
||||||
streams = append(streams, cameradar.Stream{
|
streams = append(streams, cameradar.Stream{
|
||||||
Address: addr,
|
Address: addr,
|
||||||
Port: uint16(port.Number),
|
Port: uint16(port.Number),
|
||||||
|
Scheme: scheme,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ func TestRunScan(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantProgress: []string{"Found 2 RTSP streams"},
|
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",
|
name: "returns error when scan fails",
|
||||||
err: errors.New("scan failed"),
|
err: errors.New("scan failed"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/Ullaakut/cameradar/v6/pkg/ports"
|
||||||
nmaplib "github.com/Ullaakut/nmap/v4"
|
nmaplib "github.com/Ullaakut/nmap/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +68,8 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(port.Service.Name, "rtsp") {
|
isCandidate := streamCandidate(port.Service.Name, port.ID)
|
||||||
|
if !isCandidate {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +80,12 @@ func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.S
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheme := ports.InferTunnelScheme(port.ID, port.Service.Name)
|
||||||
streams = append(streams, cameradar.Stream{
|
streams = append(streams, cameradar.Stream{
|
||||||
Device: port.Service.Product,
|
Device: port.Service.Product,
|
||||||
Address: addr,
|
Address: addr,
|
||||||
Port: port.ID,
|
Port: port.ID,
|
||||||
|
Scheme: scheme,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,3 +108,17 @@ func updateSummary(reporter Reporter, streams []cameradar.Stream) {
|
|||||||
}
|
}
|
||||||
updater.UpdateSummary(streams)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,8 +44,56 @@ func TestScanner_Scan(t *testing.T) {
|
|||||||
Address: netip.MustParseAddr("127.0.0.1"),
|
Address: netip.MustParseAddr("127.0.0.1"),
|
||||||
Port: 8554,
|
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",
|
name: "collects multiple hosts",
|
||||||
|
|||||||
+11
-5
@@ -88,11 +88,9 @@ func formatStream(stream cameradar.Stream) string {
|
|||||||
builder.WriteString(" Routes: not found\n")
|
builder.WriteString(" Routes: not found\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if stream.CredentialsFound {
|
if stream.CredentialsFound || stream.AuthenticationType == cameradar.AuthNone {
|
||||||
builder.WriteString(" Credentials: ")
|
builder.WriteString(" Credentials: ")
|
||||||
builder.WriteString(stream.Username)
|
builder.WriteString(formatCredentials(stream))
|
||||||
builder.WriteString(":")
|
|
||||||
builder.WriteString(stream.Password)
|
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
} else {
|
} else {
|
||||||
builder.WriteString(" Credentials: not found\n")
|
builder.WriteString(" Credentials: not found\n")
|
||||||
@@ -105,7 +103,7 @@ func formatStream(stream cameradar.Stream) string {
|
|||||||
builder.WriteString("no\n")
|
builder.WriteString("no\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if stream.RouteFound && stream.CredentialsFound {
|
if stream.RouteFound && (stream.CredentialsFound || stream.AuthenticationType == cameradar.AuthNone) {
|
||||||
builder.WriteString(" RTSP URL: ")
|
builder.WriteString(" RTSP URL: ")
|
||||||
builder.WriteString(formatRTSPURL(stream))
|
builder.WriteString(formatRTSPURL(stream))
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
@@ -133,6 +131,14 @@ func formatAdminPanelURL(stream cameradar.Stream) string {
|
|||||||
return fmt.Sprintf("http://%s/", stream.Address.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 {
|
func authTypeLabel(auth cameradar.AuthType) string {
|
||||||
switch auth {
|
switch auth {
|
||||||
case cameradar.AuthNone:
|
case cameradar.AuthNone:
|
||||||
|
|||||||
@@ -73,18 +73,41 @@ func TestFormatSummary(t *testing.T) {
|
|||||||
"Authentication: digest",
|
"Authentication: digest",
|
||||||
"Routes: stream1, stream2",
|
"Routes: stream1, stream2",
|
||||||
"Credentials: user:pass",
|
"Credentials: user:pass",
|
||||||
|
"Credentials: none",
|
||||||
"RTSP URL: rtsp://user:pass@10.0.0.1:8554/stream1",
|
"RTSP URL: rtsp://user:pass@10.0.0.1:8554/stream1",
|
||||||
"Admin panel: http://10.0.0.1/",
|
"Admin panel: http://10.0.0.1/",
|
||||||
"Admin panel: http://10.0.0.2/",
|
"Admin panel: http://10.0.0.2/",
|
||||||
},
|
},
|
||||||
wantNotContains: []string{
|
wantNotContains: []string{
|
||||||
"RTSP URL: rtsp://10.0.0.2",
|
|
||||||
"Error:",
|
"Error:",
|
||||||
},
|
},
|
||||||
orderedPairs: [][2]string{
|
orderedPairs: [][2]string{
|
||||||
{"• 10.0.0.1:8554", "• 10.0.0.2:554"},
|
{"• 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 {
|
for _, test := range tests {
|
||||||
|
|||||||
+1
-1
@@ -542,7 +542,7 @@ func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilitySt
|
|||||||
|
|
||||||
credentials := emptyEntry
|
credentials := emptyEntry
|
||||||
if visibility.showCredentials && stream.CredentialsFound {
|
if visibility.showCredentials && stream.CredentialsFound {
|
||||||
credentials = fmt.Sprintf("%s:%s", stream.Username, stream.Password)
|
credentials = formatCredentials(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
available := emptyEntry
|
available := emptyEntry
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package cameradar
|
package cameradar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthType represents the RTSP authentication method.
|
// AuthType represents the RTSP authentication method.
|
||||||
@@ -15,7 +21,7 @@ const (
|
|||||||
AuthDigest
|
AuthDigest
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stream represents a camera's RTSP stream.
|
// Stream represents a camera's stream, typically accessed over RTSP/RTSPS.
|
||||||
type Stream struct {
|
type Stream struct {
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -24,13 +30,33 @@ type Stream struct {
|
|||||||
Address netip.Addr `json:"address" validate:"required"`
|
Address netip.Addr `json:"address" validate:"required"`
|
||||||
Port uint16 `json:"port" validate:"required"`
|
Port uint16 `json:"port" validate:"required"`
|
||||||
|
|
||||||
CredentialsFound bool `json:"credentials_found"`
|
CredentialsFound bool `json:"credentials_found"`
|
||||||
RouteFound bool `json:"route_found"`
|
RouteFound bool `json:"route_found"`
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
|
|
||||||
AuthenticationType AuthType `json:"authentication_type"`
|
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.
|
// Route returns this stream's route if there is one.
|
||||||
func (s Stream) Route() string {
|
func (s Stream) Route() string {
|
||||||
if len(s.Routes) > 0 {
|
if len(s.Routes) > 0 {
|
||||||
@@ -38,3 +64,28 @@ func (s Stream) Route() string {
|
|||||||
}
|
}
|
||||||
return ""
|
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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user