Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d589b610b2 | |||
| 1f06a075e4 | |||
| fb7b3fd9af | |||
| a867a606b2 | |||
| 77a2eac262 | |||
| dc4d6c9489 | |||
| cb832179a2 | |||
| 10bf1b59e8 | |||
| 510a9af2fd | |||
| 777bd2a488 | |||
| 99c2e55ec4 | |||
| bf720fcea2 |
@@ -14,30 +14,43 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# We need to set a cache marker to ensure that the cache is individual for each job.
|
|
||||||
- name: Add Cache Marker
|
|
||||||
run: echo "go-build" > env.txt
|
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
id: install-go
|
id: install-go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'go.mod'
|
go-version-file: 'go.mod'
|
||||||
cache-dependency-path: |
|
cache: false
|
||||||
go.sum
|
|
||||||
env.txt
|
- name: Cache Go mod
|
||||||
|
id: gomod
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-mod-
|
||||||
|
|
||||||
|
- name: Cache Go build
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/go-build
|
||||||
|
key: ${{ runner.os }}-go-build-${{ github.ref_name }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-build-
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
if: steps.install-go.outputs.cache-hit != 'true'
|
if: steps.gomod.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
env:
|
env:
|
||||||
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
DOCKER_REPOSITORY: ullaakut/cameradar
|
with:
|
||||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
distribution: goreleaser
|
||||||
|
version: ${{ inputs.GORELEASER_VERSION }}
|
||||||
|
args: release --clean --snapshot --skip=docker
|
||||||
|
|||||||
@@ -42,7 +42,15 @@ jobs:
|
|||||||
- name: Run Linter
|
- name: Run Linter
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
version: v2.10.2
|
version: v2.7.2
|
||||||
|
|
||||||
|
- name: Setup gotestsum
|
||||||
|
uses: gertd/action-gotestsum@v3.0.0
|
||||||
|
with:
|
||||||
|
gotestsum_version: v1.13.0
|
||||||
|
|
||||||
|
- name: Download nmap
|
||||||
|
run: sudo apt-get install -y nmap
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
|
<img src="https://img.shields.io/docker/pulls/ullaakut/cameradar.svg?style=flat" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/Ullaakut/cameradar/actions">
|
<a href="https://github.com/Ullaakut/cameradar/actions">
|
||||||
<img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build" />
|
<img src="https://img.shields.io/github/actions/workflow/status/Ullaakut/cameradar/build.yaml" />
|
||||||
</a>
|
</a>
|
||||||
<a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'>
|
<a href='https://coveralls.io/github/Ullaakut/cameradar?branch=master'>
|
||||||
<img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' />
|
<img src='https://coveralls.io/repos/github/Ullaakut/cameradar/badge.svg?branch=master' alt='Coverage Status' />
|
||||||
@@ -24,10 +24,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## An RTSP stream access tool with a Go library
|
## RTSP stream access tool
|
||||||
|
|
||||||
Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes.
|
Cameradar scans RTSP endpoints on authorized targets, and uses dictionary attacks to bruteforce their credentials and routes.
|
||||||
Use the CLI for end-to-end scanning or import the library in Go code.
|
|
||||||
|
|
||||||
### What Cameradar does
|
### What Cameradar does
|
||||||
|
|
||||||
@@ -56,6 +55,10 @@ Use the CLI for end-to-end scanning or import the library in Go code.
|
|||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center"><img src="images/example.gif"/></p>
|
||||||
|
|
||||||
## Quick start with Docker
|
## Quick start with Docker
|
||||||
|
|
||||||
Install [Docker](https://docs.docker.com/engine/installation/) and run:
|
Install [Docker](https://docs.docker.com/engine/installation/) and run:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 746 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
+108
-5
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
// Route that should never be a constructor default.
|
// Route that should never be a constructor default.
|
||||||
const dummyRoute = "/0x8b6c42"
|
const dummyRoute = "/0x8b6c42"
|
||||||
|
|
||||||
|
const maxIncrementalRouteAttempts = 32
|
||||||
|
|
||||||
// Dictionary provides dictionaries for routes, usernames and passwords.
|
// Dictionary provides dictionaries for routes, usernames and passwords.
|
||||||
type Dictionary interface {
|
type Dictionary interface {
|
||||||
Routes() []string
|
Routes() []string
|
||||||
@@ -232,7 +235,12 @@ func (a Attacker) attackCredentialsForStream(ctx context.Context, target camerad
|
|||||||
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
|
msg := fmt.Sprintf("Credentials found for %s:%d", target.Address.String(), target.Port)
|
||||||
a.reporter.Progress(cameradar.StepAttackCredentials, msg)
|
a.reporter.Progress(cameradar.StepAttackCredentials, msg)
|
||||||
|
|
||||||
return target, nil
|
updated, err := a.tryIncrementalRoutes(ctx, target, target.Route(), true)
|
||||||
|
if err != nil {
|
||||||
|
return target, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated, nil
|
||||||
}
|
}
|
||||||
time.Sleep(a.attackInterval)
|
time.Sleep(a.attackInterval)
|
||||||
}
|
}
|
||||||
@@ -257,7 +265,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
|
|||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
target.RouteFound = true
|
target.RouteFound = true
|
||||||
target.Routes = append(target.Routes, "/")
|
target.Routes = appendRouteIfMissing(target.Routes, "/")
|
||||||
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
|
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
|
||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
@@ -279,8 +287,14 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
|
|||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
target.RouteFound = true
|
target.RouteFound = true
|
||||||
target.Routes = append(target.Routes, route)
|
target.Routes = appendRouteIfMissing(target.Routes, route)
|
||||||
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route))
|
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Route found for %s:%d -> %s", target.Address.String(), target.Port, route))
|
||||||
|
|
||||||
|
updated, err := a.tryIncrementalRoutes(ctx, target, route, emitProgress)
|
||||||
|
if err != nil {
|
||||||
|
return target, err
|
||||||
|
}
|
||||||
|
target = updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +362,22 @@ func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream)
|
|||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When no credentials are used, we expect 200, 401 or 403 status codes, which would mean either that the stream is
|
||||||
|
// unprotected and this is the correct route, or that it is protected and this is also a correct route.
|
||||||
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
|
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
|
||||||
|
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
|
||||||
|
return code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// When credentials are given, we only expect a 200 status code, which confirms the combination of route and credentials.
|
||||||
|
func (a Attacker) routeAttackWithCredentials(stream cameradar.Stream, route string) (bool, error) {
|
||||||
|
return a.routeAttackWithStatus(stream, route, func(code base.StatusCode) bool {
|
||||||
|
return code == base.StatusOK
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attacker) routeAttackWithStatus(stream cameradar.Stream, route string, allowed func(base.StatusCode) bool) (bool, error) {
|
||||||
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
|
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("building rtsp url: %w", err)
|
return false, fmt.Errorf("building rtsp url: %w", err)
|
||||||
@@ -360,8 +389,82 @@ func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
|
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, code))
|
||||||
access := code == base.StatusOK || code == base.StatusUnauthorized || code == base.StatusForbidden
|
return allowed(code), nil
|
||||||
return access, nil
|
}
|
||||||
|
|
||||||
|
func (a Attacker) tryIncrementalRoutes(ctx context.Context,
|
||||||
|
target cameradar.Stream, route string,
|
||||||
|
emitProgress bool,
|
||||||
|
) (cameradar.Stream, error) {
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
if !ok {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNumber := match.number + 1
|
||||||
|
attempts := 0
|
||||||
|
for {
|
||||||
|
if attempts >= maxIncrementalRouteAttempts {
|
||||||
|
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf(
|
||||||
|
"incremental route attempts capped at %d for %s:%d",
|
||||||
|
maxIncrementalRouteAttempts,
|
||||||
|
target.Address.String(),
|
||||||
|
target.Port,
|
||||||
|
))
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return target, ctx.Err()
|
||||||
|
case <-time.After(a.attackInterval):
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++
|
||||||
|
|
||||||
|
nextRoute := buildIncrementedRoute(match, nextNumber)
|
||||||
|
if slices.Contains(target.Routes, nextRoute) {
|
||||||
|
if !match.isChannel {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
nextNumber++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if emitProgress {
|
||||||
|
a.reporter.Progress(cameradar.StepAttackRoutes, cameradar.ProgressTickMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := a.routeAttackWithCredentials(target, nextRoute)
|
||||||
|
if err != nil {
|
||||||
|
a.reporter.Debug(cameradar.StepAttackRoutes, fmt.Sprintf("incremental route attempt failed for %s:%d (%s): %v",
|
||||||
|
target.Address.String(),
|
||||||
|
target.Port,
|
||||||
|
nextRoute,
|
||||||
|
err,
|
||||||
|
))
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target.RouteFound = true
|
||||||
|
target.Routes = appendRouteIfMissing(target.Routes, nextRoute)
|
||||||
|
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Incremental route found for %s:%d -> %s", target.Address.String(), target.Port, nextRoute))
|
||||||
|
|
||||||
|
if !match.isChannel {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
nextNumber++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendRouteIfMissing(routes []string, route string) []string {
|
||||||
|
if slices.Contains(routes, route) {
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
return append(routes, route)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
|
func (a Attacker) credAttack(stream cameradar.Stream, username, password string) (bool, error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package attack_test
|
package attack_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -50,11 +51,11 @@ func TestNew(t *testing.T) {
|
|||||||
|
|
||||||
func TestAttacker_Attack_BasicAuth(t *testing.T) {
|
func TestAttacker_Attack_BasicAuth(t *testing.T) {
|
||||||
addr, port := startRTSPServer(t, rtspServerConfig{
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
})
|
})
|
||||||
|
|
||||||
dict := testDictionary{
|
dict := testDictionary{
|
||||||
@@ -101,9 +102,9 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "no authentication",
|
name: "no authentication",
|
||||||
config: rtspServerConfig{
|
config: rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
},
|
},
|
||||||
dict: testDictionary{
|
dict: testDictionary{
|
||||||
routes: []string{"stream"},
|
routes: []string{"stream"},
|
||||||
@@ -117,11 +118,11 @@ func TestAttacker_Attack_AuthVariants(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "digest authentication",
|
name: "digest authentication",
|
||||||
config: rtspServerConfig{
|
config: rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
authMethod: headers.AuthMethodDigest,
|
authMethod: headers.AuthMethodDigest,
|
||||||
},
|
},
|
||||||
dict: testDictionary{
|
dict: testDictionary{
|
||||||
routes: []string{"stream"},
|
routes: []string{"stream"},
|
||||||
@@ -193,9 +194,9 @@ func TestAttacker_Attack_ValidationErrors(t *testing.T) {
|
|||||||
|
|
||||||
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
|
func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
|
||||||
addr, port := startRTSPServer(t, rtspServerConfig{
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
})
|
})
|
||||||
|
|
||||||
dict := testDictionary{
|
dict := testDictionary{
|
||||||
@@ -221,11 +222,11 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
|
|||||||
|
|
||||||
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
|
func TestAttacker_Attack_ReturnsErrorWhenCredentialsMissing(t *testing.T) {
|
||||||
addr, port := startRTSPServer(t, rtspServerConfig{
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
})
|
})
|
||||||
|
|
||||||
dict := testDictionary{
|
dict := testDictionary{
|
||||||
@@ -254,12 +255,12 @@ func TestAttacker_Attack_CredentialAttemptFails(t *testing.T) {
|
|||||||
reporter := &recordingReporter{}
|
reporter := &recordingReporter{}
|
||||||
|
|
||||||
addr, port := startRTSPServer(t, rtspServerConfig{
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
username: "user",
|
username: "user",
|
||||||
password: "pass",
|
password: "pass",
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
failOnAuth: true,
|
failOnAuth: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
dict := testDictionary{
|
dict := testDictionary{
|
||||||
@@ -310,10 +311,10 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
|
|||||||
|
|
||||||
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
|
func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
|
||||||
addr, port := startRTSPServer(t, rtspServerConfig{
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
allowedRoute: "stream",
|
allowRoutes: []string{"stream"},
|
||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
authMethod: headers.AuthMethodBasic,
|
authMethod: headers.AuthMethodBasic,
|
||||||
setupStatus: base.StatusUnsupportedTransport,
|
setupStatus: base.StatusUnsupportedTransport,
|
||||||
})
|
})
|
||||||
|
|
||||||
dict := testDictionary{
|
dict := testDictionary{
|
||||||
@@ -335,6 +336,71 @@ func TestAttacker_Attack_ValidationFailsWhenSetupErrors(t *testing.T) {
|
|||||||
assert.True(t, got[0].RouteFound)
|
assert.True(t, got[0].RouteFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAttacker_Attack_IncrementalRoutesStopsOnFirstMissAndAvoidsDuplicates(t *testing.T) {
|
||||||
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
|
allowRoutes: []string{"channel1", "channel2"},
|
||||||
|
requireAuth: false,
|
||||||
|
authMethod: headers.AuthMethodBasic,
|
||||||
|
})
|
||||||
|
|
||||||
|
dict := testDictionary{
|
||||||
|
routes: []string{"channel1", "channel2"},
|
||||||
|
usernames: []string{"user"},
|
||||||
|
passwords: []string{"pass"},
|
||||||
|
}
|
||||||
|
|
||||||
|
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
streams := []cameradar.Stream{{
|
||||||
|
Address: addr,
|
||||||
|
Port: port,
|
||||||
|
}}
|
||||||
|
|
||||||
|
got, err := attacker.Attack(t.Context(), streams)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{"channel1", "channel2"}, got[0].Routes)
|
||||||
|
assert.Equal(t, 1, countRoute(got[0].Routes, "channel2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttacker_Attack_IncrementalRoutesStopsAtCap(t *testing.T) {
|
||||||
|
allowedRoutes := make([]string, 0, 50)
|
||||||
|
for i := 1; i <= 50; i++ {
|
||||||
|
allowedRoutes = append(allowedRoutes, fmt.Sprintf("channel%d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, port := startRTSPServer(t, rtspServerConfig{
|
||||||
|
allowRoutes: allowedRoutes,
|
||||||
|
requireAuth: false,
|
||||||
|
authMethod: headers.AuthMethodBasic,
|
||||||
|
})
|
||||||
|
|
||||||
|
dict := testDictionary{
|
||||||
|
routes: []string{"channel1"},
|
||||||
|
usernames: []string{"user"},
|
||||||
|
passwords: []string{"pass"},
|
||||||
|
}
|
||||||
|
|
||||||
|
attacker, err := attack.New(dict, 0, time.Second, ui.NopReporter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
streams := []cameradar.Stream{{
|
||||||
|
Address: addr,
|
||||||
|
Port: port,
|
||||||
|
}}
|
||||||
|
|
||||||
|
got, err := attacker.Attack(t.Context(), streams)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
|
||||||
|
const expectedRoutes = 33 // channel1 + 32 incremental attempts
|
||||||
|
assert.Len(t, got[0].Routes, expectedRoutes)
|
||||||
|
assert.Contains(t, got[0].Routes, "channel33")
|
||||||
|
assert.NotContains(t, got[0].Routes, "channel34")
|
||||||
|
}
|
||||||
|
|
||||||
type testDictionary struct {
|
type testDictionary struct {
|
||||||
routes []string
|
routes []string
|
||||||
usernames []string
|
usernames []string
|
||||||
@@ -376,9 +442,10 @@ func (r *recordingReporter) Summary([]cameradar.Stream, error) {}
|
|||||||
|
|
||||||
func (r *recordingReporter) Close() {}
|
func (r *recordingReporter) Close() {}
|
||||||
|
|
||||||
func (r *recordingReporter) HasDebugContaining(value string) bool {
|
func (r *recordingReporter) ContainsDebug(value string) bool {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
for _, message := range r.debugMessages {
|
for _, message := range r.debugMessages {
|
||||||
if strings.Contains(message, value) {
|
if strings.Contains(message, value) {
|
||||||
return true
|
return true
|
||||||
@@ -386,3 +453,13 @@ func (r *recordingReporter) HasDebugContaining(value string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countRoute(routes []string, route string) int {
|
||||||
|
count := 0
|
||||||
|
for _, value := range routes {
|
||||||
|
if value == route {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package attack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type incrementalRoute struct {
|
||||||
|
prefix string
|
||||||
|
suffix string
|
||||||
|
number int
|
||||||
|
width int
|
||||||
|
isChannel bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectIncrementalRoute identifies routes that can be incremented.
|
||||||
|
// It prioritizes channel-like patterns to enable sequential scanning when possible.
|
||||||
|
//
|
||||||
|
// Examples of supported patterns:
|
||||||
|
// - /StreamingSetting?ChannelID=01&other=params -> /StreamingSetting?ChannelID=02&other=params
|
||||||
|
// - /path/to/channel2/stream -> /path/to/channel3/stream
|
||||||
|
// - /foo/bar12/baz -> /foo/bar13/baz
|
||||||
|
//
|
||||||
|
// It returns false if no incrementable pattern is found.
|
||||||
|
func detectIncrementalRoute(route string) (incrementalRoute, bool) {
|
||||||
|
if strings.TrimSpace(route) == "" {
|
||||||
|
return incrementalRoute{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if match, ok := findChannelIncrement(route); ok {
|
||||||
|
match.isChannel = true
|
||||||
|
return match, true
|
||||||
|
}
|
||||||
|
|
||||||
|
match, ok := findLastNumber(route)
|
||||||
|
if !ok {
|
||||||
|
return incrementalRoute{}, false
|
||||||
|
}
|
||||||
|
return match, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// findChannelIncrement locates a numeric segment tied to channel-like keywords.
|
||||||
|
// It returns the last match for the first keyword that yields a hit.
|
||||||
|
//
|
||||||
|
// Supported keywords include: channel_id, channelid, channelno, channel, channelname.
|
||||||
|
func findChannelIncrement(route string) (incrementalRoute, bool) {
|
||||||
|
patterns := []string{"channel_id", "channelid", "channelno", "channel", "channelname"}
|
||||||
|
lower := strings.ToLower(route)
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
var lastMatch incrementalRoute
|
||||||
|
found := false
|
||||||
|
index := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
pos := strings.Index(lower[index:], pattern)
|
||||||
|
if pos == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos += index
|
||||||
|
|
||||||
|
start, end, ok := firstNumberAfterKey(route, pos+len(pattern))
|
||||||
|
if ok {
|
||||||
|
num, width, parseOK := parseNumber(route, start, end)
|
||||||
|
if parseOK {
|
||||||
|
lastMatch = incrementalRoute{
|
||||||
|
prefix: route[:start],
|
||||||
|
suffix: route[end:],
|
||||||
|
number: num,
|
||||||
|
width: width,
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = pos + len(pattern)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return lastMatch, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incrementalRoute{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastNumber finds the last numeric token in the route so it can be incremented.
|
||||||
|
// This supports routes where the channel number is not the final component.
|
||||||
|
func findLastNumber(route string) (incrementalRoute, bool) {
|
||||||
|
for i := len(route) - 1; i >= 0; {
|
||||||
|
if !isDigit(route[i]) {
|
||||||
|
i--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := i + 1
|
||||||
|
start := i
|
||||||
|
for start >= 0 && isDigit(route[start]) {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
start++
|
||||||
|
|
||||||
|
num, width, ok := parseNumber(route, start, end)
|
||||||
|
if !ok {
|
||||||
|
i = start - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return incrementalRoute{
|
||||||
|
prefix: route[:start],
|
||||||
|
suffix: route[end:],
|
||||||
|
number: num,
|
||||||
|
width: width,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return incrementalRoute{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNumber reads the numeric token and returns its integer value and width.
|
||||||
|
func parseNumber(route string, start, end int) (int, int, bool) {
|
||||||
|
if start < 0 || end > len(route) || start >= end {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
value := route[start:end]
|
||||||
|
num, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return num, len(value), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNumberAfterKey returns the first numeric token after a keyword, limited to
|
||||||
|
// the current token and requiring an '=' delimiter (query param or path segment).
|
||||||
|
func firstNumberAfterKey(route string, after int) (start, end int, ok bool) {
|
||||||
|
if after < 0 {
|
||||||
|
after = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenEnd := len(route)
|
||||||
|
for i := after; i < len(route); i++ {
|
||||||
|
if isTokenDelimiter(route[i]) {
|
||||||
|
tokenEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relEq := strings.IndexByte(route[after:tokenEnd], '=')
|
||||||
|
searchStart := after
|
||||||
|
if relEq != -1 {
|
||||||
|
searchStart = after + relEq + 1
|
||||||
|
}
|
||||||
|
for i := searchStart; i < tokenEnd; i++ {
|
||||||
|
if !isDigit(route[i]) {
|
||||||
|
if relEq == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := i + 1
|
||||||
|
for end < tokenEnd && isDigit(route[end]) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
return i, end, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIncrementedRoute formats the route with the new numeric value.
|
||||||
|
// It preserves zero padding when the original token had a fixed width.
|
||||||
|
func buildIncrementedRoute(match incrementalRoute, number int) string {
|
||||||
|
if match.width <= 0 {
|
||||||
|
return match.prefix + strconv.Itoa(number) + match.suffix
|
||||||
|
}
|
||||||
|
return match.prefix + fmt.Sprintf("%0*d", match.width, number) + match.suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(b byte) bool {
|
||||||
|
return b >= '0' && b <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTokenDelimiter(b byte) bool {
|
||||||
|
switch b {
|
||||||
|
case '&', '/', '?', '#':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package attack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelID(t *testing.T) {
|
||||||
|
route := "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=01&ChannelName=Channel1"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, match.isChannel)
|
||||||
|
assert.Equal(t, 1, match.number)
|
||||||
|
assert.Equal(t, 2, match.width)
|
||||||
|
assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=", match.prefix)
|
||||||
|
assert.Equal(t, "&ChannelName=Channel1", match.suffix)
|
||||||
|
|
||||||
|
next := buildIncrementedRoute(match, match.number+1)
|
||||||
|
assert.Equal(t, "/StreamingSetting?version=1.0&action=getRTSPStream&ChannelID=02&ChannelName=Channel1", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelSuffix(t *testing.T) {
|
||||||
|
route := "/path/to/channel2/stream"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, match.isChannel)
|
||||||
|
assert.Equal(t, 2, match.number)
|
||||||
|
assert.Equal(t, "/path/to/channel", match.prefix)
|
||||||
|
assert.Equal(t, "/stream", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_LastNumber(t *testing.T) {
|
||||||
|
route := "/foo/bar12/baz"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.False(t, match.isChannel)
|
||||||
|
assert.Equal(t, 12, match.number)
|
||||||
|
assert.Equal(t, 2, match.width)
|
||||||
|
assert.Equal(t, "/foo/bar", match.prefix)
|
||||||
|
assert.Equal(t, "/baz", match.suffix)
|
||||||
|
|
||||||
|
next := buildIncrementedRoute(match, 13)
|
||||||
|
assert.Equal(t, "/foo/bar13/baz", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_NoNumber(t *testing.T) {
|
||||||
|
match, ok := detectIncrementalRoute("/no/number/here")
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Equal(t, incrementalRoute{}, match)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_OverflowAtEndFallsBack(t *testing.T) {
|
||||||
|
// The trailing token overflows strconv.Atoi, so we fall back to earlier numbers.
|
||||||
|
route := "/foo1/bar999999999999999999999999999999"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.False(t, match.isChannel)
|
||||||
|
assert.Equal(t, 1, match.number)
|
||||||
|
assert.Equal(t, "/foo", match.prefix)
|
||||||
|
assert.Equal(t, "/bar999999999999999999999999999999", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordShouldNotBindAcrossParams(t *testing.T) {
|
||||||
|
// The channel keyword should not bind to digits in other query parameters.
|
||||||
|
route := "/path?channelname=foo&version=12"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.False(t, match.isChannel)
|
||||||
|
assert.Equal(t, 12, match.number)
|
||||||
|
assert.Equal(t, "/path?channelname=foo&version=", match.prefix)
|
||||||
|
assert.Equal(t, "", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordStopsAtDelimiter(t *testing.T) {
|
||||||
|
// Digits after a delimiter should not be associated with a channel keyword.
|
||||||
|
route := "/path/channel?channel=foo/7"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.False(t, match.isChannel)
|
||||||
|
assert.Equal(t, 7, match.number)
|
||||||
|
assert.Equal(t, "/path/channel?channel=foo/", match.prefix)
|
||||||
|
assert.Equal(t, "", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordWithoutDigitsFallsBack(t *testing.T) {
|
||||||
|
// channel keyword without digits should fall back to last numeric token.
|
||||||
|
route := "/path/channel?channel=foo&stream=9"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.False(t, match.isChannel)
|
||||||
|
assert.Equal(t, 9, match.number)
|
||||||
|
assert.Equal(t, "/path/channel?channel=foo&stream=", match.prefix)
|
||||||
|
assert.Equal(t, "", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordKeepsQueryDigits(t *testing.T) {
|
||||||
|
// channel keyword with query param digits should be detected as channel.
|
||||||
|
route := "/path?channel=03&other=1"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, match.isChannel)
|
||||||
|
assert.Equal(t, 3, match.number)
|
||||||
|
assert.Equal(t, 2, match.width)
|
||||||
|
assert.Equal(t, "/path?channel=", match.prefix)
|
||||||
|
assert.Equal(t, "&other=1", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordMultipleMatchesUsesKeywordPriority(t *testing.T) {
|
||||||
|
// Keyword priority should win even if another keyword appears earlier in the route.
|
||||||
|
route := "/path?channel=1&channelid=9"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, match.isChannel)
|
||||||
|
assert.Equal(t, 9, match.number)
|
||||||
|
assert.Equal(t, "/path?channel=1&channelid=", match.prefix)
|
||||||
|
assert.Equal(t, "", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIncrementalRoute_ChannelKeywordSelectsLastMatchWithinKeyword(t *testing.T) {
|
||||||
|
// The last match for a given keyword should be selected.
|
||||||
|
route := "/path?channel=1&foo=bar&channel=4"
|
||||||
|
|
||||||
|
match, ok := detectIncrementalRoute(route)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, match.isChannel)
|
||||||
|
assert.Equal(t, 4, match.number)
|
||||||
|
assert.Equal(t, "/path?channel=1&foo=bar&channel=", match.prefix)
|
||||||
|
assert.Equal(t, "", match.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIncrementedRoute_ZeroPadding(t *testing.T) {
|
||||||
|
match := incrementalRoute{
|
||||||
|
prefix: "/channel",
|
||||||
|
suffix: "/stream",
|
||||||
|
number: 1,
|
||||||
|
width: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "/channel002/stream", buildIncrementedRoute(match, 2))
|
||||||
|
}
|
||||||
@@ -18,27 +18,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type rtspServerConfig struct {
|
type rtspServerConfig struct {
|
||||||
allowAll bool
|
allowAll bool
|
||||||
allowedRoute string
|
allowRoutes []string
|
||||||
requireAuth bool
|
requireAuth bool
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
authMethod headers.AuthMethod
|
authMethod headers.AuthMethod
|
||||||
authHeader base.HeaderValue
|
authHeader base.HeaderValue
|
||||||
failOnAuth bool
|
failOnAuth bool
|
||||||
setupStatus base.StatusCode
|
setupStatus base.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
type testServerHandler struct {
|
type testServerHandler struct {
|
||||||
stream *gortsplib.ServerStream
|
stream *gortsplib.ServerStream
|
||||||
allowAll bool
|
allowAll bool
|
||||||
allowedRoute string
|
allowRoutes []string
|
||||||
requireAuth bool
|
requireAuth bool
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
authHeader base.HeaderValue
|
authHeader base.HeaderValue
|
||||||
failOnAuth bool
|
failOnAuth bool
|
||||||
setupStatus base.StatusCode
|
setupStatus base.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
|
func (h *testServerHandler) OnDescribe(ctx *gortsplib.ServerHandlerOnDescribeCtx) (*base.Response, *gortsplib.ServerStream, error) {
|
||||||
@@ -86,20 +86,29 @@ func (h *testServerHandler) OnSetup(ctx *gortsplib.ServerHandlerOnSetupCtx) (*ba
|
|||||||
|
|
||||||
func (h *testServerHandler) routeAllowed(path string) bool {
|
func (h *testServerHandler) routeAllowed(path string) bool {
|
||||||
path = strings.TrimLeft(path, "/")
|
path = strings.TrimLeft(path, "/")
|
||||||
return h.allowAll || path == h.allowedRoute
|
if h.allowAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, route := range h.allowRoutes {
|
||||||
|
if path == route {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
|
func startRTSPServer(t *testing.T, cfg rtspServerConfig) (netip.Addr, uint16) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
handler := &testServerHandler{
|
handler := &testServerHandler{
|
||||||
allowAll: cfg.allowAll,
|
allowAll: cfg.allowAll,
|
||||||
allowedRoute: cfg.allowedRoute,
|
allowRoutes: cfg.allowRoutes,
|
||||||
requireAuth: cfg.requireAuth,
|
requireAuth: cfg.requireAuth,
|
||||||
username: cfg.username,
|
username: cfg.username,
|
||||||
password: cfg.password,
|
password: cfg.password,
|
||||||
failOnAuth: cfg.failOnAuth,
|
failOnAuth: cfg.failOnAuth,
|
||||||
setupStatus: cfg.setupStatus,
|
setupStatus: cfg.setupStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.authHeader) > 0 {
|
if len(cfg.authHeader) > 0 {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
|
func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
|
||||||
targets := []string{
|
targets := []string{
|
||||||
"192.0.2.0/30",
|
"192.0.2.0/30",
|
||||||
"localhost",
|
|
||||||
"192.0.2.15",
|
"192.0.2.15",
|
||||||
"192.0.2.10-11",
|
"192.0.2.10-11",
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,6 @@ func TestNew_ExpandsTargetsAndPorts(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addrs := []netip.Addr{
|
addrs := []netip.Addr{
|
||||||
netip.MustParseAddr("127.0.0.1"),
|
|
||||||
netip.MustParseAddr("192.0.2.0"),
|
netip.MustParseAddr("192.0.2.0"),
|
||||||
netip.MustParseAddr("192.0.2.1"),
|
netip.MustParseAddr("192.0.2.1"),
|
||||||
netip.MustParseAddr("192.0.2.2"),
|
netip.MustParseAddr("192.0.2.2"),
|
||||||
@@ -92,7 +90,12 @@ func TestNew_ResolvesHostnames(t *testing.T) {
|
|||||||
streams, err := scanner.Scan(t.Context())
|
streams, err := scanner.Scan(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, streams)
|
require.NotEmpty(t, streams)
|
||||||
assert.Equal(t, netip.MustParseAddr("127.0.0.1"), streams[0].Address)
|
addr := streams[0].Address
|
||||||
|
assert.True(t,
|
||||||
|
addr == netip.MustParseAddr("127.0.0.1") || addr == netip.MustParseAddr("::1"),
|
||||||
|
"expected localhost to resolve to 127.0.0.1 or ::1, got %s",
|
||||||
|
addr.String(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
|
func TestNew_ReturnsErrorOnHostnameLookupFailure(t *testing.T) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
#EXTM3U
|
|
||||||
#EXTINF:-1,127.0.0.1:8554 (GStreamer rtspd)
|
|
||||||
rtsp://admin:12345@127.0.0.1:8554/live.sdp
|
|
||||||
#EXTINF:-1,127.0.0.1:8555 (GStreamer rtspd)
|
|
||||||
rtsp://admin:12345@127.0.0.1:8555/live.sdp
|
|
||||||
Reference in New Issue
Block a user