feat: v6 rewrite

This commit is contained in:
Brendan Le Glaunec
2025-07-08 17:36:48 +02:00
parent f586940b6c
commit e81eeb0c4d
81 changed files with 7430 additions and 4099 deletions
+465
View File
@@ -0,0 +1,465 @@
package attack
import (
"context"
"errors"
"fmt"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
// Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface {
Routes() []string
Usernames() []string
Passwords() []string
}
// Reporter reports progress and results of the attacks.
type Reporter interface {
Start(step cameradar.Step, message string)
Done(step cameradar.Step, message string)
Progress(step cameradar.Step, message string)
Error(step cameradar.Step, err error)
Debug(step cameradar.Step, message string)
}
// Attacker attempts to discover routes and credentials for RTSP streams.
type Attacker struct {
dictionary Dictionary
reporter Reporter
attackInterval time.Duration
timeout time.Duration
}
// New builds an Attacker with the provided dependencies.
func New(dict Dictionary, attackInterval, timeout time.Duration, reporter Reporter) (Attacker, error) {
if dict == nil {
return Attacker{}, errors.New("dictionary is required")
}
return Attacker{
dictionary: dict,
attackInterval: attackInterval,
timeout: timeout,
reporter: reporter,
}, nil
}
// Attack attacks the given targets and returns the accessed streams.
func (a Attacker) Attack(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
if len(targets) == 0 {
return nil, errors.New("no stream found")
}
streams, err := a.attackRoutesPhase(ctx, targets)
if err != nil {
return streams, err
}
streams, err = a.detectAuthPhase(ctx, streams)
if err != nil {
return streams, err
}
streams, err = a.attackCredentialsPhase(ctx, streams)
if err != nil {
return streams, err
}
streams, err = a.validateStreamsPhase(ctx, streams)
if err != nil {
return streams, err
}
// Some cameras run an inaccurate version of the RTSP protocol which prioritizes 401 over 404.
// For these cameras, running another route attack solves the problem.
if !needsReattack(streams) {
return streams, nil
}
streams, err = a.reattackRoutes(ctx, streams)
if err != nil {
return streams, err
}
return streams, nil
}
func (a Attacker) attackRoutesPhase(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepAttackRoutes, "Attacking RTSP routes")
routeAttempts := (len(a.dictionary.Routes()) + 1) * len(targets)
if routeAttempts > 0 {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTotalMessage(routeAttempts))
}
streams, err := runParallel(ctx, targets, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.attackRoutesForStream(ctx, target, true)
})
if err != nil {
a.reporter.Error(cameradar.StepAttackRoutes, err)
return streams, fmt.Errorf("attacking routes: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepAttackRoutes, "Finished route attacks")
return streams, nil
}
func (a Attacker) detectAuthPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepDetectAuth, "Detecting authentication methods")
if len(streams) > 0 {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTotalMessage(len(streams)))
}
streams, err := a.detectAuthMethods(ctx, streams)
if err != nil {
a.reporter.Error(cameradar.StepDetectAuth, err)
return streams, fmt.Errorf("detecting authentication methods: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepDetectAuth, "Authentication detection complete")
return streams, nil
}
func (a Attacker) attackCredentialsPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepAttackCredentials, "Attacking credentials")
credentialsAttempts := len(streams) * len(a.dictionary.Usernames()) * len(a.dictionary.Passwords())
if credentialsAttempts > 0 {
a.reporter.Progress(cameradar.StepAttackCredentials, cameradar.ProgressTotalMessage(credentialsAttempts))
}
streams, err := runParallel(ctx, streams, a.attackCredentialsForStream)
if err != nil {
a.reporter.Error(cameradar.StepAttackCredentials, err)
return streams, fmt.Errorf("attacking credentials: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepAttackCredentials, "Credential attacks complete")
return streams, nil
}
func (a Attacker) validateStreamsPhase(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Start(cameradar.StepValidateStreams, "Validating streams")
if len(streams) > 0 {
a.reporter.Progress(cameradar.StepValidateStreams, cameradar.ProgressTotalMessage(len(streams)))
}
streams, err := runParallel(ctx, streams, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.validateStream(ctx, target, true)
})
if err != nil {
a.reporter.Error(cameradar.StepValidateStreams, err)
return streams, fmt.Errorf("validating streams: %w", err)
}
updateSummary(a.reporter, streams)
a.reporter.Done(cameradar.StepValidateStreams, "Stream validation complete")
return streams, nil
}
func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream) ([]cameradar.Stream, error) {
a.reporter.Progress(cameradar.StepAttackRoutes, "Re-attacking routes for partial results")
updated, err := runParallel(ctx, streams, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.attackRoutesForStream(ctx, target, false)
})
if err != nil {
a.reporter.Error(cameradar.StepAttackRoutes, err)
return streams, fmt.Errorf("attacking routes: %w", err)
}
updated, err = runParallel(ctx, updated, func(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
return a.validateStream(ctx, target, false)
})
if err != nil {
a.reporter.Error(cameradar.StepValidateStreams, err)
return updated, fmt.Errorf("validating streams: %w", err)
}
updateSummary(a.reporter, updated)
return updated, nil
}
func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available {
continue
}
return true
}
return false
}
type summaryUpdater interface {
UpdateSummary(streams []cameradar.Stream)
}
func updateSummary(reporter Reporter, streams []cameradar.Stream) {
updater, ok := reporter.(summaryUpdater)
if !ok {
return
}
updater.UpdateSummary(streams)
}
func (a Attacker) attackCredentialsForStream(ctx context.Context, target cameradar.Stream) (cameradar.Stream, error) {
for _, username := range a.dictionary.Usernames() {
for _, password := range a.dictionary.Passwords() {
if ctx.Err() != nil {
return target, ctx.Err()
}
a.reporter.Progress(cameradar.StepAttackCredentials, cameradar.ProgressTickMessage())
ok, err := a.credAttack(target, username, password)
if err != nil {
target.CredentialsFound = false
msg := fmt.Sprintf("credential attempt failed for %s:%d (%s:%s): %v", target.Address.String(), target.Port, username, password, err)
a.reporter.Debug(cameradar.StepAttackCredentials, msg)
return target, nil
}
if ok {
target.CredentialsFound = true
target.Username = username
target.Password = password
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
a.reporter.Progress(cameradar.StepAttackCredentials, msg)
return target, nil
}
time.Sleep(a.attackInterval)
}
}
target.CredentialsFound = false
return target, nil
}
func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.Stream, emitProgress bool) (cameradar.Stream, error) {
if target.RouteFound {
return target, nil
}
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(target, dummyRoute)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("route probe failed for %s:%d: %v", target.Address.String(), target.Port, err))
return target, nil
}
if ok {
target.RouteFound = true
target.Routes = append(target.Routes, "/")
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil
}
for _, route := range a.dictionary.Routes() {
select {
case <-ctx.Done():
return target, ctx.Err()
case <-time.After(a.attackInterval):
}
if emitProgress {
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
}
ok, err := a.routeAttack(target, route)
if err != nil {
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("route attempt failed for %s:%d (%s): %v", target.Address.String(), target.Port, route, err))
return target, nil
}
if ok {
target.RouteFound = true
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))
}
}
return target, nil
}
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
return stream, nil
}
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err)
}
code, err := a.describeStatus(u)
if err != nil {
return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
return access, nil
}
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, stream.Route(), username, password)
if err != nil {
return false, fmt.Errorf("building rtsp url: %w", err)
}
code, err := a.describeStatus(u)
if err != nil {
return false, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepAttackCredentials, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
return code == base.StatusOK || code == base.StatusNotFound, nil
}
func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, emitProgress bool) (cameradar.Stream, error) {
if emitProgress {
defer a.reporter.Progress(cameradar.StepValidateStreams, cameradar.ProgressTickMessage())
}
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), stream.Username, stream.Password)
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()
desc, res, err := client.Describe(u)
if err != nil {
return a.handleDescribeError(stream, urlStr, err)
}
a.logDescribeResponse(urlStr, res)
if desc == nil || len(desc.Medias) == 0 {
return stream, fmt.Errorf("no media tracks found for %q", urlStr)
}
res, err = client.Setup(desc.BaseURL, desc.Medias[0], 0, 0)
if err != nil {
return a.handleSetupError(stream, urlStr, err)
}
a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK
if stream.Available {
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream validated for %s:%d", stream.Address.String(), stream.Port))
}
return stream, nil
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(),
stream.Port,
badStatus.Code,
))
stream.Available = false
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
func (a Attacker) handleSetupError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", urlStr, badStatus.Code))
stream.Available = badStatus.Code == base.StatusOK
return stream, nil
}
return stream, fmt.Errorf("performing setup request at %q: %w", urlStr, err)
}
func (a Attacker) logDescribeResponse(urlStr string, res *base.Response) {
if res == nil {
return
}
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
func (a Attacker) logSetupResponse(urlStr string, res *base.Response) {
if res == nil {
return
}
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("SETUP %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
+388
View File
@@ -0,0 +1,388 @@
package attack_test
import (
"strings"
"sync"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack"
"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"
)
func TestNew(t *testing.T) {
tests := []struct {
name string
dict attack.Dictionary
wantErr require.ErrorAssertionFunc
}{
{
name: "rejects nil dictionary",
dict: nil,
wantErr: require.Error,
},
{
name: "accepts dictionary",
dict: testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
},
wantErr: require.NoError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
attacker, err := attack.New(test.dict, 10*time.Millisecond, time.Second, ui.NopReporter{})
test.wantErr(t, err)
if err != nil {
assert.NotNil(t, attacker)
}
})
}
}
func TestAttacker_Attack_BasicAuth(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user", "other"},
passwords: []string{"pass", "bad"},
}
testInterval := time.Millisecond
testRequestTimeout := time.Second
attacker, err := attack.New(dict, testInterval, testRequestTimeout, 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.True(t, got[0].RouteFound)
assert.True(t, got[0].CredentialsFound)
assert.True(t, got[0].Available)
assert.Equal(t, cameradar.AuthBasic, got[0].AuthenticationType)
assert.Equal(t, "user", got[0].Username)
assert.Equal(t, "pass", got[0].Password)
assert.Contains(t, got[0].Routes, "stream")
}
func TestAttacker_Attack_AuthVariants(t *testing.T) {
tests := []struct {
name string
config rtspServerConfig
dict testDictionary
wantAuthType cameradar.AuthType
wantRoute bool
wantCreds bool
wantAvail bool
wantErr require.ErrorAssertionFunc
errContains string
}{
{
name: "no authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
},
dict: testDictionary{
routes: []string{"stream"},
},
wantAuthType: cameradar.AuthNone,
wantRoute: true,
wantCreds: false,
wantAvail: true,
wantErr: require.NoError,
},
{
name: "digest authentication",
config: rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodDigest,
},
dict: testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
},
wantAuthType: cameradar.AuthDigest,
wantRoute: true,
wantCreds: true,
wantAvail: true,
wantErr: require.NoError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPServer(t, test.config)
attacker, err := attack.New(test.dict, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
test.wantErr(t, err)
if test.errContains != "" {
assert.ErrorContains(t, err, test.errContains)
}
require.Len(t, got, 1)
assert.Equal(t, test.wantAuthType, got[0].AuthenticationType)
assert.Equal(t, test.wantRoute, got[0].RouteFound)
assert.Equal(t, test.wantCreds, got[0].CredentialsFound)
assert.Equal(t, test.wantAvail, got[0].Available)
})
}
}
func TestAttacker_Attack_ValidationErrors(t *testing.T) {
attacker, err := attack.New(testDictionary{routes: []string{"stream"}}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
tests := []struct {
name string
attacker attack.Attacker
targets []cameradar.Stream
wantErr string
}{
{
name: "fails with no targets",
attacker: attacker,
targets: nil,
wantErr: "no stream found",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := test.attacker.Attack(t.Context(), test.targets)
require.Error(t, err)
assert.ErrorContains(t, err, test.wantErr)
})
}
}
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"missing"},
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.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods")
require.Len(t, got, 1)
assert.False(t, got[0].RouteFound)
}
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"wrong"},
}
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.Error(t, err)
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.Equal(t, cameradar.AuthBasic, got[0].AuthenticationType)
assert.False(t, got[0].CredentialsFound)
}
func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
reporter := &recordingReporter{}
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: true,
username: "user",
password: "pass",
authMethod: headers.AuthMethodBasic,
failOnAuth: true,
})
dict := testDictionary{
routes: []string{"stream"},
usernames: []string{"user"},
passwords: []string{"pass"},
}
attacker, err := attack.New(dict, 0, time.Second, reporter)
require.NoError(t, err)
streams := []cameradar.Stream{{
Address: addr,
Port: port,
}}
got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err)
assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1)
assert.False(t, got[0].CredentialsFound)
}
func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowAll: true,
requireAuth: false,
authMethod: headers.AuthMethodBasic,
})
dict := testDictionary{}
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.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes)
assert.True(t, got[0].Available)
}
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
addr, port := startRTSPServer(t, rtspServerConfig{
allowedRoute: "stream",
requireAuth: false,
authMethod: headers.AuthMethodBasic,
setupStatus: base.StatusUnsupportedTransport,
})
dict := testDictionary{
routes: []string{"stream"},
}
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.False(t, got[0].Available)
assert.True(t, got[0].RouteFound)
}
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
}
type recordingReporter struct {
mu sync.Mutex
debugMessages []string
}
func (r *recordingReporter) Start(cameradar.Step, string) {}
func (r *recordingReporter) Done(cameradar.Step, string) {}
func (r *recordingReporter) Progress(cameradar.Step, string) {}
func (r *recordingReporter) Debug(_ cameradar.Step, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.debugMessages = append(r.debugMessages, message)
}
func (r *recordingReporter) Error(cameradar.Step, error) {}
func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {}
func (r *recordingReporter) HasDebugContaining(value string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, message := range r.debugMessages {
if strings.Contains(message, value) {
return true
}
}
return false
}
+109
View File
@@ -0,0 +1,109 @@
package attack
import (
"errors"
"net"
"net/url"
"strconv"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
)
func (a Attacker) newRTSPClient(u *base.URL) (*gortsplib.Client, error) {
client := &gortsplib.Client{
ReadTimeout: a.timeout,
WriteTimeout: a.timeout,
}
client.Scheme = u.Scheme
client.Host = u.Host
err := client.Start()
if err != nil {
return nil, err
}
return client, nil
}
func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
client, err := a.newRTSPClient(u)
if err != nil {
return 0, err
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil {
return badStatus.Code, nil
}
return 0, err
}
if res == nil {
return 0, errors.New("no response received")
}
return res.StatusCode, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 {
return cameradar.AuthNone
}
var hasBasic bool
var hasDigest bool
for _, value := range values {
var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil {
continue
}
switch authHeader.Method {
case headers.AuthMethodDigest:
hasDigest = true
case headers.AuthMethodBasic:
hasBasic = true
}
}
if hasDigest {
return cameradar.AuthDigest
}
if hasBasic {
return cameradar.AuthBasic
}
return cameradar.AuthType(-1)
}
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
path := "/" + route
if route == "" {
path = "/"
}
u := &url.URL{
Scheme: "rtsp",
Host: host,
Path: path,
}
if username != "" || password != "" {
u.User = url.UserPassword(username, password)
}
urlStr := u.String()
parsed, err := base.ParseURL(urlStr)
if err != nil {
return nil, "", err
}
return parsed, urlStr, nil
}
+166
View File
@@ -0,0 +1,166 @@
package attack_test
import (
"errors"
"net"
"net/netip"
"strings"
"testing"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/auth"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
"github.com/stretchr/testify/require"
)
type rtspServerConfig struct {
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authMethod headers.AuthMethod
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
type testServerHandler struct {
stream *gortsplib.ServerStream
allowAll bool
allowedRoute string
requireAuth bool
username string
password string
authHeader base.HeaderValue
failOnAuth bool
setupStatus base.StatusCode
}
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
if !h.routeAllowed(ctx.Path) {
return &base.Response{StatusCode: base.StatusNotFound}, nil, nil
}
if h.failOnAuth && len(ctx.Request.Header["Authorization"]) > 0 {
return &base.Response{StatusCode: base.StatusBadRequest}, nil, errors.New("forced auth failure")
}
if h.requireAuth && !ctx.Conn.VerifyCredentials(ctx.Request, h.username, h.password) {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": h.authHeader,
},
}, nil, liberrors.ErrServerAuth{}
}
return &base.Response{StatusCode: base.StatusOK}, h.stream, nil
}
func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*base.Response, *gortsplib.ServerStream, error) {
if !h.routeAllowed(ctx.Path) {
return &base.Response{StatusCode: base.StatusNotFound}, nil, nil
}
if h.requireAuth && !ctx.Conn.VerifyCredentials(ctx.Request, h.username, h.password) {
return &base.Response{
StatusCode: base.StatusUnauthorized,
Header: base.Header{
"WWW-Authenticate": h.authHeader,
},
}, nil, liberrors.ErrServerAuth{}
}
status := base.StatusOK
if h.setupStatus != 0 {
status = h.setupStatus
}
return &base.Response{StatusCode: status}, h.stream, nil
}
func (h *testServerHandler) routeAllowed(path string) bool {
path = strings.TrimLeft(path, "/")
return h.allowAll || path == h.allowedRoute
}
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
t.Helper()
handler := &testServerHandler{
allowAll: cfg.allowAll,
allowedRoute: cfg.allowedRoute,
requireAuth: cfg.requireAuth,
username: cfg.username,
password: cfg.password,
failOnAuth: cfg.failOnAuth,
setupStatus: cfg.setupStatus,
}
if len(cfg.authHeader) > 0 {
handler.authHeader = cfg.authHeader
} else {
authHeader := headers.Authenticate{
Method: cfg.authMethod,
Realm: "cameradar",
}
if cfg.authMethod == headers.AuthMethodDigest {
authHeader.Nonce = "nonce"
}
handler.authHeader = authHeader.Marshal()
}
server := &gortsplib.Server{
Handler: handler,
RTSPAddress: "127.0.0.1:0",
AuthMethods: authMethods(cfg.authMethod),
}
err := server.Start()
require.NoError(t, err)
t.Cleanup(server.Close)
desc := &description.Session{
Medias: []*description.Media{{
Type: description.MediaTypeVideo,
Formats: []format.Format{&format.H264{
PayloadTyp: 96,
PacketizationMode: 1,
}},
}},
}
stream := &gortsplib.ServerStream{
Server: server,
Desc: desc,
}
err = stream.Initialize()
require.NoError(t, err)
t.Cleanup(stream.Close)
handler.stream = stream
listener := server.NetListener()
require.NotNil(t, listener)
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
}
func authMethods(method headers.AuthMethod) []auth.VerifyMethod {
switch method {
case headers.AuthMethodDigest:
return []auth.VerifyMethod{auth.VerifyMethodDigestMD5}
case headers.AuthMethodBasic:
return []auth.VerifyMethod{auth.VerifyMethodBasic}
default:
return nil
}
}
+105
View File
@@ -0,0 +1,105 @@
package attack
import (
"context"
"runtime"
"sync"
"github.com/Ullaakut/cameradar/v6"
)
type attackFn func(context.Context, cameradar.Stream) (cameradar.Stream, error)
func runParallel(ctx context.Context, targets []cameradar.Stream, fn attackFn) ([]cameradar.Stream, error) {
if len(targets) == 0 {
return targets, nil
}
workerCount := parallelWorkerCount(len(targets))
if workerCount == 0 {
return targets, nil
}
errCh := make(chan error, 1)
jobs := make(chan attackJob)
updated := make([]cameradar.Stream, len(targets))
copy(updated, targets)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
for range workerCount {
wg.Go(func() {
runWorker(ctx, jobs, cancel, fn, updated, errCh)
})
}
queueJobs(ctx, jobs, targets)
close(jobs)
wg.Wait()
select {
case err := <-errCh:
return updated, err
default:
}
return updated, nil
}
type attackJob struct {
index int
stream cameradar.Stream
}
func queueJobs(ctx context.Context, jobs chan<- attackJob, targets []cameradar.Stream) {
for i, stream := range targets {
select {
case <-ctx.Done():
return
case jobs <- attackJob{index: i, stream: stream}:
}
}
}
func runWorker(ctx context.Context, jobs <-chan attackJob, cancelFn func(), fn attackFn, updated []cameradar.Stream, errCh chan error) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
stream, err := fn(ctx, job.stream)
if err != nil {
select {
case errCh <- err:
default:
}
cancelFn()
return
}
updated[job.index] = stream
}
}
}
func parallelWorkerCount(targetCount int) int {
if targetCount <= 0 {
return 0
}
workers := max(runtime.GOMAXPROCS(0), 1)
if targetCount < workers {
return targetCount
}
return workers
}
+81
View File
@@ -0,0 +1,81 @@
{
"usernames": [
"",
"666666",
"888888",
"Admin",
"admin",
"admin1",
"administrator",
"Administrator",
"aiphone",
"Dinion",
"none",
"root",
"Root",
"service",
"supervisor",
"ubnt"
],
"passwords": [
"",
"0000",
"00000",
"1111",
"111111",
"1111111",
"123",
"1234",
"12345",
"123456",
"1234567",
"12345678",
"123456789",
"12345678910",
"4321",
"666666",
"6fJjMKYx",
"888888",
"9999",
"admin",
"admin123456",
"admin pass",
"Admin",
"admin123",
"administrator",
"Administrator",
"aiphone",
"camera",
"Camera",
"fliradmin",
"GRwvcj8j",
"hikvision",
"hikadmin",
"HuaWei123",
"ikwd",
"jvc",
"kj3TqCWv",
"meinsm",
"pass",
"Pass",
"password",
"password123",
"qwerty",
"qwerty123",
"Recorder",
"reolink",
"root",
"service",
"supervisor",
"support",
"system",
"tlJwpbo6",
"toor",
"tp-link",
"ubnt",
"user",
"wbox",
"wbox123",
"Y5eIMz3C"
]
}
+196
View File
@@ -0,0 +1,196 @@
/live/ch01_0
0/1:1/main
0/usrnm:pwd/main
0/video1
1
1.AMP
1/h264major
1/stream1
11
12
125
1080p
1440p
480p
4K
666
720p
AVStream1_1
CAM_ID.password.mp2
CH001.sdp
GetData.cgi
HD
HighResolutionVideo
LowResolutionVideo
MediaInput/h264
MediaInput/mpeg4
ONVIF/MediaInput
ONVIF/MediaInput?profile=4_def_profile6
StdCh1
Streaming/Channels/1
Streaming/Unicast/channels/101
StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=1&ChannelName=Channel1
VideoInput/1/h264/1
VideoInput/1/mpeg4/1
access_code
access_name_for_stream_1_to_5
api/mjpegvideo.cgi
av0_0
av2
avc
avn=2
axis-media/media.amp
axis-media/media.amp?camera=1
axis-media/media.amp?videocodec=h264
cam
cam/realmonitor
cam/realmonitor?channel=0&subtype=0
cam/realmonitor?channel=1&subtype=0
cam/realmonitor?channel=1&subtype=1
cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif
cam0
cam0_0
cam0_1
cam1
cam1/h264
cam1/h264/multicast
cam1/mjpeg
cam1/mpeg4
cam1/mpeg4?user='username'&pwd='password'
cam1/onvif-h264
camera.stm
ch0
ch00/0
ch001.sdp
ch01.264
ch01.264?
ch01.264?ptype=tcp
ch1_0
ch2_0
ch3_0
ch4_0
ch1/0
ch2/0
ch3/0
ch4/0
ch0_0.h264
ch0_unicast_firststream
ch0_unicast_secondstream
ch1-s1
channel1
gnz_media/main
h264
h264.sdp
h264/ch1/sub/av_stream
h264/media.amp
h264Preview_01_main
h264Preview_01_sub
h264_vga.sdp
h264_stream
image.mpg
img/media.sav
img/media.sav?channel=1
img/video.asf
img/video.sav
ioImage/1
ipcam.sdp
ipcam_h264.sdp
ipcam_mjpeg.sdp
live
live.sdp
live/av0
live/ch0
live/ch00_0
live/ch01_0
live/h264
live/main
live/main0
live/mpeg4
live1.sdp
live3.sdp
live_mpeg4.sdp
live_st1
livestream
main
media
media.amp
media.amp?streamprofile=Profile1
media/media.amp
media/video1
medias2
mjpeg/media.smp
mp4
mpeg/media.amp
mpeg4
mpeg4/1/media.amp
mpeg4/media.amp
mpeg4/media.smp
mpeg4unicast
mpg4/rtsp.amp
multicaststream
now.mp4
nph-h264.cgi
nphMpeg4/g726-640x
nphMpeg4/g726-640x48
nphMpeg4/g726-640x480
nphMpeg4/nil-320x240
onvif-media/media.amp
onvif1
pass@10.0.0.5:6667/blinkhd
play1.sdp
play2.sdp
profile0
profile1
profile2
profile2/media.smp
profile5/media.smp
rtpvideo1.sdp
rtsp_live0
rtsp_live1
rtsp_live2
rtsp_tunnel
rtsph264
rtsph2641080p
snap.jpg
stream
stream/0
stream/1
stream/live.sdp
stream.sdp
stream1
streaming/channels/0
streaming/channels/1
streaming/channels/101
tcp/av0_0
test
tmpfs/auto.jpg
trackID=1
ucast/11
udp/av0_0
udp/unicast/aiphone_H264
udpstream
user.pin.mp2
user=admin&password=&channel=1&stream=0.sdp?
user=admin&password=&channel=1&stream=0.sdp?real_stream
user=admin_password=?????_channel=1_stream=0.sdp?real_stream
user=admin_password=R5XFY888_channel=1_stream=0.sdp?real_stream
user_defined
v2
video
video.3gp
video.h264
video.mjpg
video.mp4
video.pro1
video.pro2
video.pro3
video0
video0.sdp
video1
video1.sdp
video1+audio1
videoMain
videoinput_1/h264_1/media.stm
videostream.asf
vis
wfov
+11
View File
@@ -0,0 +1,11 @@
package dict
import (
_ "embed"
)
//go:embed assets/credentials.json
var defaultCredentials []byte
//go:embed assets/routes
var defaultRoutes string
+134
View File
@@ -0,0 +1,134 @@
package dict
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
)
// credentials is a map of credentials.
type credentials struct {
Usernames []string `json:"usernames"`
Passwords []string `json:"passwords"`
}
// routes is a slice of routes.
type routes []string
// Dictionary groups routes and credentials for attacks.
type Dictionary struct {
creds credentials
routes routes
}
// Usernames returns the usernames list.
func (d Dictionary) Usernames() []string {
return d.creds.Usernames
}
// Passwords returns the passwords list.
func (d Dictionary) Passwords() []string {
return d.creds.Passwords
}
// Routes returns the routes list.
func (d Dictionary) Routes() []string {
return d.routes
}
// New loads a dictionary using the provided configuration.
func New(credentialsPath, routesPath string) (Dictionary, error) {
creds, err := loadCredentials(credentialsPath)
if err != nil {
return Dictionary{}, err
}
routes, err := loadRoutes(routesPath)
if err != nil {
return Dictionary{}, err
}
return Dictionary{
creds: creds,
routes: routes,
}, nil
}
// loadCredentials loads credentials from a custom path or embedded defaults.
func loadCredentials(credentialsPath string) (credentials, error) {
if strings.TrimSpace(credentialsPath) != "" {
content, err := os.ReadFile(credentialsPath)
if err != nil {
return credentials{}, fmt.Errorf("reading credentials dictionary %q: %w", credentialsPath, err)
}
creds, err := parseCredentials(content)
if err != nil {
return credentials{}, err
}
return creds, nil
}
creds, err := parseCredentials(defaultCredentials)
if err != nil {
return credentials{}, err
}
return creds, nil
}
// loadRoutes loads routes from a custom path or embedded defaults.
func loadRoutes(routesPath string) (routes, error) {
if strings.TrimSpace(routesPath) != "" {
file, err := os.Open(routesPath)
if err != nil {
return nil, fmt.Errorf("opening routes dictionary %q: %w", routesPath, err)
}
defer file.Close()
routes, err := parseRoutes(file)
if err != nil {
return nil, err
}
return routes, nil
}
reader := strings.NewReader(defaultRoutes)
routes, err := parseRoutes(io.NopCloser(reader))
if err != nil {
return nil, err
}
return routes, nil
}
func parseCredentials(content []byte) (credentials, error) {
if len(content) == 0 {
return credentials{}, errors.New("credentials dictionary is empty")
}
var creds credentials
err := json.Unmarshal(content, &creds)
if err != nil {
return credentials{}, fmt.Errorf("reading dictionary contents: %w", err)
}
return creds, nil
}
func parseRoutes(reader io.ReadCloser) (routes, error) {
defer reader.Close()
var routes routes
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
routes = append(routes, scanner.Text())
}
return routes, scanner.Err()
}
+163
View File
@@ -0,0 +1,163 @@
package dict_test
import (
"bufio"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Ullaakut/cameradar/v6/internal/dict"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_LoadsDictionaryFromPaths(t *testing.T) {
tempDir := t.TempDir()
credsPath := writeTempFile(t, tempDir, "creds.json", `{"usernames":["alice"],"passwords":["secret"]}`)
routesPath := writeTempFile(t, tempDir, "routes", "stream\nother\n")
got, err := dict.New(credsPath, routesPath)
require.NoError(t, err)
assert.Equal(t, []string{"alice"}, got.Usernames())
assert.Equal(t, []string{"secret"}, got.Passwords())
assert.Equal(t, []string{"stream", "other"}, got.Routes())
}
func TestNew_CustomAndDefaultPaths(t *testing.T) {
tempDir := t.TempDir()
customCredsPath := writeTempFile(t, tempDir, "creds.json", `{"usernames":["alice"],"passwords":["secret"]}`)
customRoutesPath := writeTempFile(t, tempDir, "routes", "stream\nother\n")
tests := []struct {
name string
credentialsPath string
routesPath string
assertFunc func(t *testing.T, got dict.Dictionary)
}{
{
name: "custom credentials and routes",
credentialsPath: customCredsPath,
routesPath: customRoutesPath,
assertFunc: func(t *testing.T, got dict.Dictionary) {
assert.Equal(t, []string{"alice"}, got.Usernames())
assert.Equal(t, []string{"secret"}, got.Passwords())
assert.Equal(t, []string{"stream", "other"}, got.Routes())
},
},
{
name: "custom credentials default routes",
credentialsPath: customCredsPath,
assertFunc: func(t *testing.T, got dict.Dictionary) {
assert.Equal(t, []string{"alice"}, got.Usernames())
assert.Equal(t, []string{"secret"}, got.Passwords())
assert.NotEmpty(t, got.Routes())
assert.Contains(t, got.Routes(), "stream")
},
},
{
name: "default credentials custom routes",
routesPath: customRoutesPath,
assertFunc: func(t *testing.T, got dict.Dictionary) {
assert.NotEmpty(t, got.Usernames())
assert.Contains(t, got.Usernames(), "admin")
assert.NotEmpty(t, got.Passwords())
assert.Contains(t, got.Passwords(), "admin")
assert.Equal(t, []string{"stream", "other"}, got.Routes())
},
},
{
name: "whitespace paths use defaults",
credentialsPath: " \t\n",
routesPath: "\n\t",
assertFunc: func(t *testing.T, got dict.Dictionary) {
assert.NotEmpty(t, got.Usernames())
assert.Contains(t, got.Usernames(), "admin")
assert.NotEmpty(t, got.Passwords())
assert.Contains(t, got.Passwords(), "admin")
assert.NotEmpty(t, got.Routes())
assert.Contains(t, got.Routes(), "stream")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := dict.New(test.credentialsPath, test.routesPath)
require.NoError(t, err)
test.assertFunc(t, got)
})
}
}
func TestNew_Errors(t *testing.T) {
tempDir := t.TempDir()
validCredsPath := writeTempFile(t, tempDir, "creds.json", `{"usernames":["alice"],"passwords":["secret"]}`)
validRoutesPath := writeTempFile(t, tempDir, "routes", "stream\n")
invalidJSONPath := writeTempFile(t, tempDir, "invalid.json", "{")
emptyCredsPath := writeTempFile(t, tempDir, "empty.json", "")
longRoute := strings.Repeat("a", bufio.MaxScanTokenSize+1)
tooLongRoutesPath := writeTempFile(t, tempDir, "routes-too-long", longRoute)
tests := []struct {
name string
credentialsPath string
routesPath string
wantErrContains string
wantErrIs error
}{
{
name: "missing credentials file",
credentialsPath: filepath.Join(tempDir, "missing.json"),
routesPath: validRoutesPath,
wantErrContains: "reading credentials dictionary",
},
{
name: "invalid credentials json",
credentialsPath: invalidJSONPath,
routesPath: validRoutesPath,
wantErrContains: "reading dictionary contents",
},
{
name: "empty credentials file",
credentialsPath: emptyCredsPath,
routesPath: validRoutesPath,
wantErrContains: "credentials dictionary is empty",
},
{
name: "missing routes file",
credentialsPath: validCredsPath,
routesPath: filepath.Join(tempDir, "missing-routes"),
wantErrContains: "opening routes dictionary",
},
{
name: "routes file too long",
credentialsPath: validCredsPath,
routesPath: tooLongRoutesPath,
wantErrIs: bufio.ErrTooLong,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := dict.New(test.credentialsPath, test.routesPath)
require.Error(t, err)
if test.wantErrContains != "" {
assert.ErrorContains(t, err, test.wantErrContains)
}
if test.wantErrIs != nil {
assert.True(t, errors.Is(err, test.wantErrIs))
}
})
}
}
func writeTempFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
+126
View File
@@ -0,0 +1,126 @@
package output
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
)
type m3uReporter struct {
delegate ui.Reporter
outputPath string
}
// NewM3UReporter wraps the provided reporter and writes an M3U playlist on summary.
func NewM3UReporter(delegate ui.Reporter, outputPath string) ui.Reporter {
return &m3uReporter{
delegate: delegate,
outputPath: strings.TrimSpace(outputPath),
}
}
func (r *m3uReporter) Start(step cameradar.Step, message string) {
r.delegate.Start(step, message)
}
func (r *m3uReporter) Done(step cameradar.Step, message string) {
r.delegate.Done(step, message)
}
func (r *m3uReporter) Progress(step cameradar.Step, message string) {
r.delegate.Progress(step, message)
}
func (r *m3uReporter) Debug(step cameradar.Step, message string) {
r.delegate.Debug(step, message)
}
func (r *m3uReporter) Error(step cameradar.Step, err error) {
r.delegate.Error(step, err)
}
func (r *m3uReporter) Summary(streams []cameradar.Stream, err error) {
r.delegate.Summary(streams, err)
if r.outputPath == "" {
return
}
writeErr := writeM3UFile(r.outputPath, streams)
if writeErr != nil {
r.delegate.Error(cameradar.StepSummary, writeErr)
}
}
func (r *m3uReporter) UpdateSummary(streams []cameradar.Stream) {
updater, ok := r.delegate.(interface{ UpdateSummary([]cameradar.Stream) })
if !ok {
return
}
updater.UpdateSummary(streams)
}
func (r *m3uReporter) Close() {
r.delegate.Close()
}
func writeM3UFile(path string, streams []cameradar.Stream) error {
content := BuildM3U(streams)
dir := filepath.Dir(path)
if dir != "." {
err := os.MkdirAll(dir, 0o750)
if err != nil {
return fmt.Errorf("creating output directory %q: %w", dir, err)
}
}
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
return fmt.Errorf("writing m3u output: %w", err)
}
return nil
}
// BuildM3U creates an M3U playlist with discovered streams.
func BuildM3U(streams []cameradar.Stream) string {
var builder strings.Builder
builder.WriteString("#EXTM3U\n")
for _, stream := range streams {
url := formatRTSPURL(stream)
if url == "" {
continue
}
builder.WriteString("#EXTINF:-1,")
builder.WriteString(formatStreamLabel(stream))
builder.WriteString("\n")
builder.WriteString(url)
builder.WriteString("\n")
}
return builder.String()
}
func formatStreamLabel(stream cameradar.Stream) string {
label := stream.Address.String() + ":" + strconv.FormatUint(uint64(stream.Port), 10)
if stream.Device == "" {
return label
}
return label + " (" + stream.Device + ")"
}
func formatRTSPURL(stream cameradar.Stream) string {
path := strings.TrimSpace(stream.Route())
if path != "" && !strings.HasPrefix(path, "/") {
path = "/" + path
}
credentials := ""
if stream.CredentialsFound && (stream.Username != "" || stream.Password != "") {
credentials = stream.Username + ":" + stream.Password + "@"
}
return "rtsp://" + credentials + stream.Address.String() + ":" + strconv.FormatUint(uint64(stream.Port), 10) + path
}
+35
View File
@@ -0,0 +1,35 @@
package scan
import (
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/scan/nmap"
"github.com/Ullaakut/cameradar/v6/internal/scan/skip"
)
// Config configures how Cameradar discovers RTSP streams.
type Config struct {
SkipScan bool
Targets []string
Ports []string
ScanSpeed int16
}
// Reporter reports scan progress and debug information.
type Reporter interface {
Debug(step cameradar.Step, message string)
Progress(step cameradar.Step, message string)
}
// New builds a stream scanner based on the provided configuration.
func New(config Config, reporter Reporter) (cameradar.StreamScanner, error) {
expandedTargets, err := expandTargetsForScan(config.Targets)
if err != nil {
return nil, err
}
if config.SkipScan {
return skip.New(expandedTargets, config.Ports), nil
}
return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter)
}
+66
View File
@@ -0,0 +1,66 @@
package scan_test
import (
"net/netip"
"testing"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/scan"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_UsesSkipScanner(t *testing.T) {
config := scan.Config{
SkipScan: true,
Targets: []string{
"192.0.2.0/30",
"192.0.2.10-11",
},
Ports: []string{"554", "8554-8555"},
ScanSpeed: 4,
}
scanner, err := scan.New(config, nil)
require.NoError(t, err)
streams, err := scanner.Scan(t.Context())
require.NoError(t, err)
addrs := []netip.Addr{
netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"),
netip.MustParseAddr("192.0.2.3"),
netip.MustParseAddr("192.0.2.10"),
netip.MustParseAddr("192.0.2.11"),
}
portsExpected := []uint16{554, 8554, 8555}
var expected []cameradar.Stream
for _, addr := range addrs {
for _, port := range portsExpected {
expected = append(expected, cameradar.Stream{
Address: addr,
Port: port,
})
}
}
assert.Equal(t, expected, streams)
}
func TestNew_SkipScanPropagatesErrors(t *testing.T) {
config := scan.Config{
SkipScan: true,
Targets: []string{"192.0.2.1"},
Ports: []string{"8555-8554"},
}
scanner, err := scan.New(config, nil)
require.NoError(t, err)
_, err = scanner.Scan(t.Context())
require.Error(t, err)
assert.ErrorContains(t, err, "invalid port range")
}
+106
View File
@@ -0,0 +1,106 @@
package nmap
import (
"context"
"fmt"
"net/netip"
"strings"
"github.com/Ullaakut/cameradar/v6"
nmaplib "github.com/Ullaakut/nmap/v4"
)
// Reporter reports scan progress and debug information.
type Reporter interface {
Debug(step cameradar.Step, message string)
Progress(step cameradar.Step, message string)
}
// Runner is something that can run an nmap scan.
type Runner interface {
Run(ctx context.Context) (*nmaplib.Run, error)
}
// Scanner scans targets and ports for RTSP streams.
type Scanner struct {
runner Runner
reporter Reporter
}
// New returns a Scanner configured with the provided terminal and scan speed.
func New(scanSpeed int16, targets, ports []string, reporter Reporter) (*Scanner, error) {
runner, err := nmaplib.NewScanner(
nmaplib.WithTargets(targets...),
nmaplib.WithPorts(ports...),
nmaplib.WithServiceInfo(),
nmaplib.WithTimingTemplate(nmaplib.Timing(scanSpeed)),
)
if err != nil {
return nil, fmt.Errorf("creating nmap scanner: %w", err)
}
return &Scanner{
runner: runner,
reporter: reporter,
}, nil
}
// Scan discovers RTSP streams on the configured targets and ports.
func (s *Scanner) Scan(ctx context.Context) ([]cameradar.Stream, error) {
return runScan(ctx, s.runner, s.reporter)
}
func runScan(ctx context.Context, nmap Runner, reporter Reporter) ([]cameradar.Stream, error) {
results, err := nmap.Run(ctx)
if err != nil {
return nil, fmt.Errorf("scanning network: %w", err)
}
for _, warning := range results.Warnings() {
reporter.Debug(cameradar.StepScan, "nmap warning: "+warning)
}
var streams []cameradar.Stream
for _, host := range results.Hosts {
for _, port := range host.Ports {
if port.Status() != "open" {
continue
}
if !strings.Contains(port.Service.Name, "rtsp") {
continue
}
for _, address := range host.Addresses {
addr, err := netip.ParseAddr(address.Addr)
if err != nil {
reporter.Progress(cameradar.StepScan, fmt.Sprintf("Skipping invalid address %q: %v", address.Addr, err))
continue
}
streams = append(streams, cameradar.Stream{
Device: port.Service.Product,
Address: addr,
Port: port.ID,
})
}
}
}
reporter.Progress(cameradar.StepScan, fmt.Sprintf("Found %d RTSP streams", len(streams)))
updateSummary(reporter, streams)
return streams, nil
}
type summaryUpdater interface {
UpdateSummary(streams []cameradar.Stream)
}
func updateSummary(reporter Reporter, streams []cameradar.Stream) {
updater, ok := reporter.(summaryUpdater)
if !ok {
return
}
updater.UpdateSummary(streams)
}
+187
View File
@@ -0,0 +1,187 @@
package nmap
import (
"context"
"errors"
"net/netip"
"sync"
"testing"
"github.com/Ullaakut/cameradar/v6"
nmaplib "github.com/Ullaakut/nmap/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScanner_Scan(t *testing.T) {
ctx := context.WithValue(t.Context(), contextKey("trace"), "scan")
tests := []struct {
name string
result *nmaplib.Run
err error
wantStreams []cameradar.Stream
wantDebug []string
wantProgress string
wantErrContains string
}{
{
name: "filters non-rtsp and closed ports",
result: buildRun(nmaplib.Host{
Addresses: []nmaplib.Address{
{Addr: "127.0.0.1"},
{Addr: "not-an-ip"},
},
Ports: []nmaplib.Port{
openPort(8554, "rtsp", "ACME"),
closedPort(554, "rtsp", "ACME"),
openPort(80, "http", "ACME"),
},
}),
wantStreams: []cameradar.Stream{
{
Device: "ACME",
Address: netip.MustParseAddr("127.0.0.1"),
Port: 8554,
},
},
wantProgress: "Found 1 RTSP streams",
},
{
name: "collects multiple hosts",
result: buildRun(
nmaplib.Host{
Addresses: []nmaplib.Address{{Addr: "192.0.2.10"}, {Addr: "192.0.2.11"}},
Ports: []nmaplib.Port{
openPort(8554, "rtsp-alt", "Model A"),
},
},
nmaplib.Host{
Addresses: []nmaplib.Address{{Addr: "198.51.100.9"}},
Ports: []nmaplib.Port{
openPort(554, "rtsp", "Model B"),
},
},
),
wantStreams: []cameradar.Stream{
{
Device: "Model A",
Address: netip.MustParseAddr("192.0.2.10"),
Port: 8554,
},
{
Device: "Model A",
Address: netip.MustParseAddr("192.0.2.11"),
Port: 8554,
},
{
Device: "Model B",
Address: netip.MustParseAddr("198.51.100.9"),
Port: 554,
},
},
wantProgress: "Found 3 RTSP streams",
},
{
name: "returns error when scan fails",
err: errors.New("scan failed"),
wantErrContains: "scanning network",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reporter := &recordingReporter{}
scanner, err := New(4, []string{"192.0.2.1"}, []string{"554", "8554"}, reporter)
require.NoError(t, err)
scanner.runner = fakeRunner{result: test.result, err: test.err}
streams, err := scanner.Scan(ctx)
if test.wantErrContains != "" {
require.Error(t, err)
assert.ErrorContains(t, err, test.wantErrContains)
assert.Empty(t, streams)
assert.Empty(t, reporter.progress)
assert.Equal(t, test.wantDebug, reporter.debug)
return
}
require.NoError(t, err)
assert.Equal(t, test.wantStreams, streams)
assert.Equal(t, test.wantDebug, reporter.debug)
assert.Contains(t, reporter.progress, test.wantProgress)
})
}
}
type contextKey string
type fakeRunner struct {
result *nmaplib.Run
err error
}
func (f fakeRunner) Run(context.Context) (*nmaplib.Run, error) {
return f.result, f.err
}
type recordingReporter struct {
mu sync.Mutex
debug []string
progress []string
}
func (r *recordingReporter) Start(cameradar.Step, string) {}
func (r *recordingReporter) Done(cameradar.Step, string) {}
func (r *recordingReporter) Progress(_ cameradar.Step, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.progress = append(r.progress, message)
}
func (r *recordingReporter) Debug(_ cameradar.Step, message string) {
r.mu.Lock()
defer r.mu.Unlock()
r.debug = append(r.debug, message)
}
func (r *recordingReporter) Error(cameradar.Step, error) {}
func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
func (r *recordingReporter) Close() {}
func buildRun(hosts ...nmaplib.Host) *nmaplib.Run {
return &nmaplib.Run{Hosts: hosts}
}
func openPort(id uint16, serviceName, product string) nmaplib.Port {
return nmaplib.Port{
ID: id,
State: nmaplib.State{
State: string(nmaplib.Open),
},
Service: nmaplib.Service{
Name: serviceName,
Product: product,
},
}
}
func closedPort(id uint16, serviceName, product string) nmaplib.Port {
return nmaplib.Port{
ID: id,
State: nmaplib.State{
State: string(nmaplib.Closed),
},
Service: nmaplib.Service{
Name: serviceName,
Product: product,
},
}
}
+338
View File
@@ -0,0 +1,338 @@
package skip
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"strconv"
"strings"
"github.com/Ullaakut/cameradar/v6"
)
// Scanner is a stream scanner that skips discovery and treats every target/port as a stream.
type Scanner struct {
targets []string
ports []string
}
// New builds a scanner that skips discovery and treats every target/port as a stream.
func New(targets, ports []string) *Scanner {
return &Scanner{
targets: targets,
ports: ports,
}
}
// Scan returns the precomputed list of streams.
func (s *Scanner) Scan(ctx context.Context) ([]cameradar.Stream, error) {
return buildStreamsFromTargets(ctx, s.targets, s.ports)
}
func buildStreamsFromTargets(ctx context.Context, targets, ports []string) ([]cameradar.Stream, error) {
resolvedPorts, err := parsePorts(ctx, ports)
if err != nil {
return nil, err
}
if len(resolvedPorts) == 0 {
return nil, errors.New("no valid ports provided")
}
resolvedTargets, err := expandTargets(ctx, targets)
if err != nil {
return nil, err
}
if len(resolvedTargets) == 0 {
return nil, errors.New("no valid target addresses resolved")
}
streams := make([]cameradar.Stream, 0, len(resolvedTargets)*len(resolvedPorts))
for _, addr := range resolvedTargets {
for _, port := range resolvedPorts {
streams = append(streams, cameradar.Stream{
Address: addr,
Port: port,
})
}
}
return streams, nil
}
func parsePorts(ctx context.Context, ports []string) ([]uint16, error) {
seen := make(map[uint16]struct{})
resolved := make([]uint16, 0, len(ports))
for _, entry := range ports {
for raw := range strings.SplitSeq(entry, ",") {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
values, err := parsePortValue(ctx, value)
if err != nil {
return nil, err
}
for _, port := range values {
if _, exists := seen[port]; exists {
continue
}
seen[port] = struct{}{}
resolved = append(resolved, port)
}
}
}
return resolved, nil
}
func parsePortValue(ctx context.Context, value string) ([]uint16, error) {
if strings.Contains(value, "-") {
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid port range %q", value)
}
start, err := parsePortNumber(strings.TrimSpace(parts[0]))
if err != nil {
return nil, fmt.Errorf("invalid port range %q: %w", value, err)
}
end, err := parsePortNumber(strings.TrimSpace(parts[1]))
if err != nil {
return nil, fmt.Errorf("invalid port range %q: %w", value, err)
}
if start > end {
return nil, fmt.Errorf("invalid port range %q", value)
}
ports := make([]uint16, 0, end-start+1)
for port := start; port <= end; port++ {
ports = append(ports, port)
}
return ports, nil
}
port, err := parsePortNumber(value)
if err == nil {
return []uint16{port}, nil
}
servicePort, lookupErr := net.DefaultResolver.LookupPort(ctx, "tcp", value)
if lookupErr != nil {
return nil, fmt.Errorf("invalid port %q", value)
}
if servicePort < 1 || servicePort > 65535 {
return nil, fmt.Errorf("port %d out of range", servicePort)
}
return []uint16{uint16(servicePort)}, nil
}
func parsePortNumber(value string) (uint16, error) {
port, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("port %d out of range", port)
}
return uint16(port), nil
}
func expandTargets(ctx context.Context, targets []string) ([]netip.Addr, error) {
seen := make(map[netip.Addr]struct{})
resolved := make([]netip.Addr, 0, len(targets))
for _, target := range targets {
value := strings.TrimSpace(target)
if value == "" {
continue
}
addrs, err := parseTargetAddrs(ctx, value)
if err != nil {
return nil, err
}
for _, addr := range addrs {
if !addr.IsValid() {
continue
}
if _, exists := seen[addr]; exists {
continue
}
seen[addr] = struct{}{}
resolved = append(resolved, addr)
}
}
return resolved, nil
}
func parseTargetAddrs(ctx context.Context, target string) ([]netip.Addr, error) {
prefix, err := netip.ParsePrefix(target)
if err == nil { // Return early.
return expandPrefix(prefix), nil
}
if strings.Contains(target, "-") {
addrs, ok, err := parseIPv4Range(target)
if ok {
return addrs, err
}
}
addr, err := netip.ParseAddr(target)
if err == nil { // Return early.
return []netip.Addr{addr}, nil
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, target)
if err != nil {
return nil, fmt.Errorf("resolving hostname %q: %w", target, err)
}
addrs := make([]netip.Addr, 0, len(ips))
for _, ip := range ips {
addr, ok := netip.AddrFromSlice(ip.IP)
if !ok {
continue
}
addrs = append(addrs, addr.Unmap())
}
if len(addrs) == 0 {
return nil, fmt.Errorf("no ip addresses found for hostname %q", target)
}
return addrs, nil
}
func expandPrefix(prefix netip.Prefix) []netip.Addr {
if !prefix.IsValid() {
return nil
}
prefix = prefix.Masked()
addr := prefix.Addr()
addrs := make([]netip.Addr, 0, 16)
for current := addr; prefix.Contains(current); {
addrs = append(addrs, current)
next := current.Next()
if !next.IsValid() {
break
}
current = next
}
return addrs
}
type octetRange struct {
start int
end int
}
func parseIPv4Range(target string) ([]netip.Addr, bool, error) {
parts := strings.Split(target, ".")
if len(parts) != 4 {
return nil, false, nil
}
ranges := make([]octetRange, 4)
for i, part := range parts {
parsed, ok, err := parseOctetRange(part)
if err != nil {
return nil, true, err
}
if !ok {
return nil, false, nil
}
ranges[i] = parsed
}
addrs := make([]netip.Addr, 0, 16)
for first := ranges[0].start; first <= ranges[0].end; first++ {
for second := ranges[1].start; second <= ranges[1].end; second++ {
for third := ranges[2].start; third <= ranges[2].end; third++ {
for fourth := ranges[3].start; fourth <= ranges[3].end; fourth++ {
addrs = append(addrs, netip.AddrFrom4([4]byte{
byte(first),
byte(second),
byte(third),
byte(fourth),
}))
}
}
}
}
return addrs, true, nil
}
func parseOctetRange(value string) (octetRange, bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return octetRange{}, false, nil
}
if strings.Contains(value, "-") {
parts := strings.SplitN(value, "-", 2)
if len(parts) != 2 {
return octetRange{}, true, fmt.Errorf("invalid range %q", value)
}
start, err := parseOctetValue(strings.TrimSpace(parts[0]))
if err != nil {
return octetRange{}, true, err
}
end, err := parseOctetValue(strings.TrimSpace(parts[1]))
if err != nil {
return octetRange{}, true, err
}
if start > end {
return octetRange{}, true, fmt.Errorf("invalid range %q", value)
}
return octetRange{start: start, end: end}, true, nil
}
if !isDigits(value) {
return octetRange{}, false, nil
}
octet, err := parseOctetValue(value)
if err != nil {
return octetRange{}, true, err
}
return octetRange{start: octet, end: octet}, true, nil
}
func parseOctetValue(value string) (int, error) {
if !isDigits(value) {
return 0, fmt.Errorf("invalid octet %q", value)
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid octet %q", value)
}
if parsed < 0 || parsed > 255 {
return 0, fmt.Errorf("octet %d out of range", parsed)
}
return parsed, nil
}
func isDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return value != ""
}
+104
View File
@@ -0,0 +1,104 @@
package skip_test
import (
"net/netip"
"strconv"
"testing"
"github.com/Ullaakut/cameradar/v6/internal/scan/skip"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
targets := []string{
"192.0.2.0/30",
"localhost",
"192.0.2.15",
"192.0.2.10-11",
}
ports := []string{"554", "8554-8555"}
scanner := skip.New(targets, ports)
streams, err := scanner.Scan(t.Context())
require.NoError(t, err)
addrs := []netip.Addr{
netip.MustParseAddr("127.0.0.1"),
netip.MustParseAddr("192.0.2.0"),
netip.MustParseAddr("192.0.2.1"),
netip.MustParseAddr("192.0.2.2"),
netip.MustParseAddr("192.0.2.3"),
netip.MustParseAddr("192.0.2.10"),
netip.MustParseAddr("192.0.2.11"),
netip.MustParseAddr("192.0.2.15"),
}
portsExpected := []uint16{554, 8554, 8555}
var want []string
for _, addr := range addrs {
for _, port := range portsExpected {
want = append(want, addr.String()+":"+strconv.Itoa(int(port)))
}
}
var got []string
for _, stream := range streams {
got = append(got, stream.Address.String()+":"+strconv.Itoa(int(stream.Port)))
}
assert.ElementsMatch(t, want, got)
}
func TestNew_ReturnsErrorOnInvalidPortRange(t *testing.T) {
scanner := skip.New([]string{"192.0.2.1"}, []string{"8555-8554"})
_, err := scanner.Scan(t.Context())
require.Error(t, err)
assert.ErrorContains(t, err, "invalid port range")
}
func TestNew_ReturnsErrorOnEmptyTargets(t *testing.T) {
scanner := skip.New([]string{}, []string{"554"})
_, err := scanner.Scan(t.Context())
require.Error(t, err)
assert.ErrorContains(t, err, "no valid target addresses resolved")
}
func TestNew_ResolvesServicePorts(t *testing.T) {
scanner := skip.New([]string{"127.0.0.1"}, []string{"http"})
streams, err := scanner.Scan(t.Context())
require.NoError(t, err)
require.Len(t, streams, 1)
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address)
assert.Equal(t, uint16(80), streams[0].Port)
}
func TestNew_ReturnsErrorOnUnknownServicePort(t *testing.T) {
scanner := skip.New([]string{"127.0.0.1"}, []string{"not-a-service"})
_, err := scanner.Scan(t.Context())
require.Error(t, err)
assert.ErrorContains(t, err, "invalid port")
}
func TestNew_ResolvesHostnames(t *testing.T) {
scanner := skip.New([]string{"localhost"}, []string{"554"})
streams, err := scanner.Scan(t.Context())
require.NoError(t, err)
require.NotEmpty(t, streams)
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address)
}
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
scanner := skip.New([]string{"does-not-exist.invalid"}, []string{"554"})
_, err := scanner.Scan(t.Context())
require.Error(t, err)
assert.ErrorContains(t, err, "resolving hostname")
}
+139
View File
@@ -0,0 +1,139 @@
package scan
import (
"fmt"
"math/bits"
"net/netip"
"strconv"
"strings"
)
func expandTargetsForScan(targets []string) ([]string, error) {
expanded := make([]string, 0, len(targets))
for _, target := range targets {
value := strings.TrimSpace(target)
if value == "" {
continue
}
addrs, ok, err := parseIPv4RangePair(value)
if err != nil {
return nil, err
}
if ok {
expanded = append(expanded, addrs...)
continue
}
expanded = append(expanded, value)
}
return expanded, nil
}
// Parse masscan range formats.
func parseIPv4RangePair(target string) ([]string, bool, error) {
parts := strings.SplitN(target, "-", 2)
if len(parts) != 2 {
return nil, false, nil
}
startValue := strings.TrimSpace(parts[0])
endValue := strings.TrimSpace(parts[1])
if startValue == "" || endValue == "" {
return nil, false, nil
}
// Fall through if this is in nmap range format.
if endIsOctet(endValue) {
return nil, false, nil
}
startAddr, startOK := parseIPv4Addr(startValue)
endAddr, endOK := parseIPv4Addr(endValue)
if !startOK && !endOK { // Allows the case where the target is just a hostname with a dash.
return nil, false, nil
}
if !startOK || !endOK { // Prevents the case where one is an address and the other part is not.
return nil, false, fmt.Errorf("invalid range %q", target)
}
startAddr = startAddr.Unmap()
endAddr = endAddr.Unmap()
if !startAddr.Is4() || !endAddr.Is4() {
return nil, true, fmt.Errorf("invalid range %q", target)
}
start := ipv4ToUint32(startAddr)
end := ipv4ToUint32(endAddr)
if start > end {
return nil, true, fmt.Errorf("invalid range %q", target)
}
return expandIPv4RangeToTargets(start, end), true, nil
}
func parseIPv4Addr(value string) (netip.Addr, bool) {
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Addr{}, false
}
return addr, true
}
func endIsOctet(value string) bool {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return false
}
return parsed >= 0 && parsed <= 255
}
func expandIPv4RangeToTargets(start, end uint32) []string {
if start > end {
return nil
}
const maxUint32 = uint64(^uint32(0))
remaining := uint64(end) - uint64(start) + 1
results := make([]string, 0, 16)
for current := uint64(start); remaining > 0; {
if current > maxUint32 {
return results
}
current32 := uint32(current)
maxSize := uint64(1) << bits.TrailingZeros32(current32)
for maxSize > remaining {
maxSize >>= 1
}
prefixLen := 32 - (bits.Len64(maxSize) - 1)
addr := uint32ToIPv4(current32)
if maxSize == 1 {
results = append(results, addr.String())
} else {
results = append(results, fmt.Sprintf("%s/%d", addr.String(), prefixLen))
}
current += maxSize
remaining -= maxSize
}
return results
}
func ipv4ToUint32(addr netip.Addr) uint32 {
value := addr.As4()
return uint32(value[0])<<24 | uint32(value[1])<<16 | uint32(value[2])<<8 | uint32(value[3])
}
func uint32ToIPv4(value uint32) netip.Addr {
return netip.AddrFrom4([4]byte{
byte(value >> 24),
byte(value >> 16),
byte(value >> 8),
byte(value),
})
}
+73
View File
@@ -0,0 +1,73 @@
package scan
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpandTargetsForScan_ExpandsFullIPv4Range(t *testing.T) {
targets := []string{
"192.0.2.10-192.0.2.12",
"192.168.1.140-255",
"192.0.2.0/30",
"localhost",
"",
}
got, err := expandTargetsForScan(targets)
require.NoError(t, err)
assert.ElementsMatch(t, []string{
"192.0.2.10/31",
"192.0.2.12",
"192.168.1.140-255",
"192.0.2.0/30",
"localhost",
}, got)
}
func TestExpandTargetsForScan_ReturnsErrorOnInvalidRange(t *testing.T) {
t.Run("inverted range", func(t *testing.T) {
_, err := expandTargetsForScan([]string{"192.0.2.12-192.0.2.10"})
require.Error(t, err)
assert.ErrorContains(t, err, "invalid range")
})
t.Run("invalid range", func(t *testing.T) {
_, err := expandTargetsForScan([]string{"192.0.2.12-foo"})
require.Error(t, err)
assert.ErrorContains(t, err, "invalid range")
})
t.Run("hostname with dash", func(t *testing.T) {
tgts, err := expandTargetsForScan([]string{"my-host.com"})
require.NoError(t, err)
assert.Equal(t, []string{"my-host.com"}, tgts)
})
t.Run("ends with dash", func(t *testing.T) {
tgts, err := expandTargetsForScan([]string{"a-"})
require.NoError(t, err)
assert.Equal(t, []string{"a-"}, tgts)
})
t.Run("starts with dash", func(t *testing.T) {
tgts, err := expandTargetsForScan([]string{"-a"})
require.NoError(t, err)
assert.Equal(t, []string{"-a"}, tgts)
})
t.Run("only a dash", func(t *testing.T) {
tgts, err := expandTargetsForScan([]string{"-"})
require.NoError(t, err)
assert.Equal(t, []string{"-"}, tgts)
})
t.Run("nmap format", func(t *testing.T) {
tgts, err := expandTargetsForScan([]string{"192.168.1.10-255"})
require.NoError(t, err)
assert.Equal(t, []string{"192.168.1.10-255"}, tgts)
})
}
+29
View File
@@ -0,0 +1,29 @@
package ui
import (
"github.com/Ullaakut/cameradar/v6"
)
// NopReporter discards all UI events.
type NopReporter struct{}
// Start implements Reporter.
func (NopReporter) Start(cameradar.Step, string) {}
// Done implements Reporter.
func (NopReporter) Done(cameradar.Step, string) {}
// Progress implements Reporter.
func (NopReporter) Progress(cameradar.Step, string) {}
// Debug implements Reporter.
func (NopReporter) Debug(cameradar.Step, string) {}
// Error implements Reporter.
func (NopReporter) Error(cameradar.Step, error) {}
// Summary implements Reporter.
func (NopReporter) Summary([]cameradar.Stream, error) {}
// Close implements Reporter.
func (NopReporter) Close() {}
+75
View File
@@ -0,0 +1,75 @@
package ui
import (
"fmt"
"io"
"time"
"github.com/Ullaakut/cameradar/v6"
)
// PlainReporter renders a line-oriented UI for non-interactive terminals.
type PlainReporter struct {
out io.Writer
debug bool
}
// NewPlainReporter creates a line-oriented reporter.
func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
return &PlainReporter{
out: out,
debug: debug,
}
}
// Start prints the beginning of a step.
func (r *PlainReporter) Start(step cameradar.Step, message string) {
r.print(step, "START", message)
}
// Done prints the completion of a step.
func (r *PlainReporter) Done(step cameradar.Step, message string) {
r.print(step, "DONE", message)
}
// Progress prints a progress message.
func (r *PlainReporter) Progress(step cameradar.Step, message string) {
if _, _, ok := cameradar.ParseProgressMessage(message); ok {
return
}
r.print(step, "INFO", message)
}
// Debug prints a debug message when debug mode is enabled.
func (r *PlainReporter) Debug(step cameradar.Step, message string) {
if !r.debug {
return
}
r.print(step, "DEBUG", message)
}
// Error prints an error message.
func (r *PlainReporter) Error(step cameradar.Step, err error) {
if err == nil {
return
}
r.print(step, "ERROR", err.Error())
}
// Summary prints the final summary.
func (r *PlainReporter) Summary(streams []cameradar.Stream, err error) {
_, _ = fmt.Fprintln(r.out, "Summary")
_, _ = fmt.Fprintln(r.out, "-------")
_, _ = fmt.Fprintln(r.out, FormatSummary(streams, err))
}
// Close is a no-op for the plain reporter.
func (r *PlainReporter) Close() {}
func (r *PlainReporter) print(step cameradar.Step, level, message string) {
if message == "" {
return
}
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339))
}
+47
View File
@@ -0,0 +1,47 @@
package ui_test
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestPlainReporter_Outputs(t *testing.T) {
t.Run("prints events", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, true)
reporter.Start(cameradar.StepScan, "starting")
reporter.Progress(cameradar.StepScan, "working")
reporter.Debug(cameradar.StepScan, "details")
reporter.Done(cameradar.StepScan, "finished")
reporter.Error(cameradar.StepScan, errors.New("boom"))
reporter.Summary([]cameradar.Stream{}, nil)
content := out.String()
assert.Contains(t, content, "[START] Scan targets: starting")
assert.Contains(t, content, "[INFO] Scan targets: working")
assert.Contains(t, content, "[DEBUG] Scan targets: details")
assert.Contains(t, content, "[DONE] Scan targets: finished")
assert.Contains(t, content, "[ERROR] Scan targets: boom")
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
})
t.Run("respects debug flag and empty input", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.Debug(cameradar.StepScan, "hidden")
reporter.Progress(cameradar.StepScan, "")
reporter.Error(cameradar.StepScan, nil)
content := out.String()
assert.NotContains(t, content, "DEBUG")
assert.Equal(t, "", strings.TrimSpace(content))
})
}
+44
View File
@@ -0,0 +1,44 @@
package ui
import (
"errors"
"fmt"
"io"
"github.com/Ullaakut/cameradar/v6"
)
// Reporter defines the interface for cameradar UIs.
type Reporter interface {
Start(step cameradar.Step, message string)
Done(step cameradar.Step, message string)
Progress(step cameradar.Step, message string)
Debug(step cameradar.Step, message string)
Error(step cameradar.Step, err error)
Summary(streams []cameradar.Stream, err error)
Close()
}
// NewReporter creates a Reporter based on the requested mode.
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) {
if debug {
return NewPlainReporter(out, debug), nil
}
switch mode {
case cameradar.ModePlain:
return NewPlainReporter(out, debug), nil
case cameradar.ModeTUI:
if !interactive {
return nil, errors.New("tui mode requires an interactive terminal")
}
return NewTUIReporter(debug, out)
case cameradar.ModeAuto:
if interactive {
return NewTUIReporter(debug, out)
}
return NewPlainReporter(out, debug), nil
default:
return nil, fmt.Errorf("unsupported ui mode %q", mode)
}
}
+94
View File
@@ -0,0 +1,94 @@
package ui_test
import (
"bytes"
"testing"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewReporter(t *testing.T) {
tests := []struct {
name string
mode cameradar.Mode
interactive bool
wantType string
wantErrContains string
}{
{
name: "plain",
mode: cameradar.ModePlain,
interactive: false,
wantType: "plain",
},
{
name: "auto non-interactive",
mode: cameradar.ModeAuto,
interactive: false,
wantType: "plain",
},
{
name: "tui non-interactive",
mode: cameradar.ModeTUI,
interactive: false,
wantErrContains: "interactive terminal",
},
{
name: "unsupported",
mode: cameradar.Mode("unknown"),
interactive: false,
wantErrContains: "unsupported ui mode",
},
{
name: "auto interactive",
mode: cameradar.ModeAuto,
interactive: true,
wantType: "tui",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out := &bytes.Buffer{}
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive)
if test.wantErrContains != "" {
require.Error(t, err)
assert.ErrorContains(t, err, test.wantErrContains)
assert.Nil(t, reporter)
return
}
require.NoError(t, err)
require.NotNil(t, reporter)
switch test.wantType {
case "plain":
_, ok := reporter.(*ui.PlainReporter)
assert.True(t, ok)
case "tui":
_, ok := reporter.(*ui.TUIReporter)
assert.True(t, ok)
}
reporter.Close()
})
}
}
func TestNopReporter_DoesNotPanic(t *testing.T) {
reporter := ui.NopReporter{}
assert.NotPanics(t, func() {
reporter.Start(cameradar.StepScan, "start")
reporter.Done(cameradar.StepScan, "done")
reporter.Progress(cameradar.StepScan, "progress")
reporter.Debug(cameradar.StepScan, "debug")
reporter.Error(cameradar.StepScan, assert.AnError)
reporter.Summary(nil, nil)
reporter.Close()
})
}
+177
View File
@@ -0,0 +1,177 @@
package ui
import (
"strings"
"github.com/Ullaakut/cameradar/v6"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
type modelState struct {
steps []cameradar.Step
status map[cameradar.Step]state
logs []logMsg
summary []summaryTable
summaryStreams []cameradar.Stream
summaryFinal bool
debug bool
spinner spinner.Model
progress progress.Model
width int
quitting bool
progressTotals map[cameradar.Step]int
progressCounts map[cameradar.Step]int
progressTarget float64
progressVisible float64
}
func (m *modelState) Init() tea.Cmd {
return m.spinner.Tick
}
func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch typed := msg.(type) {
case stepMsg:
m.handleStepMsg(typed)
case logMsg:
m.handleLogMsg(typed)
case summaryMsg:
m.handleSummaryMsg(typed)
case progressMsg:
m.handleProgressMsg(typed)
case closeMsg:
m.quitting = true
case spinner.TickMsg:
cmds = m.handleSpinnerMsg(typed)
case tea.WindowSizeMsg:
m.handleWindowSizeMsg(typed)
case progress.FrameMsg:
}
if len(cmds) == 0 {
return m, nil
}
return m, tea.Batch(cmds...)
}
func (m *modelState) handleStepMsg(msg stepMsg) {
m.status[msg.step] = msg.state
if msg.message != "" {
level := logInfo
if msg.state == stateError {
level = logError
}
m.logs = append(m.logs, logMsg{level: level, step: msg.step, message: msg.message})
}
if msg.state == stateDone || msg.state == stateError {
markStepComplete(m, msg.step)
queueProgressUpdate(m)
}
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
}
func (m *modelState) handleLogMsg(msg logMsg) {
m.logs = append(m.logs, msg)
}
func (m *modelState) handleSummaryMsg(msg summaryMsg) {
m.summaryStreams = msg.streams
m.summaryFinal = msg.final
m.summary = buildSummaryTables(msg.streams, m.width, m.status, msg.final)
if msg.final {
m.status[cameradar.StepSummary] = stateDone
markStepComplete(m, cameradar.StepSummary)
queueProgressUpdate(m)
m.quitting = true
}
}
func (m *modelState) handleProgressMsg(msg progressMsg) {
if msg.total > 0 {
m.progressTotals[msg.step] = msg.total
if m.progressCounts[msg.step] > msg.total {
m.progressCounts[msg.step] = msg.total
}
}
if msg.increment > 0 {
m.progressCounts[msg.step] += msg.increment
total := m.progressTotals[msg.step]
if total > 0 && m.progressCounts[msg.step] > total {
m.progressCounts[msg.step] = total
}
}
queueProgressUpdate(m)
}
func (m *modelState) handleSpinnerMsg(msg spinner.TickMsg) []tea.Cmd {
var cmds []tea.Cmd
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
advanceProgress(m)
if m.quitting && progressComplete(*m) {
cmds = append(cmds, tea.Quit)
}
return cmds
}
func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
m.width = msg.Width
m.progress.Width = progressWidth(msg.Width)
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
}
func (m *modelState) View() string {
var builder strings.Builder
builder.WriteString(sectionStyle.Render("Steps"))
builder.WriteString("\n")
builder.WriteString(renderProgress(m))
builder.WriteString("\n")
spinnerView := m.spinner.View()
for _, step := range m.steps {
builder.WriteString(renderStep(step, m.status[step], spinnerView))
builder.WriteString("\n")
}
builder.WriteString("\n")
builder.WriteString(sectionStyle.Render("Logs"))
builder.WriteString("\n")
if len(m.logs) == 0 {
builder.WriteString(dimStyle.Render("No events yet."))
builder.WriteString("\n")
} else {
for _, entry := range m.logs {
builder.WriteString(renderLog(entry))
builder.WriteString("\n")
}
}
builder.WriteString("\n")
builder.WriteString(sectionStyle.Render("Summary"))
builder.WriteString("\n")
for i, summary := range m.summary {
if summary.title != "" {
builder.WriteString(subsectionStyle.Render(summary.title))
builder.WriteString("\n")
}
if summary.emptyMessage != "" {
builder.WriteString(dimStyle.Render(summary.emptyMessage))
builder.WriteString("\n")
} else {
builder.WriteString(summaryTableStyle.Render(summary.table.View()))
builder.WriteString("\n")
}
if i < len(m.summary)-1 {
builder.WriteString("\n")
}
}
return builder.String()
}
+15
View File
@@ -0,0 +1,15 @@
package ui
import "github.com/charmbracelet/lipgloss"
var (
sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
subsectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111"))
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
activeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
summaryTableStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240"))
)
+150
View File
@@ -0,0 +1,150 @@
package ui
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/Ullaakut/cameradar/v6"
)
// FormatSummary builds a human-readable summary of discovered streams.
func FormatSummary(streams []cameradar.Stream, _ error) string {
accessible, others := partitionStreams(streams)
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Accessible streams: %d\n", len(accessible)))
if len(accessible) == 0 {
builder.WriteString("• None\n")
} else {
for _, stream := range accessible {
builder.WriteString(formatStream(stream))
}
}
if len(others) > 0 {
builder.WriteString("\n")
builder.WriteString(fmt.Sprintf("Other discovered streams: %d\n", len(others)))
for _, stream := range others {
builder.WriteString(formatStream(stream))
}
}
return builder.String()
}
func partitionStreams(streams []cameradar.Stream) ([]cameradar.Stream, []cameradar.Stream) {
var accessible []cameradar.Stream
var others []cameradar.Stream
for _, stream := range streams {
if stream.Available {
accessible = append(accessible, stream)
} else {
others = append(others, stream)
}
}
// Sort streams by address and port.
sort.Slice(accessible, func(i, j int) bool {
if accessible[i].Address.String() == accessible[j].Address.String() {
return accessible[i].Port < accessible[j].Port
}
return accessible[i].Address.String() < accessible[j].Address.String()
})
sort.Slice(others, func(i, j int) bool {
if others[i].Address.String() == others[j].Address.String() {
return others[i].Port < others[j].Port
}
return others[i].Address.String() < others[j].Address.String()
})
return accessible, others
}
func formatStream(stream cameradar.Stream) string {
var builder strings.Builder
builder.WriteString("• ")
builder.WriteString(stream.Address.String())
builder.WriteString(":")
builder.WriteString(strconv.FormatUint(uint64(stream.Port), 10))
if stream.Device != "" {
builder.WriteString(" (")
builder.WriteString(stream.Device)
builder.WriteString(")")
}
builder.WriteString("\n")
builder.WriteString(" Authentication: ")
builder.WriteString(authTypeLabel(stream.AuthenticationType))
builder.WriteString("\n")
if len(stream.Routes) > 0 {
builder.WriteString(" Routes: ")
builder.WriteString(strings.Join(stream.Routes, ", "))
builder.WriteString("\n")
} else {
builder.WriteString(" Routes: not found\n")
}
if stream.CredentialsFound {
builder.WriteString(" Credentials: ")
builder.WriteString(stream.Username)
builder.WriteString(":")
builder.WriteString(stream.Password)
builder.WriteString("\n")
} else {
builder.WriteString(" Credentials: not found\n")
}
builder.WriteString(" Availability: ")
if stream.Available {
builder.WriteString("yes\n")
} else {
builder.WriteString("no\n")
}
if stream.RouteFound && stream.CredentialsFound {
builder.WriteString(" RTSP URL: ")
builder.WriteString(formatRTSPURL(stream))
builder.WriteString("\n")
}
builder.WriteString(" Admin panel: ")
builder.WriteString(formatAdminPanelURL(stream))
builder.WriteString("\n")
return builder.String()
}
func formatRTSPURL(stream cameradar.Stream) string {
path := stream.Route()
if path != "" && !strings.HasPrefix(path, "/") {
path = "/" + path
}
credentials := ""
if stream.Username != "" || stream.Password != "" {
credentials = stream.Username + ":" + stream.Password + "@"
}
return fmt.Sprintf("rtsp://%s%s:%d%s", credentials, stream.Address.String(), stream.Port, path)
}
func formatAdminPanelURL(stream cameradar.Stream) string {
return fmt.Sprintf("http://%s/", stream.Address.String())
}
func authTypeLabel(auth cameradar.AuthType) string {
switch auth {
case cameradar.AuthNone:
return "none"
case cameradar.AuthBasic:
return "basic"
case cameradar.AuthDigest:
return "digest"
default:
return fmt.Sprintf("unknown(%d)", auth)
}
}
+107
View File
@@ -0,0 +1,107 @@
package ui_test
import (
"errors"
"net/netip"
"strings"
"testing"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestFormatSummary(t *testing.T) {
tests := []struct {
name string
streams []cameradar.Stream
err error
wantContains []string
wantNotContains []string
orderedPairs [][2]string
}{
{
name: "empty",
streams: nil,
wantContains: []string{
"Accessible streams: 0",
"• None",
},
wantNotContains: []string{
"Other discovered streams",
"Error:",
},
},
{
name: "mixed streams with error",
streams: []cameradar.Stream{
{
Device: "Model B",
Address: netip.MustParseAddr("10.0.0.2"),
Port: 554,
Available: true,
AuthenticationType: cameradar.AuthNone,
},
{
Device: "Model A",
Address: netip.MustParseAddr("10.0.0.1"),
Port: 8554,
Available: true,
Routes: []string{"stream1", "stream2"},
RouteFound: true,
CredentialsFound: true,
Username: "user",
Password: "pass",
AuthenticationType: cameradar.AuthBasic,
},
{
Address: netip.MustParseAddr("10.0.0.3"),
Port: 554,
Available: false,
AuthenticationType: cameradar.AuthDigest,
},
},
err: errors.New("boom"),
wantContains: []string{
"Accessible streams: 2",
"Other discovered streams: 1",
"• 10.0.0.1:8554 (Model A)",
"• 10.0.0.2:554 (Model B)",
"• 10.0.0.3:554",
"Authentication: basic",
"Authentication: none",
"Authentication: digest",
"Routes: stream1, stream2",
"Credentials: user:pass",
"RTSP URL: rtsp://user:pass@10.0.0.1:8554/stream1",
"Admin panel: http://10.0.0.1/",
"Admin panel: http://10.0.0.2/",
},
wantNotContains: []string{
"RTSP URL: rtsp://10.0.0.2",
"Error:",
},
orderedPairs: [][2]string{
{"• 10.0.0.1:8554", "• 10.0.0.2:554"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := ui.FormatSummary(test.streams, test.err)
for _, expected := range test.wantContains {
assert.Contains(t, got, expected)
}
for _, unexpected := range test.wantNotContains {
assert.NotContains(t, got, unexpected)
}
for _, pair := range test.orderedPairs {
first := strings.Index(got, pair[0])
second := strings.Index(got, pair[1])
assert.True(t, first >= 0 && second >= 0 && first < second)
}
})
}
}
+558
View File
@@ -0,0 +1,558 @@
package ui
import (
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
statePending state = iota
stateActive
stateDone
stateError
)
type logLevel int
const (
logInfo logLevel = iota
logDebug
logError
)
type stepMsg struct {
step cameradar.Step
state state
message string
}
type logMsg struct {
level logLevel
step cameradar.Step
message string
}
type progressMsg struct {
step cameradar.Step
total int
increment int
}
type closeMsg struct{}
type summaryMsg struct {
streams []cameradar.Stream
final bool
}
type summaryTable struct {
title string
table table.Model
emptyMessage string
}
// TUIReporter renders a Bubble Tea based UI.
type TUIReporter struct {
program *tea.Program
debug bool
once sync.Once
closed chan struct{}
}
// NewTUIReporter creates a new Bubble Tea reporter.
func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
spin := spinner.New()
spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
prog := progress.New(
progress.WithDefaultGradient(),
progress.WithFillCharacters('━', '·'),
progress.WithoutPercentage(),
progress.WithWidth(28),
)
initial := &modelState{
steps: cameradar.Steps(),
status: make(map[cameradar.Step]state),
debug: debug,
spinner: spin,
progress: prog,
progressTotals: make(map[cameradar.Step]int),
progressCounts: make(map[cameradar.Step]int),
}
initial.summary = buildSummaryTables(nil, initial.width, initial.status, false)
p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen())
reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})}
go func() {
model, err := p.Run()
if err != nil {
_, _ = fmt.Fprintf(out, "Error running TUI: %v\n", err)
close(reporter.closed)
return
}
if rendered, ok := model.(*modelState); ok {
_, _ = fmt.Fprintln(out, rendered.View())
}
close(reporter.closed)
}()
return reporter, nil
}
// Start implements Reporter.
func (r *TUIReporter) Start(step cameradar.Step, message string) {
r.send(stepMsg{step: step, state: stateActive, message: message})
}
// Done implements Reporter.
func (r *TUIReporter) Done(step cameradar.Step, message string) {
r.send(stepMsg{step: step, state: stateDone, message: message})
}
// Progress implements Reporter.
func (r *TUIReporter) Progress(step cameradar.Step, message string) {
if kind, value, ok := cameradar.ParseProgressMessage(message); ok {
msg := progressMsg{step: step}
if kind == "total" {
msg.total = value
}
if kind == "tick" {
msg.increment = value
}
r.send(msg)
return
}
r.send(logMsg{level: logInfo, step: step, message: message})
}
// Debug implements Reporter.
func (r *TUIReporter) Debug(step cameradar.Step, message string) {
if !r.debug {
return
}
r.send(logMsg{level: logDebug, step: step, message: message})
}
// Error implements Reporter.
func (r *TUIReporter) Error(step cameradar.Step, err error) {
if err == nil {
return
}
r.send(stepMsg{step: step, state: stateError, message: err.Error()})
}
// Summary implements Reporter.
func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) {
r.send(summaryMsg{streams: copyStreams(streams), final: true})
}
// UpdateSummary updates the summary section with partial results.
func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) {
r.send(summaryMsg{streams: copyStreams(streams), final: false})
}
// Close implements Reporter.
func (r *TUIReporter) Close() {
r.once.Do(func() {
r.send(closeMsg{})
})
// Timeout after 2 seconds to avoid hanging forever.
select {
case <-r.closed:
case <-time.After(2 * time.Second):
}
}
func (r *TUIReporter) send(msg tea.Msg) {
if r.program == nil {
return
}
r.program.Send(msg)
}
func renderStep(step cameradar.Step, state state, spinnerView string) string {
label := cameradar.StepLabel(step)
symbol := "·"
style := dimStyle
switch state {
case stateActive:
symbol = spinnerView
style = activeStyle
case stateDone:
symbol = "✓"
style = successStyle
case stateError:
symbol = "✗"
style = errorStyle
}
return style.Render(fmt.Sprintf("%s %s", symbol, label))
}
func renderLog(entry logMsg) string {
prefix := "INFO"
style := infoStyle
if entry.level == logDebug {
prefix = "DEBUG"
style = debugStyle
}
if entry.level == logError {
prefix = "ERROR"
style = errorStyle
}
return style.Render(fmt.Sprintf("[%s] %s: %s", prefix, cameradar.StepLabel(entry.step), entry.message))
}
func renderProgress(m *modelState) string {
completed, total := progressCounts(m.steps, m.status)
percent := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts)
countLabel := dimStyle.Render(fmt.Sprintf("%3.0f%% %d/%d complete", percent*100, completed, total))
return fmt.Sprintf("%s %s", m.progress.ViewAs(m.progressVisible), countLabel)
}
func progressCounts(steps []cameradar.Step, status map[cameradar.Step]state) (int, int) {
if len(steps) == 0 {
return 0, 0
}
completed := 0
for _, step := range steps {
switch status[step] {
case stateDone, stateError:
completed++
}
}
return completed, len(steps)
}
func progressPercent(steps []cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 {
weights := stepWeights()
percent := 0.0
for _, step := range steps {
weight := weights[step]
if weight <= 0 {
continue
}
percent += weight * stepProgress(step, status, totals, counts)
}
if percent > 1 {
return 1
}
return percent
}
func stepWeights() map[cameradar.Step]float64 {
return map[cameradar.Step]float64{
cameradar.StepScan: 0.15,
cameradar.StepAttackRoutes: 0.25,
cameradar.StepDetectAuth: 0.05,
cameradar.StepAttackCredentials: 0.35,
cameradar.StepValidateStreams: 0.2,
cameradar.StepSummary: 0.0,
}
}
func stepProgress(step cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 {
if total := totals[step]; total > 0 {
count := counts[step]
if count >= total {
return 1
}
return float64(count) / float64(total)
}
switch status[step] {
case stateDone, stateError:
return 1
default:
return 0
}
}
func queueProgressUpdate(m *modelState) {
desired := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts)
if desired <= m.progressTarget {
return
}
m.progressTarget = desired
}
func advanceProgress(m *modelState) {
if m.progressVisible >= m.progressTarget {
return
}
remaining := m.progressTarget - m.progressVisible
step := remaining * 0.2
if step < 0.02 {
step = 0.02
}
if m.quitting && step < 0.08 {
step = 0.08
}
if remaining < step {
m.progressVisible = m.progressTarget
return
}
m.progressVisible += step
}
func progressComplete(m modelState) bool {
return m.progressVisible >= m.progressTarget
}
func markStepComplete(m *modelState, step cameradar.Step) {
if m.progressTotals[step] == 0 {
m.progressTotals[step] = 1
}
if m.progressCounts[step] < m.progressTotals[step] {
m.progressCounts[step] = m.progressTotals[step]
}
}
func progressWidth(width int) int {
if width <= 0 {
return 28
}
if width < 60 {
return 20
}
if width < 100 {
return 28
}
return 36
}
func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, final bool) []summaryTable {
visibility := summaryVisibility(status)
accessible, others := partitionStreams(streams)
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
if len(rows) == 0 {
message := "Waiting for results..."
if final {
message = "No streams discovered."
}
return []summaryTable{{title: "Streams", emptyMessage: message}}
}
title := fmt.Sprintf("Streams (%d accessible / %d total)", len(accessible), len(streams))
columns := summaryColumns(width, rows)
model := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(false),
table.WithHeight(len(rows)+1),
)
model.SetStyles(summaryTableStyles())
return []summaryTable{{title: title, table: model}}
}
const emptyEntry = "—"
func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row {
rows := make([]table.Row, 0, len(streams))
for _, stream := range streams {
target := fmt.Sprintf("%s:%d", stream.Address.String(), stream.Port)
device := emptyEntry
if visibility.showDevice && stream.Device != "" {
device = stream.Device
}
routes := emptyEntry
if visibility.showRoutes && len(stream.Routes) > 0 {
routes = strings.Join(stream.Routes, ", ")
}
credentials := emptyEntry
if visibility.showCredentials && stream.CredentialsFound {
credentials = fmt.Sprintf("%s:%s", stream.Username, stream.Password)
}
available := emptyEntry
if visibility.showAvailable {
available = "no"
if stream.Available {
available = "yes"
}
}
rtspURL := emptyEntry
if visibility.showCredentials && stream.RouteFound && stream.CredentialsFound {
rtspURL = formatRTSPURL(stream)
}
authType := emptyEntry
if visibility.showAuth {
authType = authTypeLabel(stream.AuthenticationType)
}
rows = append(rows, table.Row{
target,
device,
authType,
routes,
credentials,
available,
rtspURL,
adminPanelLabel(stream, visibility),
})
}
return rows
}
func summaryColumns(width int, rows []table.Row) []table.Column {
columns := []table.Column{
{Title: "Target", Width: 18},
{Title: "Device", Width: 14},
{Title: "Auth", Width: 8},
{Title: "Routes", Width: 18},
{Title: "Credentials", Width: 16},
{Title: "Available", Width: 9},
{Title: "RTSP URL", Width: 30},
{Title: "Admin", Width: 24},
}
columns[6].Width = maxColumnWidth(columns[6].Title, rows, 6, columns[6].Width)
columns[7].Width = maxColumnWidth(columns[7].Title, rows, 7, columns[7].Width)
if width <= 0 {
return columns
}
columns = clampColumns(columns, max(width-2, 60))
return columns
}
func clampColumns(columns []table.Column, maxWidth int) []table.Column {
padding := 2 * len(columns)
contentWidth := 0
for _, col := range columns {
contentWidth += col.Width
}
contentWidth += padding
if contentWidth <= maxWidth {
return columns
}
over := contentWidth - maxWidth
shrinkOrder := []int{7, 3, 4, 1}
minWidths := map[int]int{
7: 10,
3: 10,
4: 10,
1: 10,
}
for over > 0 {
changed := false
for _, idx := range shrinkOrder {
minWidth := minWidths[idx]
if columns[idx].Width > minWidth {
columns[idx].Width--
over--
changed = true
if over == 0 {
break
}
}
}
if !changed {
break
}
}
return columns
}
func summaryTableStyles() table.Styles {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = lipgloss.NewStyle()
styles.Cell = styles.Cell.Padding(0, 1)
return styles
}
func maxColumnWidth(title string, rows []table.Row, idx, minWidth int) int {
width := max(len(title), minWidth)
for _, row := range rows {
if idx >= len(row) {
continue
}
if len(row[idx]) > width {
width = len(row[idx])
}
}
return width
}
func adminPanelLabel(stream cameradar.Stream, visibility summaryVisibilityState) string {
if !visibility.showCredentials || !stream.CredentialsFound {
return emptyEntry
}
return formatAdminPanelURL(stream)
}
type summaryVisibilityState struct {
showDevice bool
showRoutes bool
showAuth bool
showCredentials bool
showAvailable bool
}
func summaryVisibility(status map[cameradar.Step]state) summaryVisibilityState {
return summaryVisibilityState{
showDevice: stepComplete(status, cameradar.StepScan),
showRoutes: stepComplete(status, cameradar.StepAttackRoutes),
showAuth: stepComplete(status, cameradar.StepDetectAuth),
showCredentials: stepComplete(status, cameradar.StepAttackCredentials),
showAvailable: stepComplete(status, cameradar.StepValidateStreams),
}
}
func stepComplete(status map[cameradar.Step]state, step cameradar.Step) bool {
if status == nil {
return false
}
switch status[step] {
case stateDone, stateError:
return true
default:
return false
}
}
func copyStreams(streams []cameradar.Stream) []cameradar.Stream {
if len(streams) == 0 {
return nil
}
cloned := make([]cameradar.Stream, len(streams))
copy(cloned, streams)
return cloned
}