Compare commits

...

4 Commits

Author SHA1 Message Date
Brendan Le Glaunec af41fc6cb8 fix: no longer give up on detecting auth type when getting a 401 (#391) 2026-02-01 20:59:26 +01:00
Brendan Le Glaunec 777bd2a488 chore: resize cameradar logo (#387) 2026-01-28 14:35:30 +01:00
Brendan Le Glaunec 99c2e55ec4 docs: add GIF of new TUI (#386) 2026-01-27 23:27:01 +01:00
Brendan Le Glaunec bf720fcea2 fix: build and test workflows 2026-01-27 23:19:21 +01:00
14 changed files with 453 additions and 100 deletions
+26 -13
View File
@@ -14,30 +14,43 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# We need to set a cache marker to ensure that the cache is individual for each job.
- name: Add Cache Marker
run: echo "go-build" > env.txt
- name: Install Go - name: Install Go
id: install-go id: install-go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
cache-dependency-path: | cache: false
go.sum
env.txt - name: Cache Go mod
id: gomod
uses: actions/cache@v5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-mod-
- name: Cache Go build
uses: actions/cache@v5
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-${{ github.ref_name }}
restore-keys: |
${{ runner.os }}-go-build-
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
if: steps.install-go.outputs.cache-hit != 'true' if: steps.gomod.outputs.cache-hit != 'true'
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
env: env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
DOCKER_REPOSITORY: ullaakut/cameradar with:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} distribution: goreleaser
version: ${{ inputs.GORELEASER_VERSION }}
args: release --clean --snapshot --skip=docker
+9 -1
View File
@@ -42,7 +42,15 @@ jobs:
- name: Run Linter - name: Run Linter
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v8
with: with:
version: v2.10.2 version: v2.7.2
- name: Setup gotestsum
uses: gertd/action-gotestsum@v3.0.0
with:
gotestsum_version: v1.13.0
- name: Download nmap
run: sudo apt-get install -y nmap
- name: Run Tests - name: Run Tests
env: env:
+6 -3
View File
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" /> <img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
</a> </a>
<a href="https://github.com/Ullaakut/cameradar/actions"> <a href="https://github.com/Ullaakut/cameradar/actions">
<img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build" /> <img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build.yaml" />
</a> </a>
<a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'> <a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'>
<img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' /> <img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' />
@@ -24,10 +24,9 @@
</a> </a>
</p> </p>
## An RTSP stream access tool with a Go library ## RTSP stream access tool
Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes. Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes.
Use the CLI for end-to-end scanning or import the library in Go code.
### What Cameradar does ### What Cameradar does
@@ -56,6 +55,10 @@ Use the CLI for end-to-end scanning or import the library in Go code.
- [Examples](#examples) - [Examples](#examples)
- [License](#license) - [License](#license)
---
<p align="center"><img src="images/example.gif"/></p>
## Quick start with Docker ## Quick start with Docker
Install [Docker](https://docs.docker.com/engine/installation/) and run: Install [Docker](https://docs.docker.com/engine/installation/) and run:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+38 -65
View File
@@ -7,12 +7,14 @@ import (
"time" "time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base" "github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors" "github.com/bluenviron/gortsplib/v5/pkg/liberrors"
) )
// Route that should never be a constructor default. // Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42" const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords. // Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface { type Dictionary interface {
@@ -187,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
func needsReattack(streams []cameradar.Stream) bool { func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams { for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available { if stream.RouteFound && stream.CredentialsFound && stream.Available {
// This stream is fully discovered, no need to re-attack.
continue continue
} }
return true return true
@@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true target.RouteFound = true
target.Routes = append(target.Routes, "/") target.Routes = append(target.Routes, "") // Add empty route for default.
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port)) a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil return target, nil
} }
@@ -287,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
return target, nil return target, nil
} }
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
return stream, nil
}
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) u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil { if err != nil {
@@ -399,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
} }
defer client.Close() defer client.Close()
desc, res, err := client.Describe(u) desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
if err != nil { if err != nil {
return a.handleDescribeError(stream, urlStr, err) return a.handleDescribeError(stream, urlStr, err)
} }
@@ -413,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
if err != nil { if err != nil {
return a.handleSetupError(stream, urlStr, err) return a.handleSetupError(stream, urlStr, err)
} }
a.logSetupResponse(urlStr, res) a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK stream.Available = res != nil && res.StatusCode == base.StatusOK
@@ -424,9 +365,39 @@ 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) {
var (
desc *description.Session
res *base.Response
err error
)
for range 5 {
desc, res, err = client.Describe(u)
if err == nil {
return desc, res, nil
}
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))
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(time.Second):
}
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err)
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) { func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, 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.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,
@@ -436,6 +407,8 @@ 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))
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err) return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
} }
+2 -2
View File
@@ -214,7 +214,7 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
got, err := attacker.Attack(t.Context(), streams) got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err) require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods") assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1) require.Len(t, got, 1)
assert.False(t, got[0].RouteFound) assert.False(t, got[0].RouteFound)
} }
@@ -304,7 +304,7 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 1) require.Len(t, got, 1)
assert.True(t, got[0].RouteFound) assert.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes) assert.Equal(t, []string{""}, got[0].Routes)
assert.True(t, got[0].Available) assert.True(t, got[0].Available)
} }
+68
View File
@@ -0,0 +1,68 @@
package attack
import (
"context"
"fmt"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
)
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
case cameradar.AuthUnknown:
authMethod = "unknown"
default:
authMethod = fmt.Sprintf("unknown (%d)", streams[i].AuthenticationType)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
if err != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
stream.AuthenticationType = cameradar.AuthUnknown
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, 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
}
return stream, nil
}
+207
View File
@@ -0,0 +1,207 @@
package attack
import (
"bufio"
"fmt"
"net"
"net/netip"
"strings"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testDictionary struct {
routes []string
usernames []string
passwords []string
}
func (d testDictionary) Routes() []string {
return d.routes
}
func (d testDictionary) Usernames() []string {
return d.usernames
}
func (d testDictionary) Passwords() []string {
return d.passwords
}
func TestAuthTypeFromHeaders(t *testing.T) {
tests := []struct {
name string
values base.HeaderValue
want cameradar.AuthType
}{
{
name: "digest wins over basic",
values: base.HeaderValue{
headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal()[0],
headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal()[0],
},
want: cameradar.AuthDigest,
},
{
name: "basic auth",
values: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
want: cameradar.AuthBasic,
},
{
name: "digest auth",
values: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
want: cameradar.AuthDigest,
},
{
name: "unknown with empty values",
values: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown with unsupported header",
values: base.HeaderValue{"Bearer abc"},
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, authTypeFromHeaders(test.values))
})
}
}
func TestDetectAuthMethod(t *testing.T) {
tests := []struct {
name string
statusCode base.StatusCode
headers base.Header
want cameradar.AuthType
}{
{
name: "no auth when status ok",
statusCode: base.StatusOK,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthNone,
},
{
name: "basic auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthBasic,
},
{
name: "digest auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
},
want: cameradar.AuthDigest,
},
{
name: "unknown auth on unauthorized without www-authenticate",
statusCode: base.StatusUnauthorized,
headers: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown auth on other status",
statusCode: base.StatusNotFound,
headers: nil,
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPProbeServer(t, test.statusCode, test.headers)
attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
stream := cameradar.Stream{
Address: addr,
Port: port,
}
got, err := attacker.detectAuthMethod(t.Context(), stream)
require.NoError(t, err)
assert.Equal(t, test.want, got.AuthenticationType)
})
}
}
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
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 statusTextFromCode(code base.StatusCode) string {
switch code {
case base.StatusOK:
return "OK"
case base.StatusUnauthorized:
return "Unauthorized"
case base.StatusNotFound:
return "Not Found"
default:
return "Unknown"
}
}
+87 -6
View File
@@ -1,10 +1,16 @@
package attack package attack
import ( import (
"bufio"
"context"
"errors" "errors"
"fmt"
"net" "net"
"net/textproto"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5" "github.com/bluenviron/gortsplib/v5"
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
_, res, err := client.Describe(u) _, res, err := client.Describe(u)
if err != nil { if err != nil {
var badStatus liberrors.ErrClientBadStatusCode var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil { if errors.As(err, &badStatus) {
return badStatus.Code, nil return badStatus.Code, nil
} }
return 0, err return 0, err
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
return res.StatusCode, nil return res.StatusCode, nil
} }
// probeDescribeHeaders performs a manual DESCRIBE request and returns the status code and headers.
//
// 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) {
dialer := &net.Dialer{Timeout: a.timeout}
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
if err != nil {
return 0, nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(a.timeout)
}
err = conn.SetDeadline(deadline)
if err != nil {
return 0, nil, err
}
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.Host,
)
_, err = conn.Write([]byte(request))
if err != nil {
return 0, nil, err
}
reader := textproto.NewReader(bufio.NewReader(conn))
statusLine, err := reader.ReadLine()
if err != nil {
return 0, nil, err
}
fields := strings.Fields(statusLine)
if len(fields) < 2 {
return 0, nil, fmt.Errorf("invalid RTSP status line: %q", statusLine)
}
code, err := strconv.Atoi(fields[1])
if err != nil {
return 0, nil, fmt.Errorf("parsing RTSP status code %q: %w", fields[1], err)
}
mimeHeader, err := reader.ReadMIMEHeader()
if err != nil {
return 0, nil, err
}
headers := make(base.Header)
for key, values := range mimeHeader {
headers[key] = append(base.HeaderValue(nil), values...)
}
return base.StatusCode(code), headers, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType { func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 { if len(values) == 0 {
return cameradar.AuthNone return cameradar.AuthUnknown
} }
var hasBasic bool var hasBasic bool
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
var authHeader headers.Authenticate var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value}) err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil { if err != nil {
lower := strings.ToLower(value)
hasDigest = hasDigest || strings.Contains(lower, "digest")
hasBasic = hasBasic || strings.Contains(lower, "basic")
continue continue
} }
@@ -80,14 +149,26 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if hasBasic { if hasBasic {
return cameradar.AuthBasic return cameradar.AuthBasic
} }
return cameradar.AuthType(-1) return cameradar.AuthUnknown
}
func headerValues(header base.Header, name string) base.HeaderValue {
if header == nil {
return nil
}
for key, values := range header {
if strings.EqualFold(key, name) {
return values
}
}
return nil
} }
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) { func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port))) host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route path := strings.TrimSpace(route)
if route == "" { if path != "" && !strings.HasPrefix(path, "/") {
path = "/" path = "/" + path
} }
u := &url.URL{ u := &url.URL{
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main 0/1:1/main
0/usrnm:pwd/main 0/usrnm:pwd/main
0/video1 0/video1
+6 -3
View File
@@ -13,7 +13,6 @@ import (
func TestNew_ExpandsTargetsAndPorts(t *testing.T) { func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
targets := []string{ targets := []string{
"192.0.2.0/30", "192.0.2.0/30",
"localhost",
"192.0.2.15", "192.0.2.15",
"192.0.2.10-11", "192.0.2.10-11",
} }
@@ -25,7 +24,6 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
addrs := []netip.Addr{ addrs := []netip.Addr{
netip.MustParseAddr("127.0.0.1"),
netip.MustParseAddr("192.0.2.0"), netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"), netip.MustParseAddr("192.0.2.2"),
@@ -92,7 +90,12 @@ func TestNew_ResolvesHostnames(t *testing.T) {
streams, err := scanner.Scan(t.Context()) streams, err := scanner.Scan(t.Context())
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, streams) require.NotEmpty(t, streams)
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address) addr := streams[0].Address
assert.True(t,
addr == netip.MustParseAddr("127.0.0.1") || addr == netip.MustParseAddr("::1"),
"expected localhost to resolve to 127.0.0.1 or ::1, got %s",
addr.String(),
)
} }
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) { func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
+2 -1
View File
@@ -9,7 +9,8 @@ type AuthType int
// Supported authentication methods. // Supported authentication methods.
const ( const (
AuthNone AuthType = iota AuthUnknown AuthType = iota
AuthNone
AuthBasic AuthBasic
AuthDigest AuthDigest
) )
-5
View File
@@ -1,5 +0,0 @@
#EXTM3U
#EXTINF:-1,127.0.0.1:8554 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8554/live.sdp
#EXTINF:-1,127.0.0.1:8555 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8555/live.sdp