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:
- 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"
+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
}
+42 -172
View File
@@ -4,18 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"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 maxIncrementalRouteAttempts = 32
const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
@@ -190,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
@@ -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)
a.reporter.Progress(cameradar.StepAttackCredentials, msg)
updated, err := a.tryIncrementalRoutes(ctx, target, target.Route(), true)
if err != nil {
return target, err
}
return updated, nil
return target, nil
}
time.Sleep(a.attackInterval)
}
@@ -265,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
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))
return target, nil
}
@@ -287,97 +282,15 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
}
if ok {
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))
updated, err := a.tryIncrementalRoutes(ctx, target, route, emitProgress)
if err != nil {
return target, err
}
target = updated
}
}
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) {
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)
if err != nil {
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))
return allowed(code), 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)
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
return access, nil
}
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
@@ -502,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)
}
@@ -516,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
@@ -527,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,
@@ -539,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)
}
+34 -111
View File
@@ -1,7 +1,6 @@
package attack_test
import (
"fmt"
"strings"
"sync"
"testing"
@@ -51,11 +50,11 @@ func TestNew(t *testing.T) {
func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -102,9 +101,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "no authentication",
config: rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
},
dict: testDictionary{
routes: []string{"stream"},
@@ -118,11 +117,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
{
name: "digest authentication",
config: rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
},
dict: testDictionary{
routes: []string{"stream"},
@@ -194,9 +193,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -215,18 +214,18 @@ 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)
}
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
@@ -255,12 +254,12 @@ func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
})
dict := testDictionary{
@@ -305,16 +304,16 @@ 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)
}
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowRoutes: []string{"stream"},
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
})
dict := testDictionary{
@@ -336,71 +335,6 @@ func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
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 {
routes []string
usernames []string
@@ -442,10 +376,9 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {}
func (r *recordingReporter) ContainsDebug(value string) bool {
func (r *recordingReporter) HasDebugContaining(value string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, message := range r.debugMessages {
if strings.Contains(message, value) {
return true
@@ -453,13 +386,3 @@ func (r *recordingReporter) ContainsDebug(value string) bool {
}
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
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{
+26 -35
View File
@@ -18,27 +18,27 @@ import (
)
type rtspServerConfig struct {
allowAll bool
allowRoutes []string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
type testServerHandler struct {
stream *gortsplib.ServerStream
allowAll bool
allowRoutes []string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
stream *gortsplib.ServerStream
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
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 {
path = strings.TrimLeft(path, "/")
if h.allowAll {
return true
}
for _, route := range h.allowRoutes {
if path == route {
return true
}
}
return false
return h.allowAll || path == h.allowedRoute
}
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper()
handler := &testServerHandler{
allowAll: cfg.allowAll,
allowRoutes: cfg.allowRoutes,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
allowAll: cfg.allowAll,
allowedRoute: cfg.allowedRoute,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
}
if len(cfg.authHeader) > 0 {
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main
0/usrnm:pwd/main
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.
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
)