Compare commits

..

5 Commits

Author SHA1 Message Date
Brendan Le Glaunec f192139cc3 feat: small UI improvements (#394) 2026-02-01 22:30:55 +01:00
Brendan Le Glaunec af41fc6cb8 fix: no longer give up on detecting auth type when getting a 401 (#391) 2026-02-01 20:59:26 +01:00
Brendan Le Glaunec 777bd2a488 chore: resize cameradar logo (#387) 2026-01-28 14:35:30 +01:00
Brendan Le Glaunec 99c2e55ec4 docs: add GIF of new TUI (#386) 2026-01-27 23:27:01 +01:00
Brendan Le Glaunec bf720fcea2 fix: build and test workflows 2026-01-27 23:19:21 +01:00
25 changed files with 855 additions and 129 deletions
+26 -13
View File
@@ -14,30 +14,43 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# We need to set a cache marker to ensure that the cache is individual for each job.
- name: Add Cache Marker
run: echo "go-build" > env.txt
- name: Install Go - name: Install Go
id: install-go id: install-go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
cache-dependency-path: | cache: false
go.sum
env.txt - name: Cache Go mod
id: gomod
uses: actions/cache@v5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-mod-
- name: Cache Go build
uses: actions/cache@v5
with:
path: ~/.cache/go-build
key: ${{ runner.os }}-go-build-${{ github.ref_name }}
restore-keys: |
${{ runner.os }}-go-build-
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
if: steps.install-go.outputs.cache-hit != 'true' if: steps.gomod.outputs.cache-hit != 'true'
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6
env: env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }} GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
DOCKER_REPOSITORY: ullaakut/cameradar with:
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} distribution: goreleaser
version: ${{ inputs.GORELEASER_VERSION }}
args: release --clean --snapshot --skip=docker
+9 -1
View File
@@ -42,7 +42,15 @@ jobs:
- name: Run Linter - name: Run Linter
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v8
with: with:
version: v2.10.2 version: v2.7.2
- name: Setup gotestsum
uses: gertd/action-gotestsum@v3.0.0
with:
gotestsum_version: v1.13.0
- name: Download nmap
run: sudo apt-get install -y nmap
- name: Run Tests - name: Run Tests
env: env:
+11 -11
View File
@@ -46,21 +46,21 @@ archives:
dockers: dockers:
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: amd64 goarch: amd64
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:latest-386" - "ullaakut/{{ .ProjectName }}:latest-386"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: 386 goarch: 386
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:latest-armv6" - "ullaakut/{{ .ProjectName }}:latest-armv6"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -68,7 +68,7 @@ dockers:
goarch: arm goarch: arm
goarm: 6 goarm: 6
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:latest-armv7" - "ullaakut/{{ .ProjectName }}:latest-armv7"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -76,7 +76,7 @@ dockers:
goarch: arm goarch: arm
goarm: 7 goarm: 7
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:latest-arm64" - "ullaakut/{{ .ProjectName }}:latest-arm64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -84,13 +84,13 @@ dockers:
goarch: arm64 goarch: arm64
docker_manifests: docker_manifests:
- name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}" - name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- name_template: "ullaakut/{{ .ProjectName }}:latest" - name_template: "ullaakut/{{ .ProjectName }}:latest"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
+6 -3
View File
@@ -8,7 +8,7 @@
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" /> <img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
</a> </a>
<a href="https://github.com/Ullaakut/cameradar/actions"> <a href="https://github.com/Ullaakut/cameradar/actions">
<img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build" /> <img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build.yaml" />
</a> </a>
<a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'> <a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'>
<img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' /> <img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' />
@@ -24,10 +24,9 @@
</a> </a>
</p> </p>
## An RTSP stream access tool with a Go library ## RTSP stream access tool
Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes. Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes.
Use the CLI for end-to-end scanning or import the library in Go code.
### What Cameradar does ### What Cameradar does
@@ -56,6 +55,10 @@ Use the CLI for end-to-end scanning or import the library in Go code.
- [Examples](#examples) - [Examples](#examples)
- [License](#license) - [License](#license)
---
<p align="center"><img src="images/example.gif"/></p>
## Quick start with Docker ## Quick start with Docker
Install [Docker](https://docs.docker.com/engine/installation/) and run: Install [Docker](https://docs.docker.com/engine/installation/) and run:
+71 -1
View File
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack" "github.com/Ullaakut/cameradar/v6/internal/attack"
@@ -17,7 +19,11 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
//nolint:cyclop // Splitting this function does not make it clearer.
func runCameradar(ctx context.Context, cmd *cli.Command) error { func runCameradar(ctx context.Context, cmd *cli.Command) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
targetInputs := cmd.StringSlice(flagTargets) targetInputs := cmd.StringSlice(flagTargets)
if len(targetInputs) == 0 { if len(targetInputs) == 0 {
return errors.New("at least one target must be specified") return errors.New("at least one target must be specified")
@@ -60,10 +66,27 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
} }
interactive := isInteractiveTerminal() interactive := isInteractiveTerminal()
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive) buildInfo := ui.BuildInfo{Version: version, Commit: commit, Date: date}
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive, buildInfo, cancel)
if err != nil { if err != nil {
return err return err
} }
if plainReporter, ok := reporter.(*ui.PlainReporter); ok {
resolvedMode := resolveMode(mode, interactive)
plainReporter.PrintStartup(buildInfo, buildStartupOptions(
targets,
ports,
routesPath,
credsPath,
outputPath,
cmd.Int16(flagScanSpeed),
cmd.Duration(flagAttackInterval),
cmd.Duration(flagTimeout),
cmd.Bool(flagSkipScan),
cmd.Bool(flagDebug),
resolvedMode,
))
}
if outputPath != "" { if outputPath != "" {
reporter = output.NewM3UReporter(reporter, outputPath) reporter = output.NewM3UReporter(reporter, outputPath)
} }
@@ -102,6 +125,53 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
return c.Run(ctx) return c.Run(ctx)
} }
func resolveMode(mode cameradar.Mode, interactive bool) cameradar.Mode {
if mode != cameradar.ModeAuto {
return mode
}
if interactive {
return cameradar.ModeTUI
}
return cameradar.ModePlain
}
func buildStartupOptions(
targets []string,
ports []string,
routesPath string,
credsPath string,
outputPath string,
scanSpeed int16,
attackInterval time.Duration,
timeout time.Duration,
skipScan bool,
debug bool,
mode cameradar.Mode,
) []string {
options := []string{
"targets: " + strings.Join(targets, ", "),
"ports: " + strings.Join(ports, ", "),
"custom-routes: " + fallbackValue(routesPath, "builtin"),
"custom-credentials: " + fallbackValue(credsPath, "builtin"),
"scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10),
"skip-scan: " + strconv.FormatBool(skipScan),
"attack-interval: " + attackInterval.String(),
"timeout: " + timeout.String(),
"debug: " + strconv.FormatBool(debug),
"ui: " + string(mode),
"output: " + fallbackValue(outputPath, "disabled"),
}
return options
}
func fallbackValue(value, fallback string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return fallback
}
return trimmed
}
func isInteractiveTerminal() bool { func isInteractiveTerminal() bool {
if !term.IsTerminal(int(os.Stdout.Fd())) { if !term.IsTerminal(int(os.Stdout.Fd())) {
return false return false
+9 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
@@ -28,7 +29,11 @@ const (
flagOutput = "output" flagOutput = "output"
) )
var version = "dev" var (
version = "dev"
commit = "none"
date = "unknown"
)
var flags = cmd.Flags{ var flags = cmd.Flags{
&cli.StringSliceFlag{ &cli.StringSliceFlag{
@@ -128,6 +133,9 @@ func realMain() (code int) {
err := app.Run(ctx, os.Args) err := app.Run(ctx, os.Args)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
return 1
}
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) _, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
return 1 return 1
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+38 -65
View File
@@ -7,12 +7,14 @@ import (
"time" "time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base" "github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors" "github.com/bluenviron/gortsplib/v5/pkg/liberrors"
) )
// Route that should never be a constructor default. // Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42" const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords. // Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface { type Dictionary interface {
@@ -187,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
func needsReattack(streams []cameradar.Stream) bool { func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams { for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available { if stream.RouteFound && stream.CredentialsFound && stream.Available {
// This stream is fully discovered, no need to re-attack.
continue continue
} }
return true return true
@@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true target.RouteFound = true
target.Routes = append(target.Routes, "/") target.Routes = append(target.Routes, "") // Add empty route for default.
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port)) a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil return target, nil
} }
@@ -287,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
return target, nil return target, nil
} }
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
return stream, nil
}
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) { func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password) u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil { if err != nil {
@@ -399,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
} }
defer client.Close() defer client.Close()
desc, res, err := client.Describe(u) desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
if err != nil { if err != nil {
return a.handleDescribeError(stream, urlStr, err) return a.handleDescribeError(stream, urlStr, err)
} }
@@ -413,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
if err != nil { if err != nil {
return a.handleSetupError(stream, urlStr, err) return a.handleSetupError(stream, urlStr, err)
} }
a.logSetupResponse(urlStr, res) a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK stream.Available = res != nil && res.StatusCode == base.StatusOK
@@ -424,9 +365,39 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
return stream, nil return stream, nil
} }
func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, u *base.URL, urlStr string) (*description.Session, *base.Response, error) {
var (
desc *description.Session
res *base.Response
err error
)
for range 5 {
desc, res, err = client.Describe(u)
if err == nil {
return desc, res, nil
}
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", urlStr, badStatus.Code))
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(time.Second):
}
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err)
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) { func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable { if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)", a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(), stream.Address.String(),
stream.Port, stream.Port,
@@ -436,6 +407,8 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er
return stream, nil return stream, nil
} }
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err) return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
} }
+2 -2
View File
@@ -214,7 +214,7 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
got, err := attacker.Attack(t.Context(), streams) got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err) require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods") assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1) require.Len(t, got, 1)
assert.False(t, got[0].RouteFound) assert.False(t, got[0].RouteFound)
} }
@@ -304,7 +304,7 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 1) require.Len(t, got, 1)
assert.True(t, got[0].RouteFound) assert.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes) assert.Equal(t, []string{""}, got[0].Routes)
assert.True(t, got[0].Available) assert.True(t, got[0].Available)
} }
+68
View File
@@ -0,0 +1,68 @@
package attack
import (
"context"
"fmt"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
)
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
case cameradar.AuthUnknown:
authMethod = "unknown"
default:
authMethod = fmt.Sprintf("unknown (%d)", streams[i].AuthenticationType)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
if err != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
stream.AuthenticationType = cameradar.AuthUnknown
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, statusCode))
values := headerValues(headers, "WWW-Authenticate")
switch statusCode {
case base.StatusOK:
stream.AuthenticationType = cameradar.AuthNone
case base.StatusUnauthorized:
stream.AuthenticationType = authTypeFromHeaders(values)
default:
stream.AuthenticationType = cameradar.AuthUnknown
}
return stream, nil
}
+207
View File
@@ -0,0 +1,207 @@
package attack
import (
"bufio"
"fmt"
"net"
"net/netip"
"strings"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testDictionary struct {
routes []string
usernames []string
passwords []string
}
func (d testDictionary) Routes() []string {
return d.routes
}
func (d testDictionary) Usernames() []string {
return d.usernames
}
func (d testDictionary) Passwords() []string {
return d.passwords
}
func TestAuthTypeFromHeaders(t *testing.T) {
tests := []struct {
name string
values base.HeaderValue
want cameradar.AuthType
}{
{
name: "digest wins over basic",
values: base.HeaderValue{
headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal()[0],
headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal()[0],
},
want: cameradar.AuthDigest,
},
{
name: "basic auth",
values: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
want: cameradar.AuthBasic,
},
{
name: "digest auth",
values: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
want: cameradar.AuthDigest,
},
{
name: "unknown with empty values",
values: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown with unsupported header",
values: base.HeaderValue{"Bearer abc"},
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, authTypeFromHeaders(test.values))
})
}
}
func TestDetectAuthMethod(t *testing.T) {
tests := []struct {
name string
statusCode base.StatusCode
headers base.Header
want cameradar.AuthType
}{
{
name: "no auth when status ok",
statusCode: base.StatusOK,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthNone,
},
{
name: "basic auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthBasic,
},
{
name: "digest auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
},
want: cameradar.AuthDigest,
},
{
name: "unknown auth on unauthorized without www-authenticate",
statusCode: base.StatusUnauthorized,
headers: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown auth on other status",
statusCode: base.StatusNotFound,
headers: nil,
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPProbeServer(t, test.statusCode, test.headers)
attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
stream := cameradar.Stream{
Address: addr,
Port: port,
}
got, err := attacker.detectAuthMethod(t.Context(), stream)
require.NoError(t, err)
assert.Equal(t, test.want, got.AuthenticationType)
})
}
}
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(line) == "" {
break
}
}
statusText := statusTextFromCode(statusCode)
var builder strings.Builder
_, _ = fmt.Fprintf(&builder, "RTSP/1.0 %d %s\r\n", statusCode, statusText)
builder.WriteString("CSeq: 1\r\n")
for key, values := range headers {
for _, value := range values {
_, _ = fmt.Fprintf(&builder, "%s: %s\r\n", key, value)
}
}
builder.WriteString("Content-Length: 0\r\n\r\n")
_, _ = conn.Write([]byte(builder.String()))
}()
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
}
func statusTextFromCode(code base.StatusCode) string {
switch code {
case base.StatusOK:
return "OK"
case base.StatusUnauthorized:
return "Unauthorized"
case base.StatusNotFound:
return "Not Found"
default:
return "Unknown"
}
}
+87 -6
View File
@@ -1,10 +1,16 @@
package attack package attack
import ( import (
"bufio"
"context"
"errors" "errors"
"fmt"
"net" "net"
"net/textproto"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5" "github.com/bluenviron/gortsplib/v5"
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
_, res, err := client.Describe(u) _, res, err := client.Describe(u)
if err != nil { if err != nil {
var badStatus liberrors.ErrClientBadStatusCode var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil { if errors.As(err, &badStatus) {
return badStatus.Code, nil return badStatus.Code, nil
} }
return 0, err return 0, err
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
return res.StatusCode, nil return res.StatusCode, nil
} }
// probeDescribeHeaders performs a manual DESCRIBE request and returns the status code and headers.
//
// NOTE: We do not use gortsplib here because it does not expose response headers when the status code is 401 Unauthorized,
// which is exactly what we need in order to detect authentication methods.
func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) {
dialer := &net.Dialer{Timeout: a.timeout}
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
if err != nil {
return 0, nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(a.timeout)
}
err = conn.SetDeadline(deadline)
if err != nil {
return 0, nil, err
}
request := fmt.Sprintf(
"DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: cameradar\r\nAccept: application/sdp\r\nHost: %s\r\n\r\n",
urlStr,
u.Host,
)
_, err = conn.Write([]byte(request))
if err != nil {
return 0, nil, err
}
reader := textproto.NewReader(bufio.NewReader(conn))
statusLine, err := reader.ReadLine()
if err != nil {
return 0, nil, err
}
fields := strings.Fields(statusLine)
if len(fields) < 2 {
return 0, nil, fmt.Errorf("invalid RTSP status line: %q", statusLine)
}
code, err := strconv.Atoi(fields[1])
if err != nil {
return 0, nil, fmt.Errorf("parsing RTSP status code %q: %w", fields[1], err)
}
mimeHeader, err := reader.ReadMIMEHeader()
if err != nil {
return 0, nil, err
}
headers := make(base.Header)
for key, values := range mimeHeader {
headers[key] = append(base.HeaderValue(nil), values...)
}
return base.StatusCode(code), headers, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType { func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 { if len(values) == 0 {
return cameradar.AuthNone return cameradar.AuthUnknown
} }
var hasBasic bool var hasBasic bool
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
var authHeader headers.Authenticate var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value}) err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil { if err != nil {
lower := strings.ToLower(value)
hasDigest = hasDigest || strings.Contains(lower, "digest")
hasBasic = hasBasic || strings.Contains(lower, "basic")
continue continue
} }
@@ -80,14 +149,26 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if hasBasic { if hasBasic {
return cameradar.AuthBasic return cameradar.AuthBasic
} }
return cameradar.AuthType(-1) return cameradar.AuthUnknown
}
func headerValues(header base.Header, name string) base.HeaderValue {
if header == nil {
return nil
}
for key, values := range header {
if strings.EqualFold(key, name) {
return values
}
}
return nil
} }
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) { func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port))) host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route path := strings.TrimSpace(route)
if route == "" { if path != "" && !strings.HasPrefix(path, "/") {
path = "/" path = "/" + path
} }
u := &url.URL{ u := &url.URL{
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main 0/1:1/main
0/usrnm:pwd/main 0/usrnm:pwd/main
0/video1 0/video1
+6 -3
View File
@@ -13,7 +13,6 @@ import (
func TestNew_ExpandsTargetsAndPorts(t *testing.T) { func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
targets := []string{ targets := []string{
"192.0.2.0/30", "192.0.2.0/30",
"localhost",
"192.0.2.15", "192.0.2.15",
"192.0.2.10-11", "192.0.2.10-11",
} }
@@ -25,7 +24,6 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
addrs := []netip.Addr{ addrs := []netip.Addr{
netip.MustParseAddr("127.0.0.1"),
netip.MustParseAddr("192.0.2.0"), netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"), netip.MustParseAddr("192.0.2.2"),
@@ -92,7 +90,12 @@ func TestNew_ResolvesHostnames(t *testing.T) {
streams, err := scanner.Scan(t.Context()) streams, err := scanner.Scan(t.Context())
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, streams) require.NotEmpty(t, streams)
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address) addr := streams[0].Address
assert.True(t,
addr == netip.MustParseAddr("127.0.0.1") || addr == netip.MustParseAddr("::1"),
"expected localhost to resolve to 127.0.0.1 or ::1, got %s",
addr.String(),
)
} }
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) { func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
+48
View File
@@ -0,0 +1,48 @@
package ui
import "strings"
// BuildInfo represents build metadata injected at link time.
type BuildInfo struct {
Version string
Commit string
Date string
}
// DisplayVersion returns the version prefixed with "v" when needed.
func (b BuildInfo) DisplayVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
version = "dev"
}
if strings.HasPrefix(version, "v") {
return version
}
return "v" + version
}
// LogVersion returns the version without a leading "v".
func (b BuildInfo) LogVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
return "dev"
}
return strings.TrimPrefix(version, "v")
}
// ShortCommit returns a shortened commit hash suitable for display.
func (b BuildInfo) ShortCommit() string {
commit := strings.TrimSpace(b.Commit)
if commit == "" || commit == "none" || commit == "unknown" {
return "unknown"
}
if len(commit) > 7 {
return commit[:7]
}
return commit
}
// TUIHeader returns the header used by the TUI.
func (b BuildInfo) TUIHeader() string {
return "Cameradar — " + b.DisplayVersion() + " (" + b.ShortCommit() + ")"
}
+175
View File
@@ -0,0 +1,175 @@
package ui_test
import (
"testing"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestBuildInfo_DisplayVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev with prefix",
version: "",
want: "vdev",
},
{
name: "dev without prefix",
version: "dev",
want: "vdev",
},
{
name: "already prefixed",
version: "v1.2.3",
want: "v1.2.3",
},
{
name: "adds prefix",
version: "1.2.3",
want: "v1.2.3",
},
{
name: "trims spaces with prefix",
version: " v2.0 ",
want: "v2.0",
},
{
name: "trims spaces without prefix",
version: " 2.0 ",
want: "v2.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.DisplayVersion())
})
}
}
func TestBuildInfo_LogVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev",
version: "",
want: "dev",
},
{
name: "removes leading v",
version: "v1.2.3",
want: "1.2.3",
},
{
name: "keeps version without prefix",
version: "1.2.3",
want: "1.2.3",
},
{
name: "trims spaces and removes prefix",
version: " v2.0 ",
want: "2.0",
},
{
name: "removes only first prefix",
version: "vv1",
want: "v1",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.LogVersion())
})
}
}
func TestBuildInfo_ShortCommit(t *testing.T) {
tests := []struct {
name string
commit string
want string
}{
{
name: "empty defaults to unknown",
commit: "",
want: "unknown",
},
{
name: "none defaults to unknown",
commit: "none",
want: "unknown",
},
{
name: "unknown defaults to unknown",
commit: "unknown",
want: "unknown",
},
{
name: "short commit preserved",
commit: "abcdef",
want: "abcdef",
},
{
name: "seven chars preserved",
commit: "abcdefg",
want: "abcdefg",
},
{
name: "long commit shortened",
commit: "abcdefghi",
want: "abcdefg",
},
{
name: "trims spaces before shortening",
commit: " 1234567890 ",
want: "1234567",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Commit: test.commit}
assert.Equal(t, test.want, info.ShortCommit())
})
}
}
func TestBuildInfo_TUIHeader(t *testing.T) {
tests := []struct {
name string
version string
commit string
want string
}{
{
name: "uses display version and short commit",
version: "1.2.3",
commit: "abcdefghi",
want: "Cameradar — v1.2.3 (abcdefg)",
},
{
name: "uses defaults for empty values",
version: "",
commit: "",
want: "Cameradar — vdev (unknown)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version, Commit: test.commit}
assert.Equal(t, test.want, info.TUIHeader())
})
}
}
+33 -4
View File
@@ -22,9 +22,22 @@ func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
} }
} }
// PrintStartup prints build metadata and configuration options.
func (r *PlainReporter) PrintStartup(buildInfo BuildInfo, options []string) {
step := cameradar.Step("Startup")
message := fmt.Sprintf("Running cameradar version %s, commit %s", buildInfo.LogVersion(), buildInfo.ShortCommit())
r.print(step, "INFO", message)
if len(options) == 0 {
return
}
for _, option := range options {
r.print(step, "INFO", option)
}
}
// Start prints the beginning of a step. // Start prints the beginning of a step.
func (r *PlainReporter) Start(step cameradar.Step, message string) { func (r *PlainReporter) Start(step cameradar.Step, message string) {
r.print(step, "START", message) r.print(step, "STEP", message)
} }
// Done prints the completion of a step. // Done prints the completion of a step.
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
if !r.debug { if !r.debug {
return return
} }
r.print(step, "DEBUG", message) r.print(step, "DBUG", message)
} }
// Error prints an error message. // Error prints an error message.
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
if err == nil { if err == nil {
return return
} }
r.print(step, "ERROR", err.Error()) r.print(step, "EROR", err.Error())
} }
// Summary prints the final summary. // Summary prints the final summary.
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
return return
} }
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339)) level = normalizeLevel(level)
_, _ = fmt.Fprintf(r.out, "%s [%s] %s: %s\n", time.Now().Format(time.RFC3339), level, cameradar.StepLabel(step), message)
}
func normalizeLevel(level string) string {
switch level {
case "DEBUG":
return "DBUG"
case "ERROR":
return "EROR"
case "START", "STEP":
return "STEP"
}
if len(level) >= 4 {
return level[:4]
}
return fmt.Sprintf("%-4s", level)
} }
+34 -6
View File
@@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Summary([]cameradar.Stream{}, nil) reporter.Summary([]cameradar.Stream{}, nil)
content := out.String() content := out.String()
assert.Contains(t, content, "[START] Scan targets: starting") assert.Contains(t, content, " [STEP] Scan targets: starting")
assert.Contains(t, content, "[INFO] Scan targets: working") assert.Contains(t, content, " [INFO] Scan targets: working")
assert.Contains(t, content, "[DEBUG] Scan targets: details") assert.Contains(t, content, " [DBUG] Scan targets: details")
assert.Contains(t, content, "[DONE] Scan targets: finished") assert.Contains(t, content, " [DONE] Scan targets: finished")
assert.Contains(t, content, "[ERROR] Scan targets: boom") assert.Contains(t, content, " [EROR] Scan targets: boom")
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0") assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
}) })
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Error(cameradar.StepScan, nil) reporter.Error(cameradar.StepScan, nil)
content := out.String() content := out.String()
assert.NotContains(t, content, "DEBUG") assert.NotContains(t, content, "DBUG")
assert.Equal(t, "", strings.TrimSpace(content)) assert.Equal(t, "", strings.TrimSpace(content))
}) })
} }
func TestPlainReporter_PrintStartup(t *testing.T) {
t.Run("prints build info and options", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "v1.2.3", Commit: "abcdefghi"}, []string{
"targets: 127.0.0.1",
"ports: 554",
})
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version 1.2.3, commit abcdefg")
assert.Contains(t, content, " [INFO] Startup: targets: 127.0.0.1")
assert.Contains(t, content, " [INFO] Startup: ports: 554")
})
t.Run("prints only build info when options empty", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "", Commit: "none"}, nil)
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version dev, commit unknown")
assert.Equal(t, 1, strings.Count(content, " Startup: "))
})
}
+4 -3
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -20,7 +21,7 @@ type Reporter interface {
} }
// NewReporter creates a Reporter based on the requested mode. // NewReporter creates a Reporter based on the requested mode.
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) { func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool, buildInfo BuildInfo, cancel context.CancelFunc) (Reporter, error) {
if debug { if debug {
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
} }
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
if !interactive { if !interactive {
return nil, errors.New("tui mode requires an interactive terminal") return nil, errors.New("tui mode requires an interactive terminal")
} }
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
case cameradar.ModeAuto: case cameradar.ModeAuto:
if interactive { if interactive {
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
} }
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
default: default:
+1 -1
View File
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive) reporter, err := ui.NewReporter(test.mode, false, out, test.interactive, ui.BuildInfo{Version: "dev", Commit: "none"}, func() {})
if test.wantErrContains != "" { if test.wantErrContains != "" {
require.Error(t, err) require.Error(t, err)
+12 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"strings" "strings"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
@@ -16,6 +17,8 @@ type modelState struct {
summary []summaryTable summary []summaryTable
summaryStreams []cameradar.Stream summaryStreams []cameradar.Stream
summaryFinal bool summaryFinal bool
buildInfo BuildInfo
cancel context.CancelFunc
debug bool debug bool
spinner spinner.Model spinner spinner.Model
progress progress.Model progress progress.Model
@@ -45,6 +48,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.handleProgressMsg(typed) m.handleProgressMsg(typed)
case closeMsg: case closeMsg:
m.quitting = true m.quitting = true
case tea.KeyMsg:
if typed.Type == tea.KeyCtrlC {
if m.cancel != nil {
m.cancel()
}
m.quitting = true
return m, tea.Quit
}
case spinner.TickMsg: case spinner.TickMsg:
cmds = m.handleSpinnerMsg(typed) cmds = m.handleSpinnerMsg(typed)
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -129,7 +140,7 @@ func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
func (m *modelState) View() string { func (m *modelState) View() string {
var builder strings.Builder var builder strings.Builder
builder.WriteString(sectionStyle.Render("Steps")) builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader()))
builder.WriteString("\n") builder.WriteString("\n")
builder.WriteString(renderProgress(m)) builder.WriteString(renderProgress(m))
builder.WriteString("\n") builder.WriteString("\n")
+4 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -72,7 +73,7 @@ type TUIReporter struct {
} }
// NewTUIReporter creates a new Bubble Tea reporter. // NewTUIReporter creates a new Bubble Tea reporter.
func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) { func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) {
spin := spinner.New() spin := spinner.New()
spin.Spinner = spinner.Dot spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
@@ -88,6 +89,8 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
steps: cameradar.Steps(), steps: cameradar.Steps(),
status: make(map[cameradar.Step]state), status: make(map[cameradar.Step]state),
debug: debug, debug: debug,
buildInfo: buildInfo,
cancel: cancel,
spinner: spin, spinner: spin,
progress: prog, progress: prog,
progressTotals: make(map[cameradar.Step]int), progressTotals: make(map[cameradar.Step]int),
+2 -1
View File
@@ -9,7 +9,8 @@ type AuthType int
// Supported authentication methods. // Supported authentication methods.
const ( const (
AuthNone AuthType = iota AuthUnknown AuthType = iota
AuthNone
AuthBasic AuthBasic
AuthDigest AuthDigest
) )
-5
View File
@@ -1,5 +0,0 @@
#EXTM3U
#EXTINF:-1,127.0.0.1:8554 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8554/live.sdp
#EXTINF:-1,127.0.0.1:8555 (GStreamer rtspd)
rtsp://admin:12345@127.0.0.1:8555/live.sdp