Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f192139cc3 | |||
| af41fc6cb8 |
+11
-11
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() + ")"
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user