Compare commits

...

5 Commits

Author SHA1 Message Date
Brendan Le Glaunec f192139cc3 feat: small UI improvements (#394) 2026-02-01 22:30:55 +01:00
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
25 changed files with 855 additions and 129 deletions
+26 -13
View File
@@ -14,30 +14,43 @@ jobs:
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
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
id: install-go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache-dependency-path: |
go.sum
env.txt
cache: false
- 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
run: go mod download
if: steps.install-go.outputs.cache-hit != 'true'
if: steps.gomod.outputs.cache-hit != 'true'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
DOCKER_REPOSITORY: ullaakut/cameradar
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
with:
distribution: goreleaser
version: ${{ inputs.GORELEASER_VERSION }}
args: release --clean --snapshot --skip=docker
+9 -1
View File
@@ -42,7 +42,15 @@ jobs:
- name: Run Linter
uses: golangci/golangci-lint-action@v8
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
env:
+11 -11
View File
@@ -46,21 +46,21 @@ archives:
dockers:
- image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:latest-amd64"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: amd64
- image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:latest-386"
dockerfile: Dockerfile
use: buildx
goos: linux
goarch: 386
- image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:latest-armv6"
dockerfile: Dockerfile
use: buildx
@@ -68,7 +68,7 @@ dockers:
goarch: arm
goarm: 6
- image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:latest-armv7"
dockerfile: Dockerfile
use: buildx
@@ -76,7 +76,7 @@ dockers:
goarch: arm
goarm: 7
- image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:latest-arm64"
dockerfile: Dockerfile
use: buildx
@@ -84,13 +84,13 @@ dockers:
goarch: arm64
docker_manifests:
- name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}"
- name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}"
image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- name_template: "ullaakut/{{ .ProjectName }}:latest"
image_templates:
- "ullaakut/{{ .ProjectName }}:latest-amd64"
+6 -3
View File
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
</a>
<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 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' />
@@ -24,10 +24,9 @@
</a>
</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.
Use the CLI for end-to-end scanning or import the library in Go code.
### What Cameradar does
@@ -56,6 +55,10 @@ Use the CLI for end-to-end scanning or import the library in Go code.
- [Examples](#examples)
- [License](#license)
---
<p align="center"><img src="images/example.gif"/></p>
## Quick start with Docker
Install [Docker](https://docs.docker.com/engine/installation/) and run:
+71 -1
View File
@@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack"
@@ -17,7 +19,11 @@ import (
"golang.org/x/term"
)
//nolint:cyclop // Splitting this function does not make it clearer.
func runCameradar(ctx context.Context, cmd *cli.Command) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
targetInputs := cmd.StringSlice(flagTargets)
if len(targetInputs) == 0 {
return errors.New("at least one target must be specified")
@@ -60,10 +66,27 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
}
interactive := isInteractiveTerminal()
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive)
buildInfo := ui.BuildInfo{Version: version, Commit: commit, Date: date}
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive, buildInfo, cancel)
if err != nil {
return err
}
if plainReporter, ok := reporter.(*ui.PlainReporter); ok {
resolvedMode := resolveMode(mode, interactive)
plainReporter.PrintStartup(buildInfo, buildStartupOptions(
targets,
ports,
routesPath,
credsPath,
outputPath,
cmd.Int16(flagScanSpeed),
cmd.Duration(flagAttackInterval),
cmd.Duration(flagTimeout),
cmd.Bool(flagSkipScan),
cmd.Bool(flagDebug),
resolvedMode,
))
}
if outputPath != "" {
reporter = output.NewM3UReporter(reporter, outputPath)
}
@@ -102,6 +125,53 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
return c.Run(ctx)
}
func resolveMode(mode cameradar.Mode, interactive bool) cameradar.Mode {
if mode != cameradar.ModeAuto {
return mode
}
if interactive {
return cameradar.ModeTUI
}
return cameradar.ModePlain
}
func buildStartupOptions(
targets []string,
ports []string,
routesPath string,
credsPath string,
outputPath string,
scanSpeed int16,
attackInterval time.Duration,
timeout time.Duration,
skipScan bool,
debug bool,
mode cameradar.Mode,
) []string {
options := []string{
"targets: " + strings.Join(targets, ", "),
"ports: " + strings.Join(ports, ", "),
"custom-routes: " + fallbackValue(routesPath, "builtin"),
"custom-credentials: " + fallbackValue(credsPath, "builtin"),
"scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10),
"skip-scan: " + strconv.FormatBool(skipScan),
"attack-interval: " + attackInterval.String(),
"timeout: " + timeout.String(),
"debug: " + strconv.FormatBool(debug),
"ui: " + string(mode),
"output: " + fallbackValue(outputPath, "disabled"),
}
return options
}
func fallbackValue(value, fallback string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return fallback
}
return trimmed
}
func isInteractiveTerminal() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) {
return false
+9 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
@@ -28,7 +29,11 @@ const (
flagOutput = "output"
)
var version = "dev"
var (
version = "dev"
commit = "none"
date = "unknown"
)
var flags = cmd.Flags{
&cli.StringSliceFlag{
@@ -128,6 +133,9 @@ func realMain() (code int) {
err := app.Run(ctx, os.Args)
if err != nil {
if errors.Is(err, context.Canceled) {
return 1
}
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
return 1
}
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"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
// Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42"
const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
@@ -187,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available {
// This stream is fully discovered, no need to re-attack.
continue
}
return true
@@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
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))
return target, nil
}
@@ -287,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
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) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil {
@@ -399,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
}
defer client.Close()
desc, res, err := client.Describe(u)
desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
if err != nil {
return a.handleDescribeError(stream, urlStr, err)
}
@@ -413,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
if err != nil {
return a.handleSetupError(stream, urlStr, err)
}
a.logSetupResponse(urlStr, res)
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
}
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) {
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.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(),
stream.Port,
@@ -436,6 +407,8 @@ 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))
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)
require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods")
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.False(t, got[0].RouteFound)
}
@@ -304,7 +304,7 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
require.NoError(t, err)
require.Len(t, got, 1)
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)
}
+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
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/textproto"
"net/url"
"strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil {
if errors.As(err, &badStatus) {
return badStatus.Code, nil
}
return 0, err
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
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 {
if len(values) == 0 {
return cameradar.AuthNone
return cameradar.AuthUnknown
}
var hasBasic bool
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil {
lower := strings.ToLower(value)
hasDigest = hasDigest || strings.Contains(lower, "digest")
hasBasic = hasBasic || strings.Contains(lower, "basic")
continue
}
@@ -80,14 +149,26 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if hasBasic {
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) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route
if route == "" {
path = "/"
path := strings.TrimSpace(route)
if path != "" && !strings.HasPrefix(path, "/") {
path = "/" + path
}
u := &url.URL{
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main
0/usrnm:pwd/main
0/video1
+6 -3
View File
@@ -13,7 +13,6 @@ import (
func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
targets := []string{
"192.0.2.0/30",
"localhost",
"192.0.2.15",
"192.0.2.10-11",
}
@@ -25,7 +24,6 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
require.NoError(t, err)
addrs := []netip.Addr{
netip.MustParseAddr("127.0.0.1"),
netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"),
@@ -92,7 +90,12 @@ func TestNew_ResolvesHostnames(t *testing.T) {
streams, err := scanner.Scan(t.Context())
require.NoError(t, err)
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) {
+48
View File
@@ -0,0 +1,48 @@
package ui
import "strings"
// BuildInfo represents build metadata injected at link time.
type BuildInfo struct {
Version string
Commit string
Date string
}
// DisplayVersion returns the version prefixed with "v" when needed.
func (b BuildInfo) DisplayVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
version = "dev"
}
if strings.HasPrefix(version, "v") {
return version
}
return "v" + version
}
// LogVersion returns the version without a leading "v".
func (b BuildInfo) LogVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
return "dev"
}
return strings.TrimPrefix(version, "v")
}
// ShortCommit returns a shortened commit hash suitable for display.
func (b BuildInfo) ShortCommit() string {
commit := strings.TrimSpace(b.Commit)
if commit == "" || commit == "none" || commit == "unknown" {
return "unknown"
}
if len(commit) > 7 {
return commit[:7]
}
return commit
}
// TUIHeader returns the header used by the TUI.
func (b BuildInfo) TUIHeader() string {
return "Cameradar — " + b.DisplayVersion() + " (" + b.ShortCommit() + ")"
}
+175
View File
@@ -0,0 +1,175 @@
package ui_test
import (
"testing"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestBuildInfo_DisplayVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev with prefix",
version: "",
want: "vdev",
},
{
name: "dev without prefix",
version: "dev",
want: "vdev",
},
{
name: "already prefixed",
version: "v1.2.3",
want: "v1.2.3",
},
{
name: "adds prefix",
version: "1.2.3",
want: "v1.2.3",
},
{
name: "trims spaces with prefix",
version: " v2.0 ",
want: "v2.0",
},
{
name: "trims spaces without prefix",
version: " 2.0 ",
want: "v2.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.DisplayVersion())
})
}
}
func TestBuildInfo_LogVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev",
version: "",
want: "dev",
},
{
name: "removes leading v",
version: "v1.2.3",
want: "1.2.3",
},
{
name: "keeps version without prefix",
version: "1.2.3",
want: "1.2.3",
},
{
name: "trims spaces and removes prefix",
version: " v2.0 ",
want: "2.0",
},
{
name: "removes only first prefix",
version: "vv1",
want: "v1",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.LogVersion())
})
}
}
func TestBuildInfo_ShortCommit(t *testing.T) {
tests := []struct {
name string
commit string
want string
}{
{
name: "empty defaults to unknown",
commit: "",
want: "unknown",
},
{
name: "none defaults to unknown",
commit: "none",
want: "unknown",
},
{
name: "unknown defaults to unknown",
commit: "unknown",
want: "unknown",
},
{
name: "short commit preserved",
commit: "abcdef",
want: "abcdef",
},
{
name: "seven chars preserved",
commit: "abcdefg",
want: "abcdefg",
},
{
name: "long commit shortened",
commit: "abcdefghi",
want: "abcdefg",
},
{
name: "trims spaces before shortening",
commit: " 1234567890 ",
want: "1234567",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Commit: test.commit}
assert.Equal(t, test.want, info.ShortCommit())
})
}
}
func TestBuildInfo_TUIHeader(t *testing.T) {
tests := []struct {
name string
version string
commit string
want string
}{
{
name: "uses display version and short commit",
version: "1.2.3",
commit: "abcdefghi",
want: "Cameradar — v1.2.3 (abcdefg)",
},
{
name: "uses defaults for empty values",
version: "",
commit: "",
want: "Cameradar — vdev (unknown)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version, Commit: test.commit}
assert.Equal(t, test.want, info.TUIHeader())
})
}
}
+33 -4
View File
@@ -22,9 +22,22 @@ func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
}
}
// PrintStartup prints build metadata and configuration options.
func (r *PlainReporter) PrintStartup(buildInfo BuildInfo, options []string) {
step := cameradar.Step("Startup")
message := fmt.Sprintf("Running cameradar version %s, commit %s", buildInfo.LogVersion(), buildInfo.ShortCommit())
r.print(step, "INFO", message)
if len(options) == 0 {
return
}
for _, option := range options {
r.print(step, "INFO", option)
}
}
// Start prints the beginning of a step.
func (r *PlainReporter) Start(step cameradar.Step, message string) {
r.print(step, "START", message)
r.print(step, "STEP", message)
}
// Done prints the completion of a step.
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
if !r.debug {
return
}
r.print(step, "DEBUG", message)
r.print(step, "DBUG", message)
}
// Error prints an error message.
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
if err == nil {
return
}
r.print(step, "ERROR", err.Error())
r.print(step, "EROR", err.Error())
}
// Summary prints the final summary.
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
return
}
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339))
level = normalizeLevel(level)
_, _ = fmt.Fprintf(r.out, "%s [%s] %s: %s\n", time.Now().Format(time.RFC3339), level, cameradar.StepLabel(step), message)
}
func normalizeLevel(level string) string {
switch level {
case "DEBUG":
return "DBUG"
case "ERROR":
return "EROR"
case "START", "STEP":
return "STEP"
}
if len(level) >= 4 {
return level[:4]
}
return fmt.Sprintf("%-4s", level)
}
+34 -6
View File
@@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Summary([]cameradar.Stream{}, nil)
content := out.String()
assert.Contains(t, content, "[START] Scan targets: starting")
assert.Contains(t, content, "[INFO] Scan targets: working")
assert.Contains(t, content, "[DEBUG] Scan targets: details")
assert.Contains(t, content, "[DONE] Scan targets: finished")
assert.Contains(t, content, "[ERROR] Scan targets: boom")
assert.Contains(t, content, " [STEP] Scan targets: starting")
assert.Contains(t, content, " [INFO] Scan targets: working")
assert.Contains(t, content, " [DBUG] Scan targets: details")
assert.Contains(t, content, " [DONE] Scan targets: finished")
assert.Contains(t, content, " [EROR] Scan targets: boom")
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
})
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Error(cameradar.StepScan, nil)
content := out.String()
assert.NotContains(t, content, "DEBUG")
assert.NotContains(t, content, "DBUG")
assert.Equal(t, "", strings.TrimSpace(content))
})
}
func TestPlainReporter_PrintStartup(t *testing.T) {
t.Run("prints build info and options", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "v1.2.3", Commit: "abcdefghi"}, []string{
"targets: 127.0.0.1",
"ports: 554",
})
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version 1.2.3, commit abcdefg")
assert.Contains(t, content, " [INFO] Startup: targets: 127.0.0.1")
assert.Contains(t, content, " [INFO] Startup: ports: 554")
})
t.Run("prints only build info when options empty", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "", Commit: "none"}, nil)
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version dev, commit unknown")
assert.Equal(t, 1, strings.Count(content, " Startup: "))
})
}
+4 -3
View File
@@ -1,6 +1,7 @@
package ui
import (
"context"
"errors"
"fmt"
"io"
@@ -20,7 +21,7 @@ type Reporter interface {
}
// NewReporter creates a Reporter based on the requested mode.
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) {
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool, buildInfo BuildInfo, cancel context.CancelFunc) (Reporter, error) {
if debug {
return NewPlainReporter(out, debug), nil
}
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
if !interactive {
return nil, errors.New("tui mode requires an interactive terminal")
}
return NewTUIReporter(debug, out)
return NewTUIReporter(debug, out, buildInfo, cancel)
case cameradar.ModeAuto:
if interactive {
return NewTUIReporter(debug, out)
return NewTUIReporter(debug, out, buildInfo, cancel)
}
return NewPlainReporter(out, debug), nil
default:
+1 -1
View File
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
out := &bytes.Buffer{}
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive)
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive, ui.BuildInfo{Version: "dev", Commit: "none"}, func() {})
if test.wantErrContains != "" {
require.Error(t, err)
+12 -1
View File
@@ -1,6 +1,7 @@
package ui
import (
"context"
"strings"
"github.com/Ullaakut/cameradar/v6"
@@ -16,6 +17,8 @@ type modelState struct {
summary []summaryTable
summaryStreams []cameradar.Stream
summaryFinal bool
buildInfo BuildInfo
cancel context.CancelFunc
debug bool
spinner spinner.Model
progress progress.Model
@@ -45,6 +48,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.handleProgressMsg(typed)
case closeMsg:
m.quitting = true
case tea.KeyMsg:
if typed.Type == tea.KeyCtrlC {
if m.cancel != nil {
m.cancel()
}
m.quitting = true
return m, tea.Quit
}
case spinner.TickMsg:
cmds = m.handleSpinnerMsg(typed)
case tea.WindowSizeMsg:
@@ -129,7 +140,7 @@ func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
func (m *modelState) View() string {
var builder strings.Builder
builder.WriteString(sectionStyle.Render("Steps"))
builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader()))
builder.WriteString("\n")
builder.WriteString(renderProgress(m))
builder.WriteString("\n")
+4 -1
View File
@@ -1,6 +1,7 @@
package ui
import (
"context"
"fmt"
"io"
"strings"
@@ -72,7 +73,7 @@ type TUIReporter struct {
}
// NewTUIReporter creates a new Bubble Tea reporter.
func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) {
spin := spinner.New()
spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
@@ -88,6 +89,8 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
steps: cameradar.Steps(),
status: make(map[cameradar.Step]state),
debug: debug,
buildInfo: buildInfo,
cancel: cancel,
spinner: spin,
progress: prog,
progressTotals: make(map[cameradar.Step]int),
+2 -1
View File
@@ -9,7 +9,8 @@ type AuthType int
// Supported authentication methods.
const (
AuthNone AuthType = iota
AuthUnknown AuthType = iota
AuthNone
AuthBasic
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