Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b888586830 | |||
| 8eddd96637 | |||
| af4a9badde | |||
| 5bb789333d | |||
| 9457911e4a | |||
| aa8c6fbd90 | |||
| c37d584aa2 | |||
| fd0d948c16 | |||
| 7bd7460b5b | |||
| 69f4fb418a | |||
| 18ffb7af61 | |||
| c11e3217ea | |||
| d16443109a | |||
| f93f9c9780 | |||
| 55d11e2887 | |||
| f192139cc3 | |||
| af41fc6cb8 |
@@ -0,0 +1,2 @@
|
|||||||
|
*.go @Ullaakut @whiteboxsolutions @nblair2
|
||||||
|
*.md @Ullaakut @whiteboxsolutions @nblair2
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
Before filing, search open and closed issues and check the FAQ in the README.
|
|
||||||
If this is a security issue, do not post details in a public issue.
|
|
||||||
Do not include IPs or any other information that can identify a vulnerable network in your issue.
|
|
||||||
|
|
||||||
## Issue type
|
|
||||||
|
|
||||||
- [ ] Bug report
|
|
||||||
- [ ] Feature request
|
|
||||||
- [ ] Documentation issue
|
|
||||||
- [ ] Question
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
### Install method
|
|
||||||
|
|
||||||
- [ ] Docker image `ullaakut/cameradar`
|
|
||||||
- [ ] Custom Docker build
|
|
||||||
- [ ] Pre-compiled binary
|
|
||||||
- [ ] Custom binary build
|
|
||||||
- [ ] Not sure
|
|
||||||
|
|
||||||
### Version
|
|
||||||
|
|
||||||
- [ ] Release tag: <tag>
|
|
||||||
- [ ] Latest commit on `master`
|
|
||||||
- [ ] Fork: <fork URL>
|
|
||||||
- [ ] Commit: <hash>
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
- OS: <Windows | macOS | Linux | Other>
|
|
||||||
- OS version: <version>
|
|
||||||
- Architecture: <arch>
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
### Expected behavior
|
|
||||||
|
|
||||||
<expected behavior>
|
|
||||||
|
|
||||||
### Actual behavior
|
|
||||||
|
|
||||||
<actual behavior>
|
|
||||||
|
|
||||||
### Steps to reproduce
|
|
||||||
|
|
||||||
1. <step>
|
|
||||||
2. <step>
|
|
||||||
3. <step>
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
If this is a CLI or Docker issue, run with debug logs and paste output.
|
|
||||||
|
|
||||||
```text
|
|
||||||
<logs>
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help Cameradar improve
|
||||||
|
labels:
|
||||||
|
- needs-triage
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please make sure your problem is not already addressed in another issue.
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Please give a clear and concise description of the bug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Cameradar version
|
||||||
|
description: Output of `cameradar version`
|
||||||
|
render: bash
|
||||||
|
placeholder: |
|
||||||
|
Version: v6.0.2-SNAPSHOT-c11e321
|
||||||
|
Commit: c11e3217ea0b1ea9e45d0da4c072e07775bde68c
|
||||||
|
Build date: 2026-02-03T10:02:30Z
|
||||||
|
Nmap: 7.94SVN
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: How do you run cameradar?
|
||||||
|
options:
|
||||||
|
- "`ullaakut/cameradar` docker image"
|
||||||
|
- Precompiled binary from GitHub releases
|
||||||
|
- Custom docker image
|
||||||
|
- Custom binary build
|
||||||
|
default: 0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
description: Operating system where you run cameradar.
|
||||||
|
render: bash
|
||||||
|
placeholder: |
|
||||||
|
- OS: <Windows | macOS | Linux | Other>
|
||||||
|
- OS version: <version>
|
||||||
|
- Architecture: <arch>
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: cmd
|
||||||
|
attributes:
|
||||||
|
label: Command
|
||||||
|
description: The command that you ran and all of its arguments. Make sure to redact any sensitive information. Make sure to run your command in debug mode.
|
||||||
|
placeholder: |
|
||||||
|
E.g. `docker run --net=host -it ullaakut/cameradar -t localhost --debug`
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: output
|
||||||
|
attributes:
|
||||||
|
label: Output logs
|
||||||
|
description: Output of the command you ran, including any error messages. Make sure to redact any sensitive information.
|
||||||
|
placeholder: |
|
||||||
|
2026-02-03T09:33:24Z [INFO] Startup: Running cameradar version 6.0.2-SNAPSHOT-75bf524, commit 75bf524
|
||||||
|
2026-02-03T09:33:24Z [INFO] Startup: targets: localhost
|
||||||
|
2026-02-03T09:33:24Z [INFO] Startup: ports: 554, 5554, 8554, http
|
||||||
|
...
|
||||||
|
Accessible streams: 1
|
||||||
|
• 127.0.0.1:8554 (GStreamer rtspd)
|
||||||
|
Authentication: digest
|
||||||
|
Routes: live.sdp
|
||||||
|
Credentials: admin:12345
|
||||||
|
Availability: yes
|
||||||
|
RTSP URL: rtsp://admin:12345@127.0.0.1:8554/live.sdp
|
||||||
|
Admin panel: http://127.0.0.1/
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What is the expected behavior?
|
||||||
|
placeholder: |
|
||||||
|
E.g. "Cameradar should have been able to find the camera's RTSP stream using the provided credentials."
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Info
|
||||||
|
description: Additional info you want to provide such as system info, target info, network conditions etc.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Code of Conduct
|
||||||
|
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/Ullaakut/cameradar/blob/master/CODE_OF_CONDUCT.md).
|
||||||
|
options:
|
||||||
|
- label: I agree to follow this project's Code of Conduct
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Cameradar Community discussion board
|
||||||
|
url: https://github.com/Ullaakut/cameradar/discussions
|
||||||
|
about: Please ask and answer questions here.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Propose a feature or enhancement to help Cameradar improve
|
||||||
|
labels:
|
||||||
|
- needs-triage
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please make sure your request is not already proposed in another issue.
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: Please give a clear and concise description of the feature request.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Info
|
||||||
|
description: Additional info you want to provide.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: Code of Conduct
|
||||||
|
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/Ullaakut/cameradar/blob/master/CODE_OF_CONDUCT.md).
|
||||||
|
options:
|
||||||
|
- label: I agree to follow this project's Code of Conduct
|
||||||
|
required: true
|
||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
<!-- A brief description of the change being made with this pull request. -->
|
<!-- A brief description of the change being made with this pull request. -->
|
||||||
|
|
||||||
<!--
|
Fixes #
|
||||||
Fixes [#XXX](https://github.com/Ulaakut/cameradar/issues/XXX)
|
|
||||||
-->
|
|
||||||
|
|
||||||
## How did I test it?
|
## How did I test it?
|
||||||
|
|
||||||
<!-- A brief description the steps taken to test this pull request. -->
|
<!-- A brief description of the steps taken to test this pull request. -->
|
||||||
|
|||||||
@@ -208,34 +208,6 @@ These instructions are based on:
|
|||||||
- Handle errors at the appropriate level
|
- Handle errors at the appropriate level
|
||||||
- Consider using structured errors for better debugging
|
- Consider using structured errors for better debugging
|
||||||
|
|
||||||
## API Design
|
|
||||||
|
|
||||||
### HTTP Handlers
|
|
||||||
|
|
||||||
- Use `http.HandlerFunc` for simple handlers
|
|
||||||
- Implement `http.Handler` for handlers that need state
|
|
||||||
- Use middleware for cross-cutting concerns
|
|
||||||
- Set appropriate status codes and headers
|
|
||||||
- Handle errors gracefully and return appropriate error responses
|
|
||||||
- Use `github.com/go-chi/chi/v5` for its `mux` with pattern-based routing and method matching
|
|
||||||
|
|
||||||
### JSON APIs
|
|
||||||
|
|
||||||
- Use struct tags to control JSON marshaling
|
|
||||||
- Validate input data
|
|
||||||
- Use pointers for optional fields
|
|
||||||
- Consider using `json.RawMessage` for delayed parsing
|
|
||||||
- Handle JSON errors appropriately
|
|
||||||
|
|
||||||
### HTTP Clients
|
|
||||||
|
|
||||||
- Keep the client struct focused on configuration and dependencies only (e.g., base URL, `*http.Client`, auth, default headers). It must not store per-request state
|
|
||||||
- Do not store or cache `*http.Request` inside the client struct, and do not persist request-specific state across calls; instead, construct a fresh request per method invocation
|
|
||||||
- Methods should accept `context.Context` and input parameters, assemble the `*http.Request` locally (or via a short-lived builder/helper created per call), then call `c.httpClient.Do(req)`
|
|
||||||
- If request-building logic is reused, factor it into unexported helper functions or a per-call builder type; never keep `http.Request` (URL params, body, headers) as fields on the long-lived client
|
|
||||||
- Ensure the underlying `*http.Client` is configured (timeouts, transport) and is safe for concurrent use; avoid mutating `Transport` after first use
|
|
||||||
- Always set headers on the request instance you’re sending, and close response bodies (`defer resp.Body.Close()`), handling errors appropriately
|
|
||||||
|
|
||||||
## Performance Optimization
|
## Performance Optimization
|
||||||
|
|
||||||
### Memory Management
|
### Memory Management
|
||||||
@@ -350,18 +322,15 @@ These instructions are based on:
|
|||||||
|
|
||||||
### Essential Tools
|
### Essential Tools
|
||||||
|
|
||||||
- `go fmt`: Format code
|
- `make fmt`: Format code
|
||||||
- `go vet`: Find suspicious constructs
|
- `make lint`: Additional linting
|
||||||
- `golangci-lint`: Additional linting
|
- `make test`: Run tests
|
||||||
- `go test`: Run tests
|
|
||||||
- `go mod`: Manage dependencies
|
- `go mod`: Manage dependencies
|
||||||
- `go generate`: Code generation
|
|
||||||
|
|
||||||
### Development Practices
|
### Development Practices
|
||||||
|
|
||||||
- Run tests before committing
|
- Run tests before committing (`make test`)
|
||||||
- Run linter before committing
|
- Run linter before committing (`make lint`)
|
||||||
- Run `make sqlc`, `make openapi-gen` and `make readme-gen` before committing if you touched related files
|
|
||||||
- Keep commits focused and atomic
|
- Keep commits focused and atomic
|
||||||
- Write meaningful commit messages
|
- Write meaningful commit messages
|
||||||
- Review diffs before committing
|
- Review diffs before committing
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
if: steps.gomod.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@v7
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ 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
|
||||||
|
|
||||||
- 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'
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
env:
|
env:
|
||||||
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
||||||
DOCKER_REPOSITORY: ullaakut/cameradar
|
DOCKER_REPOSITORY: ullaakut/cameradar
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Go Test looks at `mtime` for caching. `git clone` messes with this. Set it consistently to last commit time.
|
# Go Test looks at `mtime` for caching. `git clone` messes with this. Set it consistently to last commit time.
|
||||||
- name: Restore file modification time
|
- name: Restore file modification time
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- 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-dependency-path: |
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
if: steps.install-go.outputs.cache-hit != 'true'
|
if: steps.install-go.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
- name: Run Linter
|
- name: Run Linter
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.7.2
|
version: v2.7.2
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -46,21 +46,21 @@ archives:
|
|||||||
|
|
||||||
dockers:
|
dockers:
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-amd64"
|
- "ullaakut/{{ .ProjectName }}:latest-amd64"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
use: buildx
|
use: buildx
|
||||||
goos: linux
|
goos: linux
|
||||||
goarch: amd64
|
goarch: amd64
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-386"
|
- "ullaakut/{{ .ProjectName }}:latest-386"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
use: buildx
|
use: buildx
|
||||||
goos: linux
|
goos: linux
|
||||||
goarch: 386
|
goarch: 386
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-armv6"
|
- "ullaakut/{{ .ProjectName }}:latest-armv6"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
use: buildx
|
use: buildx
|
||||||
@@ -68,7 +68,7 @@ dockers:
|
|||||||
goarch: arm
|
goarch: arm
|
||||||
goarm: 6
|
goarm: 6
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-armv7"
|
- "ullaakut/{{ .ProjectName }}:latest-armv7"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
use: buildx
|
use: buildx
|
||||||
@@ -76,7 +76,7 @@ dockers:
|
|||||||
goarch: arm
|
goarch: arm
|
||||||
goarm: 7
|
goarm: 7
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-arm64"
|
- "ullaakut/{{ .ProjectName }}:latest-arm64"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
use: buildx
|
use: buildx
|
||||||
@@ -84,13 +84,13 @@ dockers:
|
|||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
|
||||||
docker_manifests:
|
docker_manifests:
|
||||||
- name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}"
|
- name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}"
|
||||||
image_templates:
|
image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
|
||||||
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64"
|
- "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
|
||||||
- name_template: "ullaakut/{{ .ProjectName }}:latest"
|
- name_template: "ullaakut/{{ .ProjectName }}:latest"
|
||||||
image_templates:
|
image_templates:
|
||||||
- "ullaakut/{{ .ProjectName }}:latest-amd64"
|
- "ullaakut/{{ .ProjectName }}:latest-amd64"
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
`contact+cameradar@glaulabs.com`.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
+4
-1
@@ -2,7 +2,10 @@ FROM alpine
|
|||||||
|
|
||||||
RUN apk --update add --no-cache nmap \
|
RUN apk --update add --no-cache nmap \
|
||||||
nmap-nselibs \
|
nmap-nselibs \
|
||||||
nmap-scripts
|
nmap-scripts \
|
||||||
|
masscan \
|
||||||
|
libpcap \
|
||||||
|
libpcap-dev
|
||||||
|
|
||||||
WORKDIR /app/cameradar
|
WORKDIR /app/cameradar
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#license">
|
<a href="#license">
|
||||||
<img src="https://img.shields.io/badge/license-Apache-blue.svg?style=flat" />
|
<img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://hub.docker.com/r/ullaakut/cameradar/">
|
<a href="https://hub.docker.com/r/ullaakut/cameradar/">
|
||||||
<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" />
|
||||||
@@ -189,12 +189,12 @@ docker run --rm -t --net=host \
|
|||||||
### Skip discovery with `--skip-scan`
|
### Skip discovery with `--skip-scan`
|
||||||
|
|
||||||
If you already know the RTSP endpoints, you can skip discovery and treat each
|
If you already know the RTSP endpoints, you can skip discovery and treat each
|
||||||
target and port as a stream candidate. This mode does not run nmap and can be
|
target and port as a stream candidate. This mode does not run discovery and can be
|
||||||
useful on restricted networks or when you want to attack a known inventory.
|
useful on restricted networks or when you want to attack a known inventory.
|
||||||
|
|
||||||
Skipping discovery means:
|
Skipping discovery means:
|
||||||
|
|
||||||
- Cameradar does not run nmap and does not detect device models.
|
- Cameradar does not run discovery and does not detect device models.
|
||||||
- Targets resolve to IP addresses. Hostnames resolve via DNS.
|
- Targets resolve to IP addresses. Hostnames resolve via DNS.
|
||||||
- CIDR blocks and IPv4 ranges expand to every address in the range.
|
- CIDR blocks and IPv4 ranges expand to every address in the range.
|
||||||
- Large ranges create many targets, so use them carefully.
|
- Large ranges create many targets, so use them carefully.
|
||||||
@@ -212,6 +212,30 @@ docker run --rm -t --net=host \
|
|||||||
In this example, Cameradar attempts dictionary attacks against
|
In this example, Cameradar attempts dictionary attacks against
|
||||||
ports 554 and 8554 of `192.168.1.10`.
|
ports 554 and 8554 of `192.168.1.10`.
|
||||||
|
|
||||||
|
### Choose the discovery scanner with `--scanner`
|
||||||
|
|
||||||
|
Cameradar supports two discovery backends:
|
||||||
|
|
||||||
|
- `nmap` (default)
|
||||||
|
- `masscan`
|
||||||
|
|
||||||
|
Use `nmap` when you want more reliable RTSP discovery: it performs service
|
||||||
|
identification and can better distinguish RTSP from other open ports.
|
||||||
|
|
||||||
|
Use `masscan` when scanning very large networks: it is generally faster and
|
||||||
|
more efficient at scale, but it does not provide service discovery.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -t --net=host \
|
||||||
|
ullaakut/cameradar \
|
||||||
|
--scanner masscan \
|
||||||
|
--ports "554,8554" \
|
||||||
|
--targets 192.168.1.0/24
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `--scan-speed` only applies to the `nmap` scanner.
|
||||||
|
|
||||||
## Security and responsible use
|
## Security and responsible use
|
||||||
|
|
||||||
Cameradar is a penetration testing tool.
|
Cameradar is a penetration testing tool.
|
||||||
@@ -287,12 +311,27 @@ It replaces the default credentials dictionary used for the dictionary attack.
|
|||||||
|
|
||||||
If unset, Cameradar uses the built-in credentials dictionary.
|
If unset, Cameradar uses the built-in credentials dictionary.
|
||||||
|
|
||||||
|
### `SCANNER` / `--scanner`
|
||||||
|
|
||||||
|
This optional variable sets the discovery backend.
|
||||||
|
|
||||||
|
* `nmap` includes service discovery and is generally more reliable when you want
|
||||||
|
to specifically identify RTSP services.
|
||||||
|
* `masscan` is generally more efficient for large-scale discovery, but it does
|
||||||
|
not identify services and therefore can be less specific for RTSP.
|
||||||
|
|
||||||
|
Supported values: `nmap`, `masscan`
|
||||||
|
|
||||||
|
Default value: `nmap`
|
||||||
|
|
||||||
### `SCAN_SPEED` / `--scan-speed` / `-s`
|
### `SCAN_SPEED` / `--scan-speed` / `-s`
|
||||||
|
|
||||||
This optional variable sets nmap discovery presets for speed or accuracy.
|
This optional variable sets nmap discovery presets for speed or accuracy.
|
||||||
Lower it on slow networks and raise it on fast networks.
|
Lower it on slow networks and raise it on fast networks.
|
||||||
See [nmap timing templates](https://nmap.org/book/man-performance.html).
|
See [nmap timing templates](https://nmap.org/book/man-performance.html).
|
||||||
|
|
||||||
|
This option is ignored when `--scanner masscan` is used.
|
||||||
|
|
||||||
Default value: `4`
|
Default value: `4`
|
||||||
|
|
||||||
### `SKIP_SCAN` / `--skip-scan`
|
### `SKIP_SCAN` / `--skip-scan`
|
||||||
@@ -324,7 +363,7 @@ Default value: `2000ms`
|
|||||||
|
|
||||||
This optional variable enables more verbose output.
|
This optional variable enables more verbose output.
|
||||||
|
|
||||||
It outputs nmap results, cURL requests, and more.
|
It outputs discovery results (`nmap` or `masscan`), cURL requests, and more.
|
||||||
|
|
||||||
Default: `false`
|
Default: `false`
|
||||||
|
|
||||||
@@ -414,6 +453,10 @@ Cameradar supports both basic and digest authentication.
|
|||||||
|
|
||||||
`docker run --rm -t --net=host -v /tmp:/tmp ullaakut/cameradar --targets 192.168.0.0/24 --custom-credentials "/tmp/dictionaries/credentials.json" --custom-routes "/tmp/dictionaries/routes" --ports 554,5554,8554`
|
`docker run --rm -t --net=host -v /tmp:/tmp ullaakut/cameradar --targets 192.168.0.0/24 --custom-credentials "/tmp/dictionaries/credentials.json" --custom-routes "/tmp/dictionaries/routes" --ports 554,5554,8554`
|
||||||
|
|
||||||
|
> Running cameradar with masscan discovery
|
||||||
|
|
||||||
|
`docker run --rm -t --net=host ullaakut/cameradar --scanner masscan --targets 192.168.0.0/24 --ports 554,8554`
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright 2026 Ullaakut
|
Copyright 2026 Ullaakut
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
"github.com/Ullaakut/cameradar/v6/internal/attack"
|
"github.com/Ullaakut/cameradar/v6/internal/attack"
|
||||||
@@ -17,7 +19,11 @@ import (
|
|||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:cyclop // Splitting this function does not make it clearer.
|
||||||
func runCameradar(ctx context.Context, cmd *cli.Command) error {
|
func runCameradar(ctx context.Context, cmd *cli.Command) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
targetInputs := cmd.StringSlice(flagTargets)
|
targetInputs := cmd.StringSlice(flagTargets)
|
||||||
if len(targetInputs) == 0 {
|
if len(targetInputs) == 0 {
|
||||||
return errors.New("at least one target must be specified")
|
return errors.New("at least one target must be specified")
|
||||||
@@ -60,10 +66,28 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interactive := isInteractiveTerminal()
|
interactive := isInteractiveTerminal()
|
||||||
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive)
|
buildInfo := ui.BuildInfo{Version: version, Commit: commit, Date: date}
|
||||||
|
reporter, err := ui.NewReporter(mode, cmd.Bool(flagDebug), os.Stdout, interactive, buildInfo, cancel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if plainReporter, ok := reporter.(*ui.PlainReporter); ok {
|
||||||
|
resolvedMode := resolveMode(mode, interactive)
|
||||||
|
plainReporter.PrintStartup(buildInfo, buildStartupOptions(
|
||||||
|
targets,
|
||||||
|
ports,
|
||||||
|
routesPath,
|
||||||
|
credsPath,
|
||||||
|
outputPath,
|
||||||
|
cmd.String(flagScanner),
|
||||||
|
cmd.Int16(flagScanSpeed),
|
||||||
|
cmd.Duration(flagAttackInterval),
|
||||||
|
cmd.Duration(flagTimeout),
|
||||||
|
cmd.Bool(flagSkipScan),
|
||||||
|
cmd.Bool(flagDebug),
|
||||||
|
resolvedMode,
|
||||||
|
))
|
||||||
|
}
|
||||||
if outputPath != "" {
|
if outputPath != "" {
|
||||||
reporter = output.NewM3UReporter(reporter, outputPath)
|
reporter = output.NewM3UReporter(reporter, outputPath)
|
||||||
}
|
}
|
||||||
@@ -74,6 +98,7 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
|
|||||||
Targets: targets,
|
Targets: targets,
|
||||||
Ports: ports,
|
Ports: ports,
|
||||||
ScanSpeed: cmd.Int16(flagScanSpeed),
|
ScanSpeed: cmd.Int16(flagScanSpeed),
|
||||||
|
Scanner: cmd.String(flagScanner),
|
||||||
}
|
}
|
||||||
var scanner cameradar.StreamScanner
|
var scanner cameradar.StreamScanner
|
||||||
scanner, err = scan.New(config, reporter)
|
scanner, err = scan.New(config, reporter)
|
||||||
@@ -102,6 +127,55 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error {
|
|||||||
return c.Run(ctx)
|
return c.Run(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveMode(mode cameradar.Mode, interactive bool) cameradar.Mode {
|
||||||
|
if mode != cameradar.ModeAuto {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if interactive {
|
||||||
|
return cameradar.ModeTUI
|
||||||
|
}
|
||||||
|
return cameradar.ModePlain
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStartupOptions(
|
||||||
|
targets []string,
|
||||||
|
ports []string,
|
||||||
|
routesPath string,
|
||||||
|
credsPath string,
|
||||||
|
outputPath string,
|
||||||
|
scanner string,
|
||||||
|
scanSpeed int16,
|
||||||
|
attackInterval time.Duration,
|
||||||
|
timeout time.Duration,
|
||||||
|
skipScan bool,
|
||||||
|
debug bool,
|
||||||
|
mode cameradar.Mode,
|
||||||
|
) []string {
|
||||||
|
options := []string{
|
||||||
|
"targets: " + strings.Join(targets, ", "),
|
||||||
|
"ports: " + strings.Join(ports, ", "),
|
||||||
|
"custom-routes: " + fallbackValue(routesPath, "builtin"),
|
||||||
|
"custom-credentials: " + fallbackValue(credsPath, "builtin"),
|
||||||
|
"scanner: " + fallbackValue(scanner, "nmap"),
|
||||||
|
"scan-speed: " + strconv.FormatInt(int64(scanSpeed), 10),
|
||||||
|
"skip-scan: " + strconv.FormatBool(skipScan),
|
||||||
|
"attack-interval: " + attackInterval.String(),
|
||||||
|
"timeout: " + timeout.String(),
|
||||||
|
"debug: " + strconv.FormatBool(debug),
|
||||||
|
"ui: " + string(mode),
|
||||||
|
"output: " + fallbackValue(outputPath, "disabled"),
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackValue(value, fallback string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func isInteractiveTerminal() bool {
|
func isInteractiveTerminal() bool {
|
||||||
if !term.IsTerminal(int(os.Stdout.Fd())) {
|
if !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+34
-5
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -19,6 +20,7 @@ const (
|
|||||||
flagPorts = "ports"
|
flagPorts = "ports"
|
||||||
flagCustomRoutes = "custom-routes"
|
flagCustomRoutes = "custom-routes"
|
||||||
flagCustomCredentials = "custom-credentials"
|
flagCustomCredentials = "custom-credentials"
|
||||||
|
flagScanner = "scanner"
|
||||||
flagScanSpeed = "scan-speed"
|
flagScanSpeed = "scan-speed"
|
||||||
flagAttackInterval = "attack-interval"
|
flagAttackInterval = "attack-interval"
|
||||||
flagTimeout = "timeout"
|
flagTimeout = "timeout"
|
||||||
@@ -28,7 +30,11 @@ const (
|
|||||||
flagOutput = "output"
|
flagOutput = "output"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "dev"
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "none"
|
||||||
|
date = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
var flags = cmd.Flags{
|
var flags = cmd.Flags{
|
||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
@@ -57,6 +63,12 @@ var flags = cmd.Flags{
|
|||||||
Aliases: []string{"c"},
|
Aliases: []string{"c"},
|
||||||
Sources: cli.EnvVars(strcase.ToSNAKE(flagCustomCredentials)),
|
Sources: cli.EnvVars(strcase.ToSNAKE(flagCustomCredentials)),
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: flagScanner,
|
||||||
|
Usage: "Discovery scanner backend: nmap or masscan",
|
||||||
|
Sources: cli.EnvVars(strcase.ToSNAKE(flagScanner)),
|
||||||
|
Value: "nmap",
|
||||||
|
},
|
||||||
&cli.Int16Flag{
|
&cli.Int16Flag{
|
||||||
Name: flagScanSpeed,
|
Name: flagScanSpeed,
|
||||||
Usage: "The nmap speed preset to use for scanning (lower is stealthier)",
|
Usage: "The nmap speed preset to use for scanning (lower is stealthier)",
|
||||||
@@ -116,11 +128,25 @@ func realMain() (code int) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
scanCommand := &cli.Command{
|
||||||
|
Name: "scan",
|
||||||
|
Usage: "Scan targets for RTSP streams",
|
||||||
|
Flags: flags,
|
||||||
|
Action: runCameradar,
|
||||||
|
}
|
||||||
|
|
||||||
app := &cli.Command{
|
app := &cli.Command{
|
||||||
Name: "Cameradar",
|
Name: "Cameradar",
|
||||||
Version: version,
|
Version: version,
|
||||||
Flags: flags,
|
DefaultCommand: scanCommand.Name,
|
||||||
Action: runCameradar,
|
Commands: []*cli.Command{
|
||||||
|
scanCommand,
|
||||||
|
{
|
||||||
|
Name: "version",
|
||||||
|
Usage: "Print version information",
|
||||||
|
Action: printVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -128,6 +154,9 @@ func realMain() (code int) {
|
|||||||
|
|
||||||
err := app.Run(ctx, os.Args)
|
err := app.Run(ctx, os.Args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
|
_, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error())
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6/internal/ui"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func printVersion(ctx context.Context, _ *cli.Command) error {
|
||||||
|
buildInfo := ui.BuildInfo{Version: version, Commit: commit, Date: date}
|
||||||
|
nmapVersion := getNmapVersion(ctx)
|
||||||
|
_, err := fmt.Fprintf(
|
||||||
|
os.Stdout,
|
||||||
|
"Version:\t%s\nCommit:\t\t%s\nBuild date:\t%s\nNmap:\t\t%s\n",
|
||||||
|
buildInfo.DisplayVersion(),
|
||||||
|
buildInfo.ShortCommit(),
|
||||||
|
buildInfo.Date,
|
||||||
|
nmapVersion,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownVersion = "unknown"
|
||||||
|
|
||||||
|
func getNmapVersion(ctx context.Context) string {
|
||||||
|
output, err := exec.CommandContext(ctx, "nmap", "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return unknownVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.SplitN(string(output), "\n", 2)
|
||||||
|
firstLine := strings.TrimSpace(lines[0])
|
||||||
|
const prefix = "Nmap version "
|
||||||
|
if !strings.HasPrefix(firstLine, prefix) {
|
||||||
|
return unknownVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPart := strings.TrimSpace(strings.TrimPrefix(firstLine, prefix))
|
||||||
|
fields := strings.Fields(versionPart)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return unknownVersion
|
||||||
|
}
|
||||||
|
return fields[0]
|
||||||
|
}
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
module github.com/Ullaakut/cameradar/v6
|
module github.com/Ullaakut/cameradar/v6
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Ullaakut/nmap/v4 v4.0.0-20260127164606-833e3208bd52
|
github.com/Ullaakut/masscan v1.0.0
|
||||||
github.com/bluenviron/gortsplib/v5 v5.2.2
|
github.com/Ullaakut/nmap/v4 v4.0.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/bluenviron/gortsplib/v5 v5.3.2
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/ettle/strcase v0.2.0
|
github.com/ettle/strcase v0.2.0
|
||||||
github.com/hamba/cmd/v3 v3.0.0
|
github.com/hamba/cmd/v3 v3.1.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/urfave/cli/v3 v3.3.8
|
github.com/urfave/cli/v3 v3.6.2
|
||||||
golang.org/x/term v0.39.0
|
golang.org/x/term v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/VictoriaMetrics/metrics v1.38.0 // indirect
|
github.com/VictoriaMetrics/metrics v1.40.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bluenviron/mediacommon/v2 v2.6.0 // indirect
|
github.com/bluenviron/mediacommon/v2 v2.8.0 // indirect
|
||||||
github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect
|
github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
@@ -35,16 +39,16 @@ require (
|
|||||||
github.com/go-stack/stack v1.8.1 // indirect
|
github.com/go-stack/stack v1.8.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/grafana/pyroscope-go v1.2.2 // indirect
|
github.com/grafana/pyroscope-go v1.2.7 // indirect
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
|
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||||
github.com/hamba/logger/v2 v2.8.0 // indirect
|
github.com/hamba/logger/v2 v2.9.0 // indirect
|
||||||
github.com/hamba/statter/v2 v2.7.0 // indirect
|
github.com/hamba/statter/v2 v2.8.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
@@ -53,35 +57,36 @@ require (
|
|||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.16 // indirect
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
github.com/pion/rtp v1.9.0 // indirect
|
github.com/pion/rtp v1.10.1 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
github.com/pion/sdp/v3 v3.0.18 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.9 // indirect
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
github.com/pion/transport/v3 v3.1.1 // indirect
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.65.0 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/fastrand v1.1.0 // indirect
|
github.com/valyala/fastrand v1.1.0 // indirect
|
||||||
github.com/valyala/histogram v1.2.0 // indirect
|
github.com/valyala/histogram v1.2.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/zipkin v1.37.0 // indirect
|
go.opentelemetry.io/otel/exporters/zipkin v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
|
||||||
google.golang.org/grpc v1.73.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/grpc v1.75.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,46 +6,54 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
|
|||||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||||
github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8=
|
github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8=
|
||||||
github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w=
|
github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w=
|
||||||
github.com/Ullaakut/nmap/v4 v4.0.0-20260127164606-833e3208bd52 h1:7o/BZmbn5jJvwBoQqHxLe+UHBz1DD8yx5oWdjOJC76Q=
|
github.com/Ullaakut/masscan v1.0.0 h1:+YtpxNcIEaB2lMWNy+oDZF+5pP86S7vSzCKMjW6UDDA=
|
||||||
github.com/Ullaakut/nmap/v4 v4.0.0-20260127164606-833e3208bd52/go.mod h1:B+MtOtHdb+jR9bc11BNwZX1QVHOtsDjfKkXMCZtRzbw=
|
github.com/Ullaakut/masscan v1.0.0/go.mod h1:2LQUQ88hmdXZ+JqQTx6RaszuZDRIAwjEoUL+sVXCAe8=
|
||||||
github.com/VictoriaMetrics/metrics v1.38.0 h1:1d0dRgVH8Nnu8dKMfisKefPC3q7gqf3/odyO0quAvyA=
|
github.com/Ullaakut/nmap/v4 v4.0.0 h1:QwpxX5F+S14ZEvBQKc37xnvpPXcw4vK0rsZkGV4h98s=
|
||||||
github.com/VictoriaMetrics/metrics v1.38.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8=
|
github.com/Ullaakut/nmap/v4 v4.0.0/go.mod h1:B+MtOtHdb+jR9bc11BNwZX1QVHOtsDjfKkXMCZtRzbw=
|
||||||
|
github.com/VictoriaMetrics/metrics v1.40.1 h1:FrF5uJRpIVj9fayWcn8xgiI+FYsKGMslzPuOXjdeyR4=
|
||||||
|
github.com/VictoriaMetrics/metrics v1.40.1/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bluenviron/gortsplib/v5 v5.2.2 h1:5q2viB8PGxWOSXNhVvj8buyr1wighLbHqRZ0U7MLM3o=
|
github.com/bluenviron/gortsplib/v5 v5.3.2 h1:eGoOsJzV015A+9xuBPcDYNhqYjogH25zXhMoU1lNeXI=
|
||||||
github.com/bluenviron/gortsplib/v5 v5.2.2/go.mod h1:xkVBOAnR4fzaerPN650CBb7N+zUUsj7PI2HiY1TP7Co=
|
github.com/bluenviron/gortsplib/v5 v5.3.2/go.mod h1:x2Pn+7CYoASW4jz8O3Ae1cNTcfOoFMjUCGcafN4qzc8=
|
||||||
github.com/bluenviron/mediacommon/v2 v2.6.0 h1:wZAPXwv7V78Cx2x7cToYIHOLToHl6APcvHbdQT+gOkg=
|
github.com/bluenviron/mediacommon/v2 v2.8.0 h1:sacjx0Jwdl44awqN5jQhpm7LgVmDKf881hRqL9/fNgQ=
|
||||||
github.com/bluenviron/mediacommon/v2 v2.6.0/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs=
|
github.com/bluenviron/mediacommon/v2 v2.8.0/go.mod h1:D63vIFWAgTIo0OLsk9EVKVH4yrs8AKHlNqjzVsBTMwc=
|
||||||
github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4=
|
github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4=
|
||||||
github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY=
|
github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/containerd/containerd v1.7.15 h1:afEHXdil9iAm03BmhjzKyXnnEBtjaLJefdU7DV0IFes=
|
github.com/containerd/containerd v1.7.15 h1:afEHXdil9iAm03BmhjzKyXnnEBtjaLJefdU7DV0IFes=
|
||||||
github.com/containerd/containerd v1.7.15/go.mod h1:ISzRRTMF8EXNpJlTzyr2XMhN+j9K302C21/+cr3kUnY=
|
github.com/containerd/containerd v1.7.15/go.mod h1:ISzRRTMF8EXNpJlTzyr2XMhN+j9K302C21/+cr3kUnY=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
@@ -87,18 +95,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grafana/pyroscope-go v1.2.2 h1:uvKCyZMD724RkaCEMrSTC38Yn7AnFe8S2wiAIYdDPCE=
|
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
|
||||||
github.com/grafana/pyroscope-go v1.2.2/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU=
|
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
|
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||||
github.com/hamba/cmd/v3 v3.0.0 h1:YBMRgCCLajyHO68mEM0m5GLTUYDDwosTVp76+eDvsPE=
|
github.com/hamba/cmd/v3 v3.1.0 h1:aPartvDscWVC6VrboXC9e/uc0Z5S4ogXqj4yTTyqDmg=
|
||||||
github.com/hamba/cmd/v3 v3.0.0/go.mod h1:66LglrgdSkqPXhnxXKzDNXHkXsHYo0qiJnravEBmHII=
|
github.com/hamba/cmd/v3 v3.1.0/go.mod h1:5kSV/F3sDoN2t4R5Ayb2tRCYfHyVICNW5lUvoFe14FY=
|
||||||
github.com/hamba/logger/v2 v2.8.0 h1:0JJnEhVW4sHGn4/9fPP0LsZXD2ytG+NrnrXCdM8/vmg=
|
github.com/hamba/logger/v2 v2.9.0 h1:gLa4AuoQ17XTBovyIewOK7sALX/sHDJO3kfPUQBUA2o=
|
||||||
github.com/hamba/logger/v2 v2.8.0/go.mod h1:V58KZPAmDEWi14dOZjbKDPFkdyvpGwxXtLzLkVTNBic=
|
github.com/hamba/logger/v2 v2.9.0/go.mod h1:i+ohrYJ5XKaicZAJD+64lsYd3ZqLOjFXzt210lmZ/iQ=
|
||||||
github.com/hamba/statter/v2 v2.7.0 h1:9CnjJ5PcxOzIVJSAFSJm0lnUUBjTo3psV9nn+yZ1cMM=
|
github.com/hamba/statter/v2 v2.8.0 h1:5rLx+e/wODnvtkzpmEQim4hHcWEJbeI+KJuPHTkQCLQ=
|
||||||
github.com/hamba/statter/v2 v2.7.0/go.mod h1:SJPj0HCM+z7GxnoG+YBgN87SP0GVJ5YPjqHINrgqFYE=
|
github.com/hamba/statter/v2 v2.8.0/go.mod h1:V3pzf51ZQG5tpVQdbbkoTm3mA5GtxeQ30Yr+GPUa3Is=
|
||||||
github.com/hamba/testutils v0.7.0 h1:GQ0RJbz4+aFauvEV5AFgPMOKltl8gWZVbzROS5b9qDc=
|
github.com/hamba/testutils v0.7.0 h1:GQ0RJbz4+aFauvEV5AFgPMOKltl8gWZVbzROS5b9qDc=
|
||||||
github.com/hamba/testutils v0.7.0/go.mod h1:5rw9ZvxgDegvi9j32U5s5LBDrOBhrCu4g53EM03KOF4=
|
github.com/hamba/testutils v0.7.0/go.mod h1:5rw9ZvxgDegvi9j32U5s5LBDrOBhrCu4g53EM03KOF4=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
@@ -110,8 +118,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
@@ -120,8 +128,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
|
||||||
@@ -152,33 +160,32 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
|||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||||
github.com/pion/rtp v1.9.0 h1:NL2nGZPXhjnTQGRgsDZRv0ZTo0Or5fkjCy9o9PtBHBU=
|
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
|
||||||
github.com/pion/rtp v1.9.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
|
||||||
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
|
||||||
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
|
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||||
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
|
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
|
github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
|
||||||
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
@@ -195,8 +202,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
|
|||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E=
|
github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
|
||||||
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||||
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
||||||
@@ -205,60 +212,64 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||||
go.opentelemetry.io/otel/exporters/zipkin v1.37.0 h1:Z2apuaRnHEjzDAkpbWNPiksz1R0/FCIrJSjiMA43zwI=
|
go.opentelemetry.io/otel/exporters/zipkin v1.38.0 h1:0rJ2TmzpHDG+Ib9gPmu3J3cE0zXirumQcKS4wCoZUa0=
|
||||||
go.opentelemetry.io/otel/exporters/zipkin v1.37.0/go.mod h1:ofGu/7fG+bpmjZoiPUUmYDJ4vXWxMT57HmGoegx49uw=
|
go.opentelemetry.io/otel/exporters/zipkin v1.38.0/go.mod h1:Su/nq/K5zRjDKKC3Il0xbViE3juWgG3JDoqLumFx5G0=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
|
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||||
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
|
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
|
||||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
|
||||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.5 MiB |
+38
-65
@@ -7,12 +7,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/bluenviron/gortsplib/v5"
|
||||||
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||||
|
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||||
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
|
"github.com/bluenviron/gortsplib/v5/pkg/liberrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route that should never be a constructor default.
|
// Route that should never be a constructor default.
|
||||||
const dummyRoute = "/0x8b6c42"
|
const dummyRoute = "0x8b6c42"
|
||||||
|
|
||||||
// Dictionary provides dictionaries for routes, usernames and passwords.
|
// Dictionary provides dictionaries for routes, usernames and passwords.
|
||||||
type Dictionary interface {
|
type Dictionary interface {
|
||||||
@@ -187,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
|
|||||||
func needsReattack(streams []cameradar.Stream) bool {
|
func needsReattack(streams []cameradar.Stream) bool {
|
||||||
for _, stream := range streams {
|
for _, stream := range streams {
|
||||||
if stream.RouteFound && stream.CredentialsFound && stream.Available {
|
if stream.RouteFound && stream.CredentialsFound && stream.Available {
|
||||||
|
// This stream is fully discovered, no need to re-attack.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
|
|||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
target.RouteFound = true
|
target.RouteFound = true
|
||||||
target.Routes = append(target.Routes, "/")
|
target.Routes = append(target.Routes, "") // Add empty route for default.
|
||||||
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
|
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
|
||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
@@ -287,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
|
|||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
|
|
||||||
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
|
|
||||||
if err != nil {
|
|
||||||
return streams, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range streams {
|
|
||||||
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
|
|
||||||
|
|
||||||
var authMethod string
|
|
||||||
switch streams[i].AuthenticationType {
|
|
||||||
case cameradar.AuthNone:
|
|
||||||
authMethod = "no"
|
|
||||||
case cameradar.AuthBasic:
|
|
||||||
authMethod = "basic"
|
|
||||||
case cameradar.AuthDigest:
|
|
||||||
authMethod = "digest"
|
|
||||||
default:
|
|
||||||
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
|
|
||||||
}
|
|
||||||
|
|
||||||
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
return streams, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return stream, ctx.Err()
|
|
||||||
}
|
|
||||||
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
|
|
||||||
if err != nil {
|
|
||||||
return stream, fmt.Errorf("building rtsp url: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := a.newRTSPClient(u)
|
|
||||||
if err != nil {
|
|
||||||
return stream, fmt.Errorf("starting rtsp client: %w", err)
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
_, res, err := client.Describe(u)
|
|
||||||
if err != nil {
|
|
||||||
var badStatus liberrors.ErrClientBadStatusCode
|
|
||||||
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
|
|
||||||
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
|
|
||||||
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
|
|
||||||
return stream, nil
|
|
||||||
}
|
|
||||||
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if res != nil {
|
|
||||||
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.AuthenticationType = cameradar.AuthNone
|
|
||||||
return stream, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
|
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
|
||||||
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
|
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -399,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
|
|||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
desc, res, err := client.Describe(u)
|
desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return a.handleDescribeError(stream, urlStr, err)
|
return a.handleDescribeError(stream, urlStr, err)
|
||||||
}
|
}
|
||||||
@@ -413,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return a.handleSetupError(stream, urlStr, err)
|
return a.handleSetupError(stream, urlStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.logSetupResponse(urlStr, res)
|
a.logSetupResponse(urlStr, res)
|
||||||
|
|
||||||
stream.Available = res != nil && res.StatusCode == base.StatusOK
|
stream.Available = res != nil && res.StatusCode == base.StatusOK
|
||||||
@@ -424,9 +365,39 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
|
|||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, u *base.URL, urlStr string) (*description.Session, *base.Response, error) {
|
||||||
|
var (
|
||||||
|
desc *description.Session
|
||||||
|
res *base.Response
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
for range 5 {
|
||||||
|
desc, res, err = client.Describe(u)
|
||||||
|
if err == nil {
|
||||||
|
return desc, res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
|
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
|
||||||
|
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", urlStr, badStatus.Code))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, nil, ctx.Err()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
|
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
|
||||||
var badStatus liberrors.ErrClientBadStatusCode
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
|
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
|
||||||
|
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
|
||||||
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
|
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
|
||||||
stream.Address.String(),
|
stream.Address.String(),
|
||||||
stream.Port,
|
stream.Port,
|
||||||
@@ -436,6 +407,8 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er
|
|||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
|
||||||
|
|
||||||
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
|
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
|
|||||||
|
|
||||||
got, err := attacker.Attack(t.Context(), streams)
|
got, err := attacker.Attack(t.Context(), streams)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorContains(t, err, "detecting authentication methods")
|
assert.ErrorContains(t, err, "validating streams")
|
||||||
require.Len(t, got, 1)
|
require.Len(t, got, 1)
|
||||||
assert.False(t, got[0].RouteFound)
|
assert.False(t, got[0].RouteFound)
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, got, 1)
|
require.Len(t, got, 1)
|
||||||
assert.True(t, got[0].RouteFound)
|
assert.True(t, got[0].RouteFound)
|
||||||
assert.Equal(t, []string{"/"}, got[0].Routes)
|
assert.Equal(t, []string{""}, got[0].Routes)
|
||||||
assert.True(t, got[0].Available)
|
assert.True(t, got[0].Available)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package attack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
|
||||||
|
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
|
||||||
|
if err != nil {
|
||||||
|
return streams, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range streams {
|
||||||
|
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
|
||||||
|
|
||||||
|
var authMethod string
|
||||||
|
switch streams[i].AuthenticationType {
|
||||||
|
case cameradar.AuthNone:
|
||||||
|
authMethod = "no"
|
||||||
|
case cameradar.AuthBasic:
|
||||||
|
authMethod = "basic"
|
||||||
|
case cameradar.AuthDigest:
|
||||||
|
authMethod = "digest"
|
||||||
|
case cameradar.AuthUnknown:
|
||||||
|
authMethod = "unknown"
|
||||||
|
default:
|
||||||
|
authMethod = fmt.Sprintf("unknown (%d)", streams[i].AuthenticationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return stream, ctx.Err()
|
||||||
|
}
|
||||||
|
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
|
||||||
|
if err != nil {
|
||||||
|
return stream, fmt.Errorf("building rtsp url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
|
||||||
|
if err != nil {
|
||||||
|
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
|
||||||
|
stream.AuthenticationType = cameradar.AuthUnknown
|
||||||
|
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, statusCode))
|
||||||
|
values := headerValues(headers, "WWW-Authenticate")
|
||||||
|
switch statusCode {
|
||||||
|
case base.StatusOK:
|
||||||
|
stream.AuthenticationType = cameradar.AuthNone
|
||||||
|
case base.StatusUnauthorized:
|
||||||
|
stream.AuthenticationType = authTypeFromHeaders(values)
|
||||||
|
default:
|
||||||
|
stream.AuthenticationType = cameradar.AuthUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package attack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/Ullaakut/cameradar/v6/internal/ui"
|
||||||
|
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||||
|
"github.com/bluenviron/gortsplib/v5/pkg/headers"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testDictionary struct {
|
||||||
|
routes []string
|
||||||
|
usernames []string
|
||||||
|
passwords []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d testDictionary) Routes() []string {
|
||||||
|
return d.routes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d testDictionary) Usernames() []string {
|
||||||
|
return d.usernames
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d testDictionary) Passwords() []string {
|
||||||
|
return d.passwords
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthTypeFromHeaders(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
values base.HeaderValue
|
||||||
|
want cameradar.AuthType
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "digest wins over basic",
|
||||||
|
values: base.HeaderValue{
|
||||||
|
headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal()[0],
|
||||||
|
headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal()[0],
|
||||||
|
},
|
||||||
|
want: cameradar.AuthDigest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "basic auth",
|
||||||
|
values: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
|
||||||
|
want: cameradar.AuthBasic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "digest auth",
|
||||||
|
values: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
|
||||||
|
want: cameradar.AuthDigest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown with empty values",
|
||||||
|
values: nil,
|
||||||
|
want: cameradar.AuthUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown with unsupported header",
|
||||||
|
values: base.HeaderValue{"Bearer abc"},
|
||||||
|
want: cameradar.AuthUnknown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, test.want, authTypeFromHeaders(test.values))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAuthMethod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode base.StatusCode
|
||||||
|
headers base.Header
|
||||||
|
want cameradar.AuthType
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no auth when status ok",
|
||||||
|
statusCode: base.StatusOK,
|
||||||
|
headers: base.Header{
|
||||||
|
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
|
||||||
|
},
|
||||||
|
want: cameradar.AuthNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "basic auth on unauthorized",
|
||||||
|
statusCode: base.StatusUnauthorized,
|
||||||
|
headers: base.Header{
|
||||||
|
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
|
||||||
|
},
|
||||||
|
want: cameradar.AuthBasic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "digest auth on unauthorized",
|
||||||
|
statusCode: base.StatusUnauthorized,
|
||||||
|
headers: base.Header{
|
||||||
|
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
|
||||||
|
},
|
||||||
|
want: cameradar.AuthDigest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown auth on unauthorized without www-authenticate",
|
||||||
|
statusCode: base.StatusUnauthorized,
|
||||||
|
headers: nil,
|
||||||
|
want: cameradar.AuthUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown auth on other status",
|
||||||
|
statusCode: base.StatusNotFound,
|
||||||
|
headers: nil,
|
||||||
|
want: cameradar.AuthUnknown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
addr, port := startRTSPProbeServer(t, test.statusCode, test.headers)
|
||||||
|
|
||||||
|
attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stream := cameradar.Stream{
|
||||||
|
Address: addr,
|
||||||
|
Port: port,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := attacker.detectAuthMethod(t.Context(), stream)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.want, got.AuthenticationType)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = listener.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(time.Second))
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := statusTextFromCode(statusCode)
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
_, _ = fmt.Fprintf(&builder, "RTSP/1.0 %d %s\r\n", statusCode, statusText)
|
||||||
|
builder.WriteString("CSeq: 1\r\n")
|
||||||
|
for key, values := range headers {
|
||||||
|
for _, value := range values {
|
||||||
|
_, _ = fmt.Fprintf(&builder, "%s: %s\r\n", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.WriteString("Content-Length: 0\r\n\r\n")
|
||||||
|
|
||||||
|
_, _ = conn.Write([]byte(builder.String()))
|
||||||
|
}()
|
||||||
|
|
||||||
|
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusTextFromCode(code base.StatusCode) string {
|
||||||
|
switch code {
|
||||||
|
case base.StatusOK:
|
||||||
|
return "OK"
|
||||||
|
case base.StatusUnauthorized:
|
||||||
|
return "Unauthorized"
|
||||||
|
case base.StatusNotFound:
|
||||||
|
return "Not Found"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
-7
@@ -1,10 +1,16 @@
|
|||||||
package attack
|
package attack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
"github.com/bluenviron/gortsplib/v5"
|
"github.com/bluenviron/gortsplib/v5"
|
||||||
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
|
|||||||
_, res, err := client.Describe(u)
|
_, res, err := client.Describe(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var badStatus liberrors.ErrClientBadStatusCode
|
var badStatus liberrors.ErrClientBadStatusCode
|
||||||
if errors.As(err, &badStatus) && res != nil {
|
if errors.As(err, &badStatus) {
|
||||||
return badStatus.Code, nil
|
return badStatus.Code, nil
|
||||||
}
|
}
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
|
|||||||
return res.StatusCode, nil
|
return res.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeDescribeHeaders performs a manual DESCRIBE request and returns the status code and headers.
|
||||||
|
//
|
||||||
|
// NOTE: We do not use gortsplib here because it does not expose response headers when the status code is 401 Unauthorized,
|
||||||
|
// which is exactly what we need in order to detect authentication methods.
|
||||||
|
func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) {
|
||||||
|
dialer := &net.Dialer{Timeout: a.timeout}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(a.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = conn.SetDeadline(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
request := fmt.Sprintf(
|
||||||
|
"DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: cameradar\r\nAccept: application/sdp\r\nHost: %s\r\n\r\n",
|
||||||
|
urlStr,
|
||||||
|
u.Host,
|
||||||
|
)
|
||||||
|
_, err = conn.Write([]byte(request))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := textproto.NewReader(bufio.NewReader(conn))
|
||||||
|
statusLine, err := reader.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
fields := strings.Fields(statusLine)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return 0, nil, fmt.Errorf("invalid RTSP status line: %q", statusLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("parsing RTSP status code %q: %w", fields[1], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeHeader, err := reader.ReadMIMEHeader()
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(base.Header)
|
||||||
|
for key, values := range mimeHeader {
|
||||||
|
headers[key] = append(base.HeaderValue(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.StatusCode(code), headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
|
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
return cameradar.AuthNone
|
return cameradar.AuthUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasBasic bool
|
var hasBasic bool
|
||||||
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
|
|||||||
var authHeader headers.Authenticate
|
var authHeader headers.Authenticate
|
||||||
err := authHeader.Unmarshal(base.HeaderValue{value})
|
err := authHeader.Unmarshal(base.HeaderValue{value})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
hasDigest = hasDigest || strings.Contains(lower, "digest")
|
||||||
|
hasBasic = hasBasic || strings.Contains(lower, "basic")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,15 +149,24 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
|
|||||||
if hasBasic {
|
if hasBasic {
|
||||||
return cameradar.AuthBasic
|
return cameradar.AuthBasic
|
||||||
}
|
}
|
||||||
return cameradar.AuthType(-1)
|
return cameradar.AuthUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerValues(header base.Header, name string) base.HeaderValue {
|
||||||
|
if header == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, values := range header {
|
||||||
|
if strings.EqualFold(key, name) {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
|
func buildRTSPURL(stream cameradar.Stream, route, username, password string) (*base.URL, string, error) {
|
||||||
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
|
host := net.JoinHostPort(stream.Address.String(), strconv.Itoa(int(stream.Port)))
|
||||||
path := "/" + route
|
path := "/" + strings.TrimLeft(strings.TrimSpace(route), "/") // Ensure path starts with a single "/"
|
||||||
if route == "" {
|
|
||||||
path = "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Scheme: "rtsp",
|
Scheme: "rtsp",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package attack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildRTSPURL(t *testing.T) {
|
||||||
|
stream := cameradar.Stream{
|
||||||
|
Address: netip.MustParseAddr("192.168.0.10"),
|
||||||
|
Port: 554,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
route string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
wantURL string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty route",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root route",
|
||||||
|
route: "/",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple leading slashes",
|
||||||
|
route: "////",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "route with no leading slash",
|
||||||
|
route: "stream",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "route with leading slash",
|
||||||
|
route: "/stream",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "route with trailing slash",
|
||||||
|
route: "stream/",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/stream/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "route with spaces",
|
||||||
|
route: " /stream ",
|
||||||
|
wantURL: "rtsp://192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username and password",
|
||||||
|
route: "stream",
|
||||||
|
username: "admin",
|
||||||
|
password: "admin123",
|
||||||
|
wantURL: "rtsp://admin:admin123@192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty username with password",
|
||||||
|
route: "stream",
|
||||||
|
password: "pass",
|
||||||
|
wantURL: "rtsp://:pass@192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username only",
|
||||||
|
route: "stream",
|
||||||
|
username: "user",
|
||||||
|
wantURL: "rtsp://user:@192.168.0.10:554/stream",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, gotURL, err := buildRTSPURL(stream, test.route, test.username, test.password)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, test.wantURL, gotURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/live/ch01_0
|
|
||||||
|
live/ch01_0
|
||||||
0/1:1/main
|
0/1:1/main
|
||||||
0/usrnm:pwd/main
|
0/usrnm:pwd/main
|
||||||
0/video1
|
0/video1
|
||||||
|
|||||||
@@ -112,10 +112,7 @@ func formatStreamLabel(stream cameradar.Stream) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatRTSPURL(stream cameradar.Stream) string {
|
func formatRTSPURL(stream cameradar.Stream) string {
|
||||||
path := strings.TrimSpace(stream.Route())
|
path := "/" + strings.TrimLeft(strings.TrimSpace(stream.Route()), "/")
|
||||||
if path != "" && !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
credentials := ""
|
credentials := ""
|
||||||
if stream.CredentialsFound && (stream.Username != "" || stream.Password != "") {
|
if stream.CredentialsFound && (stream.Username != "" || stream.Password != "") {
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package scan
|
package scan
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
"github.com/Ullaakut/cameradar/v6/internal/scan/masscan"
|
||||||
"github.com/Ullaakut/cameradar/v6/internal/scan/nmap"
|
"github.com/Ullaakut/cameradar/v6/internal/scan/nmap"
|
||||||
"github.com/Ullaakut/cameradar/v6/internal/scan/skip"
|
"github.com/Ullaakut/cameradar/v6/internal/scan/skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Supported discovery backends.
|
||||||
|
const (
|
||||||
|
ScannerNmap = "nmap"
|
||||||
|
ScannerMasscan = "masscan"
|
||||||
|
)
|
||||||
|
|
||||||
// Config configures how Cameradar discovers RTSP streams.
|
// Config configures how Cameradar discovers RTSP streams.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
SkipScan bool
|
SkipScan bool
|
||||||
Targets []string
|
Targets []string
|
||||||
Ports []string
|
Ports []string
|
||||||
ScanSpeed int16
|
ScanSpeed int16
|
||||||
|
Scanner string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reporter reports scan progress and debug information.
|
// Reporter reports scan progress and debug information.
|
||||||
@@ -31,5 +42,17 @@ func New(config Config, reporter Reporter) (cameradar.StreamScanner, error) {
|
|||||||
return skip.New(expandedTargets, config.Ports), nil
|
return skip.New(expandedTargets, config.Ports), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter)
|
scanner := strings.ToLower(strings.TrimSpace(config.Scanner))
|
||||||
|
if scanner == "" {
|
||||||
|
scanner = ScannerNmap
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scanner {
|
||||||
|
case ScannerNmap:
|
||||||
|
return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter)
|
||||||
|
case ScannerMasscan:
|
||||||
|
return masscan.New(expandedTargets, config.Ports, reporter)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported scanner %q", scanner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,3 +64,31 @@ func TestNew_SkipScanPropagatesErrors(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorContains(t, err, "invalid port range")
|
assert.ErrorContains(t, err, "invalid port range")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNew_UnsupportedScanner(t *testing.T) {
|
||||||
|
config := scan.Config{
|
||||||
|
Targets: []string{"192.0.2.1"},
|
||||||
|
Ports: []string{"554"},
|
||||||
|
Scanner: "unsupported",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := scan.New(config, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "unsupported scanner")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_SkipScanIgnoresUnsupportedScanner(t *testing.T) {
|
||||||
|
config := scan.Config{
|
||||||
|
SkipScan: true,
|
||||||
|
Targets: []string{"192.0.2.1"},
|
||||||
|
Ports: []string{"554"},
|
||||||
|
Scanner: "unsupported",
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner, err := scan.New(config, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
streams, err := scanner.Scan(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []cameradar.Stream{{Address: netip.MustParseAddr("192.0.2.1"), Port: 554}}, streams)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package masscan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
masscanlib "github.com/Ullaakut/masscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 a masscan scan.
|
||||||
|
type Runner interface {
|
||||||
|
Run(ctx context.Context) (*masscanlib.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 targets and ports.
|
||||||
|
func New(targets, ports []string, reporter Reporter) (*Scanner, error) {
|
||||||
|
runner, err := masscanlib.NewScanner(
|
||||||
|
masscanlib.WithTargets(targets...),
|
||||||
|
masscanlib.WithPorts(ports...),
|
||||||
|
masscanlib.WithOpenOnly(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating masscan 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, runner Runner, reporter Reporter) ([]cameradar.Stream, error) {
|
||||||
|
results, err := runner.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning network: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, warning := range results.Warnings() {
|
||||||
|
reporter.Debug(cameradar.StepScan, "masscan warning: "+warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
var streams []cameradar.Stream
|
||||||
|
for _, host := range results.Hosts {
|
||||||
|
address := strings.TrimSpace(host.Address)
|
||||||
|
if address == "" {
|
||||||
|
reporter.Progress(cameradar.StepScan, "Skipping host with empty address")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(address)
|
||||||
|
if err != nil {
|
||||||
|
reporter.Progress(cameradar.StepScan, fmt.Sprintf("Skipping invalid address %q: %v", host.Address, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, port := range host.Ports {
|
||||||
|
if port.Status != "open" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if port.Number <= 0 || port.Number > 65535 {
|
||||||
|
reporter.Progress(cameradar.StepScan, fmt.Sprintf("Skipping invalid port %d on %s", port.Number, host.Address))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
streams = append(streams, cameradar.Stream{
|
||||||
|
Address: addr,
|
||||||
|
Port: uint16(port.Number),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package masscan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
|
masscanlib "github.com/Ullaakut/masscan"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunScan(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result *masscanlib.Run
|
||||||
|
err error
|
||||||
|
wantStreams []cameradar.Stream
|
||||||
|
wantDebug []string
|
||||||
|
wantProgress []string
|
||||||
|
wantErrContains string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "filters invalid addresses, closed and invalid ports",
|
||||||
|
result: &masscanlib.Run{
|
||||||
|
Hosts: []masscanlib.Host{
|
||||||
|
{
|
||||||
|
Address: "192.0.2.10",
|
||||||
|
Ports: []masscanlib.Port{
|
||||||
|
{Number: 554, Status: "open"},
|
||||||
|
{Number: 8554, Status: "closed"},
|
||||||
|
{Number: 0, Status: "open"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Address: "not-an-ip", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}},
|
||||||
|
{Address: "", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStreams: []cameradar.Stream{
|
||||||
|
{Address: netip.MustParseAddr("192.0.2.10"), Port: 554},
|
||||||
|
},
|
||||||
|
wantProgress: []string{
|
||||||
|
"Skipping invalid port 0 on 192.0.2.10",
|
||||||
|
"Skipping invalid address \"not-an-ip\": ParseAddr(\"not-an-ip\"): unable to parse IP",
|
||||||
|
"Skipping host with empty address",
|
||||||
|
"Found 1 RTSP streams",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collects streams from multiple hosts",
|
||||||
|
result: &masscanlib.Run{
|
||||||
|
Hosts: []masscanlib.Host{
|
||||||
|
{Address: "192.0.2.10", Ports: []masscanlib.Port{{Number: 8554, Status: "open"}}},
|
||||||
|
{Address: "198.51.100.9", Ports: []masscanlib.Port{{Number: 554, Status: "open"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantStreams: []cameradar.Stream{
|
||||||
|
{Address: netip.MustParseAddr("192.0.2.10"), Port: 8554},
|
||||||
|
{Address: netip.MustParseAddr("198.51.100.9"), Port: 554},
|
||||||
|
},
|
||||||
|
wantProgress: []string{"Found 2 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{}
|
||||||
|
|
||||||
|
streams, err := runScan(t.Context(), fakeRunner{result: test.result, err: test.err}, reporter)
|
||||||
|
|
||||||
|
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)
|
||||||
|
for _, progress := range test.wantProgress {
|
||||||
|
assert.Contains(t, reporter.progress, progress)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
result *masscanlib.Run
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeRunner) Run(context.Context) (*masscanlib.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() {}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// BuildInfo represents build metadata injected at link time.
|
||||||
|
type BuildInfo struct {
|
||||||
|
Version string
|
||||||
|
Commit string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayVersion returns the version prefixed with "v" when needed.
|
||||||
|
func (b BuildInfo) DisplayVersion() string {
|
||||||
|
version := strings.TrimSpace(b.Version)
|
||||||
|
if version == "" {
|
||||||
|
version = "dev"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(version, "v") {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
return "v" + version
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogVersion returns the version without a leading "v".
|
||||||
|
func (b BuildInfo) LogVersion() string {
|
||||||
|
version := strings.TrimSpace(b.Version)
|
||||||
|
if version == "" {
|
||||||
|
return "dev"
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(version, "v")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortCommit returns a shortened commit hash suitable for display.
|
||||||
|
func (b BuildInfo) ShortCommit() string {
|
||||||
|
commit := strings.TrimSpace(b.Commit)
|
||||||
|
if commit == "" || commit == "none" || commit == "unknown" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if len(commit) > 7 {
|
||||||
|
return commit[:7]
|
||||||
|
}
|
||||||
|
return commit
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUIHeader returns the header used by the TUI.
|
||||||
|
func (b BuildInfo) TUIHeader() string {
|
||||||
|
return "Cameradar — " + b.DisplayVersion() + " (" + b.ShortCommit() + ")"
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package ui_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Ullaakut/cameradar/v6/internal/ui"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildInfo_DisplayVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty defaults to dev with prefix",
|
||||||
|
version: "",
|
||||||
|
want: "vdev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dev without prefix",
|
||||||
|
version: "dev",
|
||||||
|
want: "vdev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "already prefixed",
|
||||||
|
version: "v1.2.3",
|
||||||
|
want: "v1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adds prefix",
|
||||||
|
version: "1.2.3",
|
||||||
|
want: "v1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims spaces with prefix",
|
||||||
|
version: " v2.0 ",
|
||||||
|
want: "v2.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims spaces without prefix",
|
||||||
|
version: " 2.0 ",
|
||||||
|
want: "v2.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
info := ui.BuildInfo{Version: test.version}
|
||||||
|
assert.Equal(t, test.want, info.DisplayVersion())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInfo_LogVersion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty defaults to dev",
|
||||||
|
version: "",
|
||||||
|
want: "dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removes leading v",
|
||||||
|
version: "v1.2.3",
|
||||||
|
want: "1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keeps version without prefix",
|
||||||
|
version: "1.2.3",
|
||||||
|
want: "1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims spaces and removes prefix",
|
||||||
|
version: " v2.0 ",
|
||||||
|
want: "2.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removes only first prefix",
|
||||||
|
version: "vv1",
|
||||||
|
want: "v1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
info := ui.BuildInfo{Version: test.version}
|
||||||
|
assert.Equal(t, test.want, info.LogVersion())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInfo_ShortCommit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
commit string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty defaults to unknown",
|
||||||
|
commit: "",
|
||||||
|
want: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "none defaults to unknown",
|
||||||
|
commit: "none",
|
||||||
|
want: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown defaults to unknown",
|
||||||
|
commit: "unknown",
|
||||||
|
want: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short commit preserved",
|
||||||
|
commit: "abcdef",
|
||||||
|
want: "abcdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seven chars preserved",
|
||||||
|
commit: "abcdefg",
|
||||||
|
want: "abcdefg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long commit shortened",
|
||||||
|
commit: "abcdefghi",
|
||||||
|
want: "abcdefg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims spaces before shortening",
|
||||||
|
commit: " 1234567890 ",
|
||||||
|
want: "1234567",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
info := ui.BuildInfo{Commit: test.commit}
|
||||||
|
assert.Equal(t, test.want, info.ShortCommit())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInfo_TUIHeader(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
version string
|
||||||
|
commit string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "uses display version and short commit",
|
||||||
|
version: "1.2.3",
|
||||||
|
commit: "abcdefghi",
|
||||||
|
want: "Cameradar — v1.2.3 (abcdefg)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uses defaults for empty values",
|
||||||
|
version: "",
|
||||||
|
commit: "",
|
||||||
|
want: "Cameradar — vdev (unknown)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
info := ui.BuildInfo{Version: test.version, Commit: test.commit}
|
||||||
|
assert.Equal(t, test.want, info.TUIHeader())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-4
@@ -22,9 +22,22 @@ func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrintStartup prints build metadata and configuration options.
|
||||||
|
func (r *PlainReporter) PrintStartup(buildInfo BuildInfo, options []string) {
|
||||||
|
step := cameradar.Step("Startup")
|
||||||
|
message := fmt.Sprintf("Running cameradar version %s, commit %s", buildInfo.LogVersion(), buildInfo.ShortCommit())
|
||||||
|
r.print(step, "INFO", message)
|
||||||
|
if len(options) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
r.print(step, "INFO", option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start prints the beginning of a step.
|
// Start prints the beginning of a step.
|
||||||
func (r *PlainReporter) Start(step cameradar.Step, message string) {
|
func (r *PlainReporter) Start(step cameradar.Step, message string) {
|
||||||
r.print(step, "START", message)
|
r.print(step, "STEP", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Done prints the completion of a step.
|
// Done prints the completion of a step.
|
||||||
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
|
|||||||
if !r.debug {
|
if !r.debug {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.print(step, "DEBUG", message)
|
r.print(step, "DBUG", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error prints an error message.
|
// Error prints an error message.
|
||||||
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.print(step, "ERROR", err.Error())
|
r.print(step, "EROR", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary prints the final summary.
|
// Summary prints the final summary.
|
||||||
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339))
|
level = normalizeLevel(level)
|
||||||
|
_, _ = fmt.Fprintf(r.out, "%s [%s] %s: %s\n", time.Now().Format(time.RFC3339), level, cameradar.StepLabel(step), message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLevel(level string) string {
|
||||||
|
switch level {
|
||||||
|
case "DEBUG":
|
||||||
|
return "DBUG"
|
||||||
|
case "ERROR":
|
||||||
|
return "EROR"
|
||||||
|
case "START", "STEP":
|
||||||
|
return "STEP"
|
||||||
|
}
|
||||||
|
if len(level) >= 4 {
|
||||||
|
return level[:4]
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%-4s", level)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) {
|
|||||||
reporter.Summary([]cameradar.Stream{}, nil)
|
reporter.Summary([]cameradar.Stream{}, nil)
|
||||||
|
|
||||||
content := out.String()
|
content := out.String()
|
||||||
assert.Contains(t, content, "[START] Scan targets: starting")
|
assert.Contains(t, content, " [STEP] Scan targets: starting")
|
||||||
assert.Contains(t, content, "[INFO] Scan targets: working")
|
assert.Contains(t, content, " [INFO] Scan targets: working")
|
||||||
assert.Contains(t, content, "[DEBUG] Scan targets: details")
|
assert.Contains(t, content, " [DBUG] Scan targets: details")
|
||||||
assert.Contains(t, content, "[DONE] Scan targets: finished")
|
assert.Contains(t, content, " [DONE] Scan targets: finished")
|
||||||
assert.Contains(t, content, "[ERROR] Scan targets: boom")
|
assert.Contains(t, content, " [EROR] Scan targets: boom")
|
||||||
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
|
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
|
|||||||
reporter.Error(cameradar.StepScan, nil)
|
reporter.Error(cameradar.StepScan, nil)
|
||||||
|
|
||||||
content := out.String()
|
content := out.String()
|
||||||
assert.NotContains(t, content, "DEBUG")
|
assert.NotContains(t, content, "DBUG")
|
||||||
assert.Equal(t, "", strings.TrimSpace(content))
|
assert.Equal(t, "", strings.TrimSpace(content))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlainReporter_PrintStartup(t *testing.T) {
|
||||||
|
t.Run("prints build info and options", func(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
reporter := ui.NewPlainReporter(out, false)
|
||||||
|
|
||||||
|
reporter.PrintStartup(ui.BuildInfo{Version: "v1.2.3", Commit: "abcdefghi"}, []string{
|
||||||
|
"targets: 127.0.0.1",
|
||||||
|
"ports: 554",
|
||||||
|
})
|
||||||
|
|
||||||
|
content := out.String()
|
||||||
|
assert.Contains(t, content, " [INFO] Startup: Running cameradar version 1.2.3, commit abcdefg")
|
||||||
|
assert.Contains(t, content, " [INFO] Startup: targets: 127.0.0.1")
|
||||||
|
assert.Contains(t, content, " [INFO] Startup: ports: 554")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prints only build info when options empty", func(t *testing.T) {
|
||||||
|
out := &bytes.Buffer{}
|
||||||
|
reporter := ui.NewPlainReporter(out, false)
|
||||||
|
|
||||||
|
reporter.PrintStartup(ui.BuildInfo{Version: "", Commit: "none"}, nil)
|
||||||
|
|
||||||
|
content := out.String()
|
||||||
|
assert.Contains(t, content, " [INFO] Startup: Running cameradar version dev, commit unknown")
|
||||||
|
assert.Equal(t, 1, strings.Count(content, " Startup: "))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,7 +21,7 @@ type Reporter interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewReporter creates a Reporter based on the requested mode.
|
// NewReporter creates a Reporter based on the requested mode.
|
||||||
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) {
|
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool, buildInfo BuildInfo, cancel context.CancelFunc) (Reporter, error) {
|
||||||
if debug {
|
if debug {
|
||||||
return NewPlainReporter(out, debug), nil
|
return NewPlainReporter(out, debug), nil
|
||||||
}
|
}
|
||||||
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
|
|||||||
if !interactive {
|
if !interactive {
|
||||||
return nil, errors.New("tui mode requires an interactive terminal")
|
return nil, errors.New("tui mode requires an interactive terminal")
|
||||||
}
|
}
|
||||||
return NewTUIReporter(debug, out)
|
return NewTUIReporter(debug, out, buildInfo, cancel)
|
||||||
case cameradar.ModeAuto:
|
case cameradar.ModeAuto:
|
||||||
if interactive {
|
if interactive {
|
||||||
return NewTUIReporter(debug, out)
|
return NewTUIReporter(debug, out, buildInfo, cancel)
|
||||||
}
|
}
|
||||||
return NewPlainReporter(out, debug), nil
|
return NewPlainReporter(out, debug), nil
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
out := &bytes.Buffer{}
|
out := &bytes.Buffer{}
|
||||||
|
|
||||||
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive)
|
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive, ui.BuildInfo{Version: "dev", Commit: "none"}, func() {})
|
||||||
|
|
||||||
if test.wantErrContains != "" {
|
if test.wantErrContains != "" {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|||||||
+144
-38
@@ -1,11 +1,13 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Ullaakut/cameradar/v6"
|
"github.com/Ullaakut/cameradar/v6"
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"github.com/charmbracelet/bubbles/progress"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/table"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,13 +15,15 @@ type modelState struct {
|
|||||||
steps []cameradar.Step
|
steps []cameradar.Step
|
||||||
status map[cameradar.Step]state
|
status map[cameradar.Step]state
|
||||||
logs []logMsg
|
logs []logMsg
|
||||||
summary []summaryTable
|
|
||||||
summaryStreams []cameradar.Stream
|
summaryStreams []cameradar.Stream
|
||||||
summaryFinal bool
|
summaryFinal bool
|
||||||
|
buildInfo BuildInfo
|
||||||
|
cancel context.CancelFunc
|
||||||
debug bool
|
debug bool
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
progress progress.Model
|
progress progress.Model
|
||||||
width int
|
width int
|
||||||
|
height int
|
||||||
quitting bool
|
quitting bool
|
||||||
progressTotals map[cameradar.Step]int
|
progressTotals map[cameradar.Step]int
|
||||||
progressCounts map[cameradar.Step]int
|
progressCounts map[cameradar.Step]int
|
||||||
@@ -45,6 +49,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.handleProgressMsg(typed)
|
m.handleProgressMsg(typed)
|
||||||
case closeMsg:
|
case closeMsg:
|
||||||
m.quitting = true
|
m.quitting = true
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if typed.Type == tea.KeyCtrlC {
|
||||||
|
if m.cancel != nil {
|
||||||
|
m.cancel()
|
||||||
|
}
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
case spinner.TickMsg:
|
case spinner.TickMsg:
|
||||||
cmds = m.handleSpinnerMsg(typed)
|
cmds = m.handleSpinnerMsg(typed)
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -71,7 +83,6 @@ func (m *modelState) handleStepMsg(msg stepMsg) {
|
|||||||
markStepComplete(m, msg.step)
|
markStepComplete(m, msg.step)
|
||||||
queueProgressUpdate(m)
|
queueProgressUpdate(m)
|
||||||
}
|
}
|
||||||
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelState) handleLogMsg(msg logMsg) {
|
func (m *modelState) handleLogMsg(msg logMsg) {
|
||||||
@@ -81,7 +92,6 @@ func (m *modelState) handleLogMsg(msg logMsg) {
|
|||||||
func (m *modelState) handleSummaryMsg(msg summaryMsg) {
|
func (m *modelState) handleSummaryMsg(msg summaryMsg) {
|
||||||
m.summaryStreams = msg.streams
|
m.summaryStreams = msg.streams
|
||||||
m.summaryFinal = msg.final
|
m.summaryFinal = msg.final
|
||||||
m.summary = buildSummaryTables(msg.streams, m.width, m.status, msg.final)
|
|
||||||
if msg.final {
|
if msg.final {
|
||||||
m.status[cameradar.StepSummary] = stateDone
|
m.status[cameradar.StepSummary] = stateDone
|
||||||
markStepComplete(m, cameradar.StepSummary)
|
markStepComplete(m, cameradar.StepSummary)
|
||||||
@@ -123,55 +133,151 @@ func (m *modelState) handleSpinnerMsg(msg spinner.TickMsg) []tea.Cmd {
|
|||||||
|
|
||||||
func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
|
func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
m.progress.Width = progressWidth(msg.Width)
|
m.progress.Width = progressWidth(msg.Width)
|
||||||
m.summary = buildSummaryTables(m.summaryStreams, m.width, m.status, m.summaryFinal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *modelState) View() string {
|
func (m *modelState) View() string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString(sectionStyle.Render("Steps"))
|
header := sectionStyle.Render(m.buildInfo.TUIHeader())
|
||||||
builder.WriteString("\n")
|
headerLines := splitLines(header)
|
||||||
builder.WriteString(renderProgress(m))
|
builder.WriteString(strings.Join(headerLines, "\n"))
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n\n")
|
||||||
|
|
||||||
spinnerView := m.spinner.View()
|
stepsLines := m.renderSteps()
|
||||||
for _, step := range m.steps {
|
builder.WriteString(strings.Join(stepsLines, "\n"))
|
||||||
builder.WriteString(renderStep(step, m.status[step], spinnerView))
|
builder.WriteString("\n\n")
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.WriteString("\n")
|
summaryHeight, logsHeight := m.layoutHeights(len(headerLines), len(stepsLines))
|
||||||
|
logsLines := m.renderLogs(logsHeight)
|
||||||
builder.WriteString(sectionStyle.Render("Logs"))
|
builder.WriteString(sectionStyle.Render("Logs"))
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
if len(m.logs) == 0 {
|
builder.WriteString(strings.Join(logsLines, "\n"))
|
||||||
builder.WriteString(dimStyle.Render("No events yet."))
|
builder.WriteString("\n\n")
|
||||||
builder.WriteString("\n")
|
|
||||||
} else {
|
|
||||||
for _, entry := range m.logs {
|
|
||||||
builder.WriteString(renderLog(entry))
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
rowsToShow := max(1, summaryHeight-2)
|
||||||
|
summaryTitle := renderSummaryTitle(m.summaryStreams)
|
||||||
|
summaryTables := buildSummaryTables(m.summaryStreams, m.width, m.status, rowsToShow)
|
||||||
|
builder.WriteString(sectionStyle.Render(summaryTitle))
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
builder.WriteString(sectionStyle.Render("Summary"))
|
for i, summary := range summaryTables {
|
||||||
builder.WriteString("\n")
|
builder.WriteString(summaryTableStyle.Render(summary.table.View()))
|
||||||
for i, summary := range m.summary {
|
if i < len(summaryTables)-1 {
|
||||||
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")
|
builder.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *modelState) FinalView() string {
|
||||||
|
var builder strings.Builder
|
||||||
|
header := sectionStyle.Render(m.buildInfo.TUIHeader())
|
||||||
|
headerLines := splitLines(header)
|
||||||
|
builder.WriteString(strings.Join(headerLines, "\n"))
|
||||||
|
builder.WriteString("\n\n")
|
||||||
|
|
||||||
|
stepsLines := m.renderSteps()
|
||||||
|
builder.WriteString(strings.Join(stepsLines, "\n"))
|
||||||
|
builder.WriteString("\n\n")
|
||||||
|
|
||||||
|
builder.WriteString(sectionStyle.Render("Logs"))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
logLines := m.renderLogsAll()
|
||||||
|
if len(logLines) == 0 {
|
||||||
|
builder.WriteString(dimStyle.Render("No events yet."))
|
||||||
|
} else {
|
||||||
|
builder.WriteString(strings.Join(logLines, "\n"))
|
||||||
|
}
|
||||||
|
builder.WriteString("\n\n")
|
||||||
|
|
||||||
|
summaryTitle := renderSummaryTitle(m.summaryStreams)
|
||||||
|
visibility := summaryVisibility(summaryStatusAllDone())
|
||||||
|
accessible, others := partitionStreams(m.summaryStreams)
|
||||||
|
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
rows = []table.Row{emptySummaryRow()}
|
||||||
|
}
|
||||||
|
columns := summaryColumns(m.width, rows)
|
||||||
|
builder.WriteString(sectionStyle.Render(summaryTitle))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(renderSummaryTablePlain(columns, rows))
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelState) renderSteps() []string {
|
||||||
|
lines := []string{sectionStyle.Render("Steps"), renderProgress(m)}
|
||||||
|
spinnerView := m.spinner.View()
|
||||||
|
for _, step := range m.steps {
|
||||||
|
lines = append(lines, renderStep(step, m.status[step], spinnerView))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelState) renderLogs(height int) []string {
|
||||||
|
if height <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(m.logs) == 0 {
|
||||||
|
lines := []string{dimStyle.Render("No events yet.")}
|
||||||
|
return padLines(lines, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if len(m.logs) > height {
|
||||||
|
start = len(m.logs) - height
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, min(height, len(m.logs)))
|
||||||
|
for _, entry := range m.logs[start:] {
|
||||||
|
lines = append(lines, renderLog(entry))
|
||||||
|
}
|
||||||
|
return padLines(lines, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelState) renderLogsAll() []string {
|
||||||
|
if len(m.logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines := make([]string, 0, len(m.logs))
|
||||||
|
for _, entry := range m.logs {
|
||||||
|
lines = append(lines, renderLog(entry))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modelState) layoutHeights(headerLines, stepsLines int) (summaryHeight, logsHeight int) {
|
||||||
|
if m.height <= 0 {
|
||||||
|
return summaryMinHeight, len(m.logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
reserved := headerLines + 1 + stepsLines + 1 + 1 + 1
|
||||||
|
remaining := m.height - reserved
|
||||||
|
remaining = max(0, remaining)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case remaining < summaryMinHeight:
|
||||||
|
summaryHeight = max(3, remaining)
|
||||||
|
case remaining > summaryMaxHeight:
|
||||||
|
summaryHeight = summaryMaxHeight
|
||||||
|
default:
|
||||||
|
summaryHeight = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
logsHeight = max(0, remaining-summaryHeight)
|
||||||
|
|
||||||
|
return summaryHeight, logsHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func padLines(lines []string, height int) []string {
|
||||||
|
if height <= 0 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
for len(lines) < height {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(value string) []string {
|
||||||
|
return strings.Split(value, "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import "github.com/charmbracelet/lipgloss"
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
sectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
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"))
|
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||||
debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
debugStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||||
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||||
|
|||||||
@@ -119,10 +119,7 @@ func formatStream(stream cameradar.Stream) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatRTSPURL(stream cameradar.Stream) string {
|
func formatRTSPURL(stream cameradar.Stream) string {
|
||||||
path := stream.Route()
|
path := "/" + strings.TrimLeft(strings.TrimSpace(stream.Route()), "/")
|
||||||
if path != "" && !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
credentials := ""
|
credentials := ""
|
||||||
if stream.Username != "" || stream.Password != "" {
|
if stream.Username != "" || stream.Password != "" {
|
||||||
|
|||||||
+173
-17
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -58,21 +59,27 @@ type summaryMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type summaryTable struct {
|
type summaryTable struct {
|
||||||
title string
|
table table.Model
|
||||||
table table.Model
|
|
||||||
emptyMessage string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
summaryMinHeight = 8
|
||||||
|
summaryMaxHeight = 10
|
||||||
|
summaryColumnCount = 8
|
||||||
|
)
|
||||||
|
|
||||||
// TUIReporter renders a Bubble Tea based UI.
|
// TUIReporter renders a Bubble Tea based UI.
|
||||||
type TUIReporter struct {
|
type TUIReporter struct {
|
||||||
program *tea.Program
|
program *tea.Program
|
||||||
debug bool
|
debug bool
|
||||||
once sync.Once
|
once sync.Once
|
||||||
closed chan struct{}
|
closed chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
last []cameradar.Stream
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTUIReporter creates a new Bubble Tea reporter.
|
// NewTUIReporter creates a new Bubble Tea reporter.
|
||||||
func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
|
func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) {
|
||||||
spin := spinner.New()
|
spin := spinner.New()
|
||||||
spin.Spinner = spinner.Dot
|
spin.Spinner = spinner.Dot
|
||||||
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
||||||
@@ -88,12 +95,13 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
|
|||||||
steps: cameradar.Steps(),
|
steps: cameradar.Steps(),
|
||||||
status: make(map[cameradar.Step]state),
|
status: make(map[cameradar.Step]state),
|
||||||
debug: debug,
|
debug: debug,
|
||||||
|
buildInfo: buildInfo,
|
||||||
|
cancel: cancel,
|
||||||
spinner: spin,
|
spinner: spin,
|
||||||
progress: prog,
|
progress: prog,
|
||||||
progressTotals: make(map[cameradar.Step]int),
|
progressTotals: make(map[cameradar.Step]int),
|
||||||
progressCounts: 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())
|
p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen())
|
||||||
reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})}
|
reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})}
|
||||||
@@ -107,7 +115,19 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rendered, ok := model.(*modelState); ok {
|
if rendered, ok := model.(*modelState); ok {
|
||||||
_, _ = fmt.Fprintln(out, rendered.View())
|
output := rendered.FinalView()
|
||||||
|
if len(rendered.summaryStreams) == 0 {
|
||||||
|
fallback := reporter.snapshotSummary()
|
||||||
|
if len(fallback) > 0 {
|
||||||
|
tmp := &modelState{
|
||||||
|
summaryStreams: fallback,
|
||||||
|
width: rendered.width,
|
||||||
|
status: summaryStatusAllDone(),
|
||||||
|
}
|
||||||
|
output = tmp.FinalView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(out, output)
|
||||||
}
|
}
|
||||||
close(reporter.closed)
|
close(reporter.closed)
|
||||||
}()
|
}()
|
||||||
@@ -162,12 +182,28 @@ func (r *TUIReporter) Error(step cameradar.Step, err error) {
|
|||||||
|
|
||||||
// Summary implements Reporter.
|
// Summary implements Reporter.
|
||||||
func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) {
|
func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) {
|
||||||
r.send(summaryMsg{streams: copyStreams(streams), final: true})
|
cloned := copyStreams(streams)
|
||||||
|
r.recordSummary(cloned)
|
||||||
|
r.send(summaryMsg{streams: cloned, final: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSummary updates the summary section with partial results.
|
// UpdateSummary updates the summary section with partial results.
|
||||||
func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) {
|
func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) {
|
||||||
r.send(summaryMsg{streams: copyStreams(streams), final: false})
|
cloned := copyStreams(streams)
|
||||||
|
r.recordSummary(cloned)
|
||||||
|
r.send(summaryMsg{streams: cloned, final: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TUIReporter) recordSummary(streams []cameradar.Stream) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.last = streams
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TUIReporter) snapshotSummary() []cameradar.Stream {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return copyStreams(r.last)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close implements Reporter.
|
// Close implements Reporter.
|
||||||
@@ -343,33 +379,153 @@ func progressWidth(width int) int {
|
|||||||
return 36
|
return 36
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, final bool) []summaryTable {
|
func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, maxRows int) []summaryTable {
|
||||||
visibility := summaryVisibility(status)
|
visibility := summaryVisibility(status)
|
||||||
accessible, others := partitionStreams(streams)
|
accessible, others := partitionStreams(streams)
|
||||||
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
|
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
message := "Waiting for results..."
|
rows = []table.Row{emptySummaryRow()}
|
||||||
if final {
|
}
|
||||||
message = "No streams discovered."
|
|
||||||
}
|
if maxRows > 0 {
|
||||||
return []summaryTable{{title: "Streams", emptyMessage: message}}
|
switch {
|
||||||
|
case len(rows) > maxRows:
|
||||||
|
if maxRows == 1 {
|
||||||
|
rows = []table.Row{summaryOverflowRow(len(rows))}
|
||||||
|
} else {
|
||||||
|
visibleRows := maxRows - 1
|
||||||
|
hidden := len(rows) - visibleRows
|
||||||
|
rows = append(rows[:visibleRows], summaryOverflowRow(hidden))
|
||||||
|
}
|
||||||
|
case len(rows) < maxRows:
|
||||||
|
rows = padSummaryRows(rows, maxRows)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Streams (%d accessible / %d total)", len(accessible), len(streams))
|
|
||||||
columns := summaryColumns(width, rows)
|
columns := summaryColumns(width, rows)
|
||||||
model := table.New(
|
model := table.New(
|
||||||
table.WithColumns(columns),
|
table.WithColumns(columns),
|
||||||
table.WithRows(rows),
|
table.WithRows(rows),
|
||||||
table.WithFocused(false),
|
table.WithFocused(false),
|
||||||
table.WithHeight(len(rows)+1),
|
table.WithHeight(len(rows)),
|
||||||
)
|
)
|
||||||
model.SetStyles(summaryTableStyles())
|
model.SetStyles(summaryTableStyles())
|
||||||
|
|
||||||
return []summaryTable{{title: title, table: model}}
|
return []summaryTable{{table: model}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSummaryTitle(streams []cameradar.Stream) string {
|
||||||
|
accessible, _ := partitionStreams(streams)
|
||||||
|
return fmt.Sprintf("Summary - Streams (%d accessible / %d total)", len(accessible), len(streams))
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryStatusAllDone() map[cameradar.Step]state {
|
||||||
|
status := make(map[cameradar.Step]state)
|
||||||
|
for _, step := range cameradar.Steps() {
|
||||||
|
status[step] = stateDone
|
||||||
|
}
|
||||||
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyEntry = "—"
|
const emptyEntry = "—"
|
||||||
|
|
||||||
|
func emptySummaryRow() table.Row {
|
||||||
|
row := make(table.Row, summaryColumnCount)
|
||||||
|
for i := range row {
|
||||||
|
row[i] = emptyEntry
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func padSummaryRows(rows []table.Row, maxRows int) []table.Row {
|
||||||
|
for len(rows) < maxRows {
|
||||||
|
rows = append(rows, emptySummaryRow())
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryOverflowRow(hidden int) table.Row {
|
||||||
|
row := emptySummaryRow()
|
||||||
|
if hidden <= 0 {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
label := "\u2026 1 more stream"
|
||||||
|
if hidden > 1 {
|
||||||
|
label = fmt.Sprintf("\u2026 %d more streams", hidden)
|
||||||
|
}
|
||||||
|
row[0] = label
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSummaryTablePlain(columns []table.Column, rows []table.Row) string {
|
||||||
|
colWidths := make([]int, len(columns))
|
||||||
|
for i, col := range columns {
|
||||||
|
colWidths[i] = max(col.Width, len([]rune(col.Title)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString(renderSummaryBorder("┌", "┬", "┐", colWidths))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(renderSummaryRow(columnTitles(columns), colWidths))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(renderSummaryBorder("├", "┼", "┤", colWidths))
|
||||||
|
for _, row := range rows {
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(renderSummaryRow(row, colWidths))
|
||||||
|
}
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(renderSummaryBorder("└", "┴", "┘", colWidths))
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSummaryBorder(left, middle, right string, widths []int) string {
|
||||||
|
parts := make([]string, 0, len(widths))
|
||||||
|
for _, width := range widths {
|
||||||
|
parts = append(parts, strings.Repeat("─", width+2))
|
||||||
|
}
|
||||||
|
return left + strings.Join(parts, middle) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSummaryRow(cells []string, widths []int) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("│")
|
||||||
|
for i, width := range widths {
|
||||||
|
value := ""
|
||||||
|
if i < len(cells) {
|
||||||
|
value = cells[i]
|
||||||
|
}
|
||||||
|
builder.WriteString(" ")
|
||||||
|
builder.WriteString(padAndTrim(value, width))
|
||||||
|
builder.WriteString(" │")
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func padAndTrim(value string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(value)
|
||||||
|
if len(runes) > width {
|
||||||
|
return string(runes[:width])
|
||||||
|
}
|
||||||
|
if len(runes) < width {
|
||||||
|
return string(runes) + strings.Repeat(" ", width-len(runes))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnTitles(columns []table.Column) []string {
|
||||||
|
if len(columns) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
titles := make([]string, len(columns))
|
||||||
|
for i, col := range columns {
|
||||||
|
titles[i] = col.Title
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row {
|
func buildSummaryRows(streams []cameradar.Stream, visibility summaryVisibilityState) []table.Row {
|
||||||
rows := make([]table.Row, 0, len(streams))
|
rows := make([]table.Row, 0, len(streams))
|
||||||
for _, stream := range streams {
|
for _, stream := range streams {
|
||||||
|
|||||||
Reference in New Issue
Block a user