Compare commits

..

2 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
21 changed files with 870 additions and 698 deletions
+11 -11
View File
@@ -46,21 +46,21 @@ archives:
dockers: dockers:
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: amd64 goarch: amd64
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:latest-386" - "ullaakut/{{ .ProjectName }}:latest-386"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: 386 goarch: 386
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:latest-armv6" - "ullaakut/{{ .ProjectName }}:latest-armv6"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -68,7 +68,7 @@ dockers:
goarch: arm goarch: arm
goarm: 6 goarm: 6
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:latest-armv7" - "ullaakut/{{ .ProjectName }}:latest-armv7"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -76,7 +76,7 @@ dockers:
goarch: arm goarch: arm
goarm: 7 goarm: 7
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:latest-arm64" - "ullaakut/{{ .ProjectName }}:latest-arm64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -84,13 +84,13 @@ dockers:
goarch: arm64 goarch: arm64
docker_manifests: docker_manifests:
- name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}" - name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- name_template: "ullaakut/{{ .ProjectName }}:latest" - name_template: "ullaakut/{{ .ProjectName }}:latest"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
+71 -1
View File
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack" "github.com/Ullaakut/cameradar/v6/internal/attack"
@@ -17,7 +19,11 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
//nolint:cyclop // Splitting this function does not make it clearer.
func runCameradar(ctx context.Context, cmd *cli.Command) error { func runCameradar(ctx context.Context, cmd *cli.Command) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
targetInputs := cmd.StringSlice(flagTargets) targetInputs := cmd.StringSlice(flagTargets)
if len(targetInputs) == 0 { if len(targetInputs) == 0 {
return errors.New("at least one target must be specified") 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() 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 { if err != nil {
return err 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 != "" { if outputPath != "" {
reporter = output.NewM3UReporter(reporter, outputPath) reporter = output.NewM3UReporter(reporter, outputPath)
} }
@@ -102,6 +125,53 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
return c.Run(ctx) 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 { func isInteractiveTerminal() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) { if !term.IsTerminal(int(os.Stdout.Fd())) {
return false return false
+9 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
@@ -28,7 +29,11 @@ const (
flagOutput = "output" flagOutput = "output"
) )
var version = "dev" var (
version = "dev"
commit = "none"
date = "unknown"
)
var flags = cmd.Flags{ var flags = cmd.Flags{
&cli.StringSliceFlag{ &cli.StringSliceFlag{
@@ -128,6 +133,9 @@ func realMain() (code int) {
err := app.Run(ctx, os.Args) err := app.Run(ctx, os.Args)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
return 1
}
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) _, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
return 1 return 1
} }
+42 -172
View File
@@ -4,18 +4,17 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"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"
const maxIncrementalRouteAttempts = 32
// Dictionary provides dictionaries for routes, usernames and passwords. // Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface { type Dictionary interface {
@@ -190,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
@@ -235,12 +235,7 @@ func (a Attacker) attackCredentialsForStream(ctx context.Context, target camerad
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port) msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
a.reporter.Progress(cameradar.StepAttackCredentials, msg) a.reporter.Progress(cameradar.StepAttackCredentials, msg)
updated, err := a.tryIncrementalRoutes(ctx, target, target.Route(), true) return target, nil
if err != nil {
return target, err
}
return updated, nil
} }
time.Sleep(a.attackInterval) time.Sleep(a.attackInterval)
} }
@@ -265,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 = appendRouteIfMissing(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,97 +282,15 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true target.RouteFound = true
target.Routes = appendRouteIfMissing(target.Routes, route) target.Routes = append(target.Routes, route)
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route)) a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route))
updated, err := a.tryIncrementalRoutes(ctx, target, route, emitProgress)
if err != nil {
return target, err
}
target = updated
} }
} }
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
}
// When no credentials are used, we expect 200, 401 or 403 status codes, which would mean either that the stream is
// unprotected and this is the correct route, or that it is protected and this is also a correct route.
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) { func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
return code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
})
}
// When credentials are given, we only expect a 200 status code, which confirms the combination of route and credentials.
func (a Attacker) routeAttackWithCredentials(stream cameradar.Stream, route string) (bool, error) {
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
return code == base.StatusOK
})
}
func (a Attacker) routeAttackWithStatus(stream cameradar.Stream, route string, allowed func(base.StatusCode) bool) (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 {
return false, fmt.Errorf("building rtsp url: %w", err) return false, fmt.Errorf("building rtsp url: %w", err)
@@ -389,82 +302,8 @@ func (a Attacker) routeAttackWithStatus(stream cameradar.Stream, route string, a
} }
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code)) a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
return allowed(code), nil access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
} return access, nil
func (a Attacker) tryIncrementalRoutes(ctx context.Context,
target cameradar.Stream, route string,
emitProgress bool,
) (cameradar.Stream, error) {
match, ok := detectIncrementalRoute(route)
if !ok {
return target, nil
}
nextNumber := match.number + 1
attempts := 0
for {
if attempts >= maxIncrementalRouteAttempts {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf(
"incremental route attempts capped at %d for %s:%d",
maxIncrementalRouteAttempts,
target.Address.String(),
target.Port,
))
return target, nil
}
select {
case <-ctx.Done():
return target, ctx.Err()
case <-time.After(a.attackInterval):
}
attempts++
nextRoute := buildIncrementedRoute(match, nextNumber)
if slices.Contains(target.Routes, nextRoute) {
if !match.isChannel {
return target, nil
}
nextNumber++
continue
}
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttackWithCredentials(target, nextRoute)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("incremental route attempt failed for %s:%d (%s): %v",
target.Address.String(),
target.Port,
nextRoute,
err,
))
return target, nil
}
if !ok {
return target, nil
}
target.RouteFound = true
target.Routes = appendRouteIfMissing(target.Routes, nextRoute)
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Incremental route found for %s:%d -> %s", target.Address.String(), target.Port, nextRoute))
if !match.isChannel {
return target, nil
}
nextNumber++
}
}
func appendRouteIfMissing(routes []string, route string) []string {
if slices.Contains(routes, route) {
return routes
}
return append(routes, route)
} }
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) { func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
@@ -502,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)
} }
@@ -516,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
@@ -527,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,
@@ -539,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)
} }
+34 -111
View File
@@ -1,7 +1,6 @@
package attack_test package attack_test
import ( import (
"fmt"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -51,11 +50,11 @@ func TestNew(t *testing.T) {
func TestAttacker_Attack_BasicAuth(t *testing.T) { func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -102,9 +101,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{ {
name: "no authentication", name: "no authentication",
config: rtspServerConfig{ config: rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}, },
dict: testDictionary{ dict: testDictionary{
routes: []string{"stream"}, routes: []string{"stream"},
@@ -118,11 +117,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{ {
name: "digest authentication", name: "digest authentication",
config: rtspServerConfig{ config: rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodDigest, authMethod: headers.AuthMethodDigest,
}, },
dict: testDictionary{ dict: testDictionary{
routes: []string{"stream"}, routes: []string{"stream"},
@@ -194,9 +193,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) { func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -215,18 +214,18 @@ 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)
} }
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) { func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -255,12 +254,12 @@ func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{} reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: true, requireAuth: true,
username: "user", username: "user",
password: "pass", password: "pass",
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
failOnAuth: true, failOnAuth: true,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -305,16 +304,16 @@ 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)
} }
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) { func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{ addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"}, allowedRoute: "stream",
requireAuth: false, requireAuth: false,
authMethod: headers.AuthMethodBasic, authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport, setupStatus: base.StatusUnsupportedTransport,
}) })
dict := testDictionary{ dict := testDictionary{
@@ -336,71 +335,6 @@ func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
assert.True(t, got[0].RouteFound) assert.True(t, got[0].RouteFound)
} }
func TestAttacker_Attack_IncrementalRoutesStopsOnFirstMissAndAvoidsDuplicates(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"channel1", "channel2"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"channel1", "channel2"},
usernames: []string{"user"},
passwords: []string{"pass"},
}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.NoError(t, err)
require.Len(t, got, 1)
assert.ElementsMatch(t, []string{"channel1", "channel2"}, got[0].Routes)
assert.Equal(t, 1, countRoute(got[0].Routes, "channel2"))
}
func TestAttacker_Attack_IncrementalRoutesStopsAtCap(t *testing.T) {
allowedRoutes := make([]string, 0, 50)
for i := 1; i <= 50; i++ {
allowedRoutes = append(allowedRoutes, fmt.Sprintf("channel%d", i))
}
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: allowedRoutes,
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"channel1"},
usernames: []string{"user"},
passwords: []string{"pass"},
}
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.NoError(t, err)
require.Len(t, got, 1)
const expectedRoutes = 33 // channel1 + 32 incremental attempts
assert.Len(t, got[0].Routes, expectedRoutes)
assert.Contains(t, got[0].Routes, "channel33")
assert.NotContains(t, got[0].Routes, "channel34")
}
type testDictionary struct { type testDictionary struct {
routes []string routes []string
usernames []string usernames []string
@@ -442,10 +376,9 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {} func (r *recordingReporter) Close() {}
func (r *recordingReporter) ContainsDebug(value string) bool { func (r *recordingReporter) HasDebugContaining(value string) bool {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
for _, message := range r.debugMessages { for _, message := range r.debugMessages {
if strings.Contains(message, value) { if strings.Contains(message, value) {
return true return true
@@ -453,13 +386,3 @@ func (r *recordingReporter) ContainsDebug(value string) bool {
} }
return false return false
} }
func countRoute(routes []string, route string) int {
count := 0
for _, value := range routes {
if value == route {
count++
}
}
return count
}
+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"
}
}
-192
View File
@@ -1,192 +0,0 @@
package attack
import (
"fmt"
"strconv"
"strings"
)
type incrementalRoute struct {
prefix string
suffix string
number int
width int
isChannel bool
}
// detectIncrementalRoute identifies routes that can be incremented.
// It prioritizes channel-like patterns to enable sequential scanning when possible.
//
// Examples of supported patterns:
// - /StreamingSetting?ChannelID=01&other=params -> /StreamingSetting?ChannelID=02&other=params
// - /path/to/channel2/stream -> /path/to/channel3/stream
// - /foo/bar12/baz -> /foo/bar13/baz
//
// It returns false if no incrementable pattern is found.
func detectIncrementalRoute(route string) (incrementalRoute, bool) {
if strings.TrimSpace(route) == "" {
return incrementalRoute{}, false
}
if match, ok := findChannelIncrement(route); ok {
match.isChannel = true
return match, true
}
match, ok := findLastNumber(route)
if !ok {
return incrementalRoute{}, false
}
return match, true
}
// findChannelIncrement locates a numeric segment tied to channel-like keywords.
// It returns the last match for the first keyword that yields a hit.
//
// Supported keywords include: channel_id, channelid, channelno, channel, channelname.
func findChannelIncrement(route string) (incrementalRoute, bool) {
patterns := []string{"channel_id", "channelid", "channelno", "channel", "channelname"}
lower := strings.ToLower(route)
for _, pattern := range patterns {
var lastMatch incrementalRoute
found := false
index := 0
for {
pos := strings.Index(lower[index:], pattern)
if pos == -1 {
break
}
pos += index
start, end, ok := firstNumberAfterKey(route, pos+len(pattern))
if ok {
num, width, parseOK := parseNumber(route, start, end)
if parseOK {
lastMatch = incrementalRoute{
prefix: route[:start],
suffix: route[end:],
number: num,
width: width,
}
found = true
}
}
index = pos + len(pattern)
}
if found {
return lastMatch, true
}
}
return incrementalRoute{}, false
}
// findLastNumber finds the last numeric token in the route so it can be incremented.
// This supports routes where the channel number is not the final component.
func findLastNumber(route string) (incrementalRoute, bool) {
for i := len(route) - 1; i >= 0; {
if !isDigit(route[i]) {
i--
continue
}
end := i + 1
start := i
for start >= 0 && isDigit(route[start]) {
start--
}
start++
num, width, ok := parseNumber(route, start, end)
if !ok {
i = start - 1
continue
}
return incrementalRoute{
prefix: route[:start],
suffix: route[end:],
number: num,
width: width,
}, true
}
return incrementalRoute{}, false
}
// parseNumber reads the numeric token and returns its integer value and width.
func parseNumber(route string, start, end int) (int, int, bool) {
if start < 0 || end > len(route) || start >= end {
return 0, 0, false
}
value := route[start:end]
num, err := strconv.Atoi(value)
if err != nil {
return 0, 0, false
}
return num, len(value), true
}
// firstNumberAfterKey returns the first numeric token after a keyword, limited to
// the current token and requiring an '=' delimiter (query param or path segment).
func firstNumberAfterKey(route string, after int) (start, end int, ok bool) {
if after < 0 {
after = 0
}
tokenEnd := len(route)
for i := after; i < len(route); i++ {
if isTokenDelimiter(route[i]) {
tokenEnd = i
break
}
}
relEq := strings.IndexByte(route[after:tokenEnd], '=')
searchStart := after
if relEq != -1 {
searchStart = after + relEq + 1
}
for i := searchStart; i < tokenEnd; i++ {
if !isDigit(route[i]) {
if relEq == -1 {
break
}
continue
}
end := i + 1
for end < tokenEnd && isDigit(route[end]) {
end++
}
return i, end, true
}
return 0, 0, false
}
// buildIncrementedRoute formats the route with the new numeric value.
// It preserves zero padding when the original token had a fixed width.
func buildIncrementedRoute(match incrementalRoute, number int) string {
if match.width <= 0 {
return match.prefix + strconv.Itoa(number) + match.suffix
}
return match.prefix + fmt.Sprintf("%0*d", match.width, number) + match.suffix
}
func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}
func isTokenDelimiter(b byte) bool {
switch b {
case '&', '/', '?', '#':
return true
default:
return false
}
}
-151
View File
@@ -1,151 +0,0 @@
package attack
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDetectIncrementalRoute_ChannelID(t *testing.T) {
route := "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=01&ChannelName=Channel1"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 1, match.number)
assert.Equal(t, 2, match.width)
assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=", match.prefix)
assert.Equal(t, "&ChannelName=Channel1", match.suffix)
next := buildIncrementedRoute(match, match.number+1)
assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=02&ChannelName=Channel1", next)
}
func TestDetectIncrementalRoute_ChannelSuffix(t *testing.T) {
route := "/path/to/channel2/stream"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 2, match.number)
assert.Equal(t, "/path/to/channel", match.prefix)
assert.Equal(t, "/stream", match.suffix)
}
func TestDetectIncrementalRoute_LastNumber(t *testing.T) {
route := "/foo/bar12/baz"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 12, match.number)
assert.Equal(t, 2, match.width)
assert.Equal(t, "/foo/bar", match.prefix)
assert.Equal(t, "/baz", match.suffix)
next := buildIncrementedRoute(match, 13)
assert.Equal(t, "/foo/bar13/baz", next)
}
func TestDetectIncrementalRoute_NoNumber(t *testing.T) {
match, ok := detectIncrementalRoute("/no/number/here")
assert.False(t, ok)
assert.Equal(t, incrementalRoute{}, match)
}
func TestDetectIncrementalRoute_OverflowAtEndFallsBack(t *testing.T) {
// The trailing token overflows strconv.Atoi, so we fall back to earlier numbers.
route := "/foo1/bar999999999999999999999999999999"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 1, match.number)
assert.Equal(t, "/foo", match.prefix)
assert.Equal(t, "/bar999999999999999999999999999999", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordShouldNotBindAcrossParams(t *testing.T) {
// The channel keyword should not bind to digits in other query parameters.
route := "/path?channelname=foo&version=12"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 12, match.number)
assert.Equal(t, "/path?channelname=foo&version=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordStopsAtDelimiter(t *testing.T) {
// Digits after a delimiter should not be associated with a channel keyword.
route := "/path/channel?channel=foo/7"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 7, match.number)
assert.Equal(t, "/path/channel?channel=foo/", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordWithoutDigitsFallsBack(t *testing.T) {
// channel keyword without digits should fall back to last numeric token.
route := "/path/channel?channel=foo&stream=9"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.False(t, match.isChannel)
assert.Equal(t, 9, match.number)
assert.Equal(t, "/path/channel?channel=foo&stream=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordKeepsQueryDigits(t *testing.T) {
// channel keyword with query param digits should be detected as channel.
route := "/path?channel=03&other=1"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 3, match.number)
assert.Equal(t, 2, match.width)
assert.Equal(t, "/path?channel=", match.prefix)
assert.Equal(t, "&other=1", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordMultipleMatchesUsesKeywordPriority(t *testing.T) {
// Keyword priority should win even if another keyword appears earlier in the route.
route := "/path?channel=1&channelid=9"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 9, match.number)
assert.Equal(t, "/path?channel=1&channelid=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestDetectIncrementalRoute_ChannelKeywordSelectsLastMatchWithinKeyword(t *testing.T) {
// The last match for a given keyword should be selected.
route := "/path?channel=1&foo=bar&channel=4"
match, ok := detectIncrementalRoute(route)
require.True(t, ok)
assert.True(t, match.isChannel)
assert.Equal(t, 4, match.number)
assert.Equal(t, "/path?channel=1&foo=bar&channel=", match.prefix)
assert.Equal(t, "", match.suffix)
}
func TestBuildIncrementedRoute_ZeroPadding(t *testing.T) {
match := incrementalRoute{
prefix: "/channel",
suffix: "/stream",
number: 1,
width: 3,
}
assert.Equal(t, "/channel002/stream", buildIncrementedRoute(match, 2))
}
+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{
+26 -35
View File
@@ -18,27 +18,27 @@ import (
) )
type rtspServerConfig struct { type rtspServerConfig struct {
allowAll bool allowAll bool
allowRoutes []string allowedRoute string
requireAuth bool requireAuth bool
username string username string
password string password string
authMethod headers.AuthMethod authMethod headers.AuthMethod
authHeader base.HeaderValue authHeader base.HeaderValue
failOnAuth bool failOnAuth bool
setupStatus base.StatusCode setupStatus base.StatusCode
} }
type testServerHandler struct { type testServerHandler struct {
stream *gortsplib.ServerStream stream *gortsplib.ServerStream
allowAll bool allowAll bool
allowRoutes []string allowedRoute string
requireAuth bool requireAuth bool
username string username string
password string password string
authHeader base.HeaderValue authHeader base.HeaderValue
failOnAuth bool failOnAuth bool
setupStatus base.StatusCode setupStatus base.StatusCode
} }
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) { func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
@@ -86,29 +86,20 @@ func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*ba
func (h *testServerHandler) routeAllowed(path string) bool { func (h *testServerHandler) routeAllowed(path string) bool {
path = strings.TrimLeft(path, "/") path = strings.TrimLeft(path, "/")
if h.allowAll { return h.allowAll || path == h.allowedRoute
return true
}
for _, route := range h.allowRoutes {
if path == route {
return true
}
}
return false
} }
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) { func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper() t.Helper()
handler := &testServerHandler{ handler := &testServerHandler{
allowAll: cfg.allowAll, allowAll: cfg.allowAll,
allowRoutes: cfg.allowRoutes, allowedRoute: cfg.allowedRoute,
requireAuth: cfg.requireAuth, requireAuth: cfg.requireAuth,
username: cfg.username, username: cfg.username,
password: cfg.password, password: cfg.password,
failOnAuth: cfg.failOnAuth, failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus, setupStatus: cfg.setupStatus,
} }
if len(cfg.authHeader) > 0 { if len(cfg.authHeader) > 0 {
+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
+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. // Start prints the beginning of a step.
func (r *PlainReporter) Start(step cameradar.Step, message string) { 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. // Done prints the completion of a step.
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
if !r.debug { if !r.debug {
return return
} }
r.print(step, "DEBUG", message) r.print(step, "DBUG", message)
} }
// Error prints an error message. // Error prints an error message.
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
if err == nil { if err == nil {
return return
} }
r.print(step, "ERROR", err.Error()) r.print(step, "EROR", err.Error())
} }
// Summary prints the final summary. // Summary prints the final summary.
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
return 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) reporter.Summary([]cameradar.Stream{}, nil)
content := out.String() content := out.String()
assert.Contains(t, content, "[START] Scan targets: starting") assert.Contains(t, content, " [STEP] Scan targets: starting")
assert.Contains(t, content, "[INFO] Scan targets: working") assert.Contains(t, content, " [INFO] Scan targets: working")
assert.Contains(t, content, "[DEBUG] Scan targets: details") assert.Contains(t, content, " [DBUG] Scan targets: details")
assert.Contains(t, content, "[DONE] Scan targets: finished") assert.Contains(t, content, " [DONE] Scan targets: finished")
assert.Contains(t, content, "[ERROR] Scan targets: boom") assert.Contains(t, content, " [EROR] Scan targets: boom")
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0") assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
}) })
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Error(cameradar.StepScan, nil) reporter.Error(cameradar.StepScan, nil)
content := out.String() content := out.String()
assert.NotContains(t, content, "DEBUG") assert.NotContains(t, content, "DBUG")
assert.Equal(t, "", strings.TrimSpace(content)) 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 package ui
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -20,7 +21,7 @@ type Reporter interface {
} }
// NewReporter creates a Reporter based on the requested mode. // 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 { if debug {
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
} }
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
if !interactive { if !interactive {
return nil, errors.New("tui mode requires an interactive terminal") return nil, errors.New("tui mode requires an interactive terminal")
} }
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
case cameradar.ModeAuto: case cameradar.ModeAuto:
if interactive { if interactive {
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
} }
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
default: default:
+1 -1
View File
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
out := &bytes.Buffer{} 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 != "" { if test.wantErrContains != "" {
require.Error(t, err) require.Error(t, err)
+12 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"strings" "strings"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
@@ -16,6 +17,8 @@ type modelState struct {
summary []summaryTable summary []summaryTable
summaryStreams []cameradar.Stream summaryStreams []cameradar.Stream
summaryFinal bool summaryFinal bool
buildInfo BuildInfo
cancel context.CancelFunc
debug bool debug bool
spinner spinner.Model spinner spinner.Model
progress progress.Model progress progress.Model
@@ -45,6 +48,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.handleProgressMsg(typed) m.handleProgressMsg(typed)
case closeMsg: case closeMsg:
m.quitting = true 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: case spinner.TickMsg:
cmds = m.handleSpinnerMsg(typed) cmds = m.handleSpinnerMsg(typed)
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -129,7 +140,7 @@ func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
func (m *modelState) View() string { func (m *modelState) View() string {
var builder strings.Builder var builder strings.Builder
builder.WriteString(sectionStyle.Render("Steps")) builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader()))
builder.WriteString("\n") builder.WriteString("\n")
builder.WriteString(renderProgress(m)) builder.WriteString(renderProgress(m))
builder.WriteString("\n") builder.WriteString("\n")
+4 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -72,7 +73,7 @@ type TUIReporter struct {
} }
// NewTUIReporter creates a new Bubble Tea reporter. // 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.New()
spin.Spinner = spinner.Dot spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
@@ -88,6 +89,8 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
steps: cameradar.Steps(), steps: cameradar.Steps(),
status: make(map[cameradar.Step]state), status: make(map[cameradar.Step]state),
debug: debug, debug: debug,
buildInfo: buildInfo,
cancel: cancel,
spinner: spin, spinner: spin,
progress: prog, progress: prog,
progressTotals: make(map[cameradar.Step]int), progressTotals: make(map[cameradar.Step]int),
+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
) )