Compare commits

...

20 Commits

Author SHA1 Message Date
Brendan Le Glaunec cc6bc4d19e fix: command/flags to prevent subcommand being required 2026-03-04 18:55:02 +01:00
whiteboxsolutions 21a35a8b48 Merge pull request #409 from Ullaakut/dependabot/go_modules/all-4cffc61c5f
Bump the all group with 2 updates
2026-03-02 22:41:04 -08:00
whiteboxsolutions 0065db672c Merge pull request #408 from Ullaakut/improve-contributing-guide
docs: more precisions in contributing guide
2026-03-02 14:41:04 -08:00
dependabot[bot] ac8a77e539 Bump the all group with 2 updates
Bumps the all group with 2 updates: [github.com/bluenviron/gortsplib/v5](https://github.com/bluenviron/gortsplib) and [github.com/urfave/cli/v3](https://github.com/urfave/cli).


Updates `github.com/bluenviron/gortsplib/v5` from 5.3.2 to 5.4.0
- [Commits](https://github.com/bluenviron/gortsplib/compare/v5.3.2...v5.4.0)

Updates `github.com/urfave/cli/v3` from 3.6.2 to 3.7.0
- [Release notes](https://github.com/urfave/cli/releases)
- [Changelog](https://github.com/urfave/cli/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/urfave/cli/compare/v3.6.2...v3.7.0)

---
updated-dependencies:
- dependency-name: github.com/bluenviron/gortsplib/v5
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
- dependency-name: github.com/urfave/cli/v3
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 21:39:54 +00:00
Brendan Le Glaunec 8956d5bc53 docs: more precisions in contributing guide 2026-03-02 19:57:19 +01:00
Brendan Le Glaunec 40f41c3028 feat: add codeowners, fix typos, fix license badge (#407) 2026-03-02 10:53:51 +01:00
dependabot[bot] 5bb789333d Bump goreleaser/goreleaser-action from 6 to 7 in the all group (#402)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 21:00:23 +01:00
dependabot[bot] 9457911e4a Bump go.opentelemetry.io/otel/sdk from 1.38.0 to 1.40.0 (#404)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 21:00:08 +01:00
dependabot[bot] aa8c6fbd90 Bump the all group with 4 updates (#401)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 20:59:09 +01:00
Brendan Le Glaunec c37d584aa2 feat: add masscan discovery backend (#403) 2026-02-28 20:51:47 +01:00
Brendan Le Glaunec fd0d948c16 chore: clean up instructions 2026-02-28 09:20:00 +01:00
Brendan Le Glaunec 7bd7460b5b fix: link to code of conduct 2026-02-03 12:28:30 +01:00
Brendan Le Glaunec 69f4fb418a fix: remove unsupported backticks from bug report template 2026-02-03 12:26:34 +01:00
Brendan Le Glaunec 18ffb7af61 feat: add version subcommand to help with issues (#400) 2026-02-03 12:15:30 +01:00
Brendan Le Glaunec c11e3217ea feat: tui mode improvements (#395) 2026-02-03 10:19:11 +01:00
Brendan Le Glaunec d16443109a fix: always add leading slash to routes (#397) 2026-02-03 10:12:23 +01:00
dependabot[bot] f93f9c9780 Bump the all group with 4 updates (#398)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 10:09:56 +01:00
dependabot[bot] 55d11e2887 Bump the all group with 3 updates (#399)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 10:08:44 +01:00
Brendan Le Glaunec f192139cc3 feat: small UI improvements (#394) 2026-02-01 22:30:55 +01:00
Brendan Le Glaunec af41fc6cb8 fix: no longer give up on detecting auth type when getting a 401 (#391) 2026-02-01 20:59:26 +01:00
44 changed files with 2084 additions and 430 deletions
+2
View File
@@ -0,0 +1,2 @@
*.go @Ullaakut @whiteboxsolutions @nblair2
*.md @Ullaakut @whiteboxsolutions @nblair2
-57
View File
@@ -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>
```
+103
View File
@@ -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
+5
View File
@@ -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 -4
View File
@@ -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. -->
+5 -36
View File
@@ -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 youre 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
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -46,21 +46,21 @@ archives:
dockers: dockers:
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: amd64 goarch: amd64
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:latest-386" - "ullaakut/{{ .ProjectName }}:latest-386"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
goos: linux goos: linux
goarch: 386 goarch: 386
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:latest-armv6" - "ullaakut/{{ .ProjectName }}:latest-armv6"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -68,7 +68,7 @@ dockers:
goarch: arm goarch: arm
goarm: 6 goarm: 6
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:latest-armv7" - "ullaakut/{{ .ProjectName }}:latest-armv7"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -76,7 +76,7 @@ dockers:
goarch: arm goarch: arm
goarm: 7 goarm: 7
- image_templates: - image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- "ullaakut/{{ .ProjectName }}:latest-arm64" - "ullaakut/{{ .ProjectName }}:latest-arm64"
dockerfile: Dockerfile dockerfile: Dockerfile
use: buildx use: buildx
@@ -84,13 +84,13 @@ dockers:
goarch: arm64 goarch: arm64
docker_manifests: docker_manifests:
- name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}" - name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7"
- "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64"
- name_template: "ullaakut/{{ .ProjectName }}:latest" - name_template: "ullaakut/{{ .ProjectName }}:latest"
image_templates: image_templates:
- "ullaakut/{{ .ProjectName }}:latest-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64"
+128
View File
@@ -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.
+26 -1
View File
@@ -22,13 +22,37 @@ make test
## Formatting and linting ## Formatting and linting
Run `gofmt` on changed files.
Keep code idiomatic and consistent with existing style. Keep code idiomatic and consistent with existing style.
By default, follow the [Uber Go Style Guide](https://github.com/uber-go/guide) and the guidelines from [Effective Go](https://go.dev/doc/effective_go).
```bash ```bash
make fmt make fmt
``` ```
### Dependency for linting
* golangci-lint
* see current version defined in `.github/workflows/test.yaml` at `jobs.tests.steps.["Run linter"]`
* configured in `.golangci.yml`
```bash
make lint
```
## Commit messages and PR titles
Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages and pull request titles.
- Use the format: `type: subject`
- Write the subject in imperative mood: `add`, `update`, `remove`, `fix`, `refactor`
- Do not use gerunds in subjects: avoid `adding`, `updating`, `removing`
Examples:
- `feat: add RTSP timeout flag`
- `fix: remove duplicate progress line`
- `docs: update commit message guidelines`
## Reporting issues ## Reporting issues
Use the issue template in [.github/ISSUE_TEMPLATE.md](.github/ISSUE_TEMPLATE.md). Use the issue template in [.github/ISSUE_TEMPLATE.md](.github/ISSUE_TEMPLATE.md).
@@ -43,3 +67,4 @@ Only scan authorized targets.
4. Add or update tests when possible. 4. Add or update tests when possible.
5. Ensure `make test` passes. 5. Ensure `make test` passes.
6. Try to bring as much test coverage as possible with your changes. 6. Try to bring as much test coverage as possible with your changes.
7. Use a Conventional Commit-style PR title with an imperative subject.
+4 -1
View File
@@ -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
+47 -4
View File
@@ -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
+75 -1
View File
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/attack" "github.com/Ullaakut/cameradar/v6/internal/attack"
@@ -17,7 +19,11 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
//nolint:cyclop // Splitting this function does not make it clearer.
func runCameradar(ctx context.Context, cmd *cli.Command) error { func runCameradar(ctx context.Context, cmd *cli.Command) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
targetInputs := cmd.StringSlice(flagTargets) targetInputs := cmd.StringSlice(flagTargets)
if len(targetInputs) == 0 { if len(targetInputs) == 0 {
return errors.New("at least one target must be specified") return errors.New("at least one target must be specified")
@@ -60,10 +66,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
+24 -2
View File
@@ -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{
@@ -36,7 +42,6 @@ var flags = cmd.Flags{
Usage: "The targets on which to scan for open RTSP streams in a network range format", Usage: "The targets on which to scan for open RTSP streams in a network range format",
Aliases: []string{"t"}, Aliases: []string{"t"},
Sources: cli.EnvVars(strcase.ToSNAKE(flagTargets)), Sources: cli.EnvVars(strcase.ToSNAKE(flagTargets)),
Required: true,
}, },
&cli.StringSliceFlag{ &cli.StringSliceFlag{
Name: flagPorts, Name: flagPorts,
@@ -57,6 +62,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)",
@@ -119,8 +130,16 @@ func realMain() (code int) {
app := &cli.Command{ app := &cli.Command{
Name: "Cameradar", Name: "Cameradar",
Version: version, Version: version,
Usage: "Scan targets for RTSP streams",
Flags: flags, Flags: flags,
Action: runCameradar, Action: runCameradar,
Commands: []*cli.Command{
{
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 +147,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
} }
+49
View File
@@ -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]
}
+49 -44
View File
@@ -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.4.0
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.7.0
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.1 // 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.51.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
) )
+114 -103
View File
@@ -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.4.0 h1:xi9G4NU67+5uNxGZzJP87SwyaWKr+rUAzbIkOE2SQBo=
github.com/bluenviron/gortsplib/v5 v5.2.2/go.mod h1:xkVBOAnR4fzaerPN650CBb7N+zUUsj7PI2HiY1TP7Co= github.com/bluenviron/gortsplib/v5 v5.4.0/go.mod h1:+vGoi2RqF8LA7ktls7nC0JIF3DmOHwj0448kdQGYBEQ=
github.com/bluenviron/mediacommon/v2 v2.6.0 h1:wZAPXwv7V78Cx2x7cToYIHOLToHl6APcvHbdQT+gOkg= github.com/bluenviron/mediacommon/v2 v2.8.1 h1:UfR+AxqpL9fl5+KeT5BGklBfWgKS0OaSA7LsL8eVYS8=
github.com/bluenviron/mediacommon/v2 v2.6.0/go.mod h1:5V15TiOfeaNVmZPVuOqAwqQSWyvMV86/dijDKu5q9Zs= github.com/bluenviron/mediacommon/v2 v2.8.1/go.mod h1:4AsE74EnTxkHeUs1VMER31fivU0jufZUAepaKFRV1lM=
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.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= github.com/urfave/cli/v3 v3.7.0/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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
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
View File
@@ -7,12 +7,14 @@ import (
"time" "time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base" "github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/liberrors" "github.com/bluenviron/gortsplib/v5/pkg/liberrors"
) )
// Route that should never be a constructor default. // Route that should never be a constructor default.
const dummyRoute = "/0x8b6c42" const dummyRoute = "0x8b6c42"
// Dictionary provides dictionaries for routes, usernames and passwords. // Dictionary provides dictionaries for routes, usernames and passwords.
type Dictionary interface { type Dictionary interface {
@@ -187,6 +189,7 @@ func (a Attacker) reattackRoutes(ctx context.Context, streams []cameradar.Stream
func needsReattack(streams []cameradar.Stream) bool { func needsReattack(streams []cameradar.Stream) bool {
for _, stream := range streams { for _, stream := range streams {
if stream.RouteFound && stream.CredentialsFound && stream.Available { if stream.RouteFound && stream.CredentialsFound && stream.Available {
// This stream is fully discovered, no need to re-attack.
continue continue
} }
return true return true
@@ -257,7 +260,7 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
} }
if ok { if ok {
target.RouteFound = true target.RouteFound = true
target.Routes = append(target.Routes, "/") target.Routes = append(target.Routes, "") // Add empty route for default.
a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port)) a.reporter.Progress(cameradar.StepAttackRoutes, fmt.Sprintf("Default route accepted for %s:%d", target.Address.String(), target.Port))
return target, nil return target, nil
} }
@@ -287,67 +290,6 @@ func (a Attacker) attackRoutesForStream(ctx context.Context, target cameradar.St
return target, nil return target, nil
} }
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
default:
return streams, fmt.Errorf("unknown authentication method %d for %s:%d", streams[i].AuthenticationType, streams[i].Address.String(), streams[i].Port)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
client, err := a.newRTSPClient(u)
if err != nil {
return stream, fmt.Errorf("starting rtsp client: %w", err)
}
defer client.Close()
_, res, err := client.Describe(u)
if err != nil {
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil && badStatus.Code == base.StatusUnauthorized {
stream.AuthenticationType = authTypeFromHeaders(res.Header["WWW-Authenticate"])
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
return stream, nil
}
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
if res != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, res.StatusCode))
}
stream.AuthenticationType = cameradar.AuthNone
return stream, nil
}
func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) { func (a Attacker) routeAttack(stream cameradar.Stream, route string) (bool, error) {
u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password) u, urlStr, err := buildRTSPURL(stream, route, stream.Username, stream.Password)
if err != nil { if err != nil {
@@ -399,7 +341,7 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
} }
defer client.Close() defer client.Close()
desc, res, err := client.Describe(u) desc, res, err := a.describeWithRetry(ctx, client, u, urlStr)
if err != nil { if err != nil {
return a.handleDescribeError(stream, urlStr, err) return a.handleDescribeError(stream, urlStr, err)
} }
@@ -413,7 +355,6 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
if err != nil { if err != nil {
return a.handleSetupError(stream, urlStr, err) return a.handleSetupError(stream, urlStr, err)
} }
a.logSetupResponse(urlStr, res) a.logSetupResponse(urlStr, res)
stream.Available = res != nil && res.StatusCode == base.StatusOK stream.Available = res != nil && res.StatusCode == base.StatusOK
@@ -424,9 +365,39 @@ func (a Attacker) validateStream(ctx context.Context, stream cameradar.Stream, e
return stream, nil return stream, nil
} }
func (a Attacker) describeWithRetry(ctx context.Context, client *gortsplib.Client, u *base.URL, urlStr string) (*description.Session, *base.Response, error) {
var (
desc *description.Session
res *base.Response
err error
)
for range 5 {
desc, res, err = client.Describe(u)
if err == nil {
return desc, res, nil
}
var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d (retrying)", urlStr, badStatus.Code))
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(time.Second):
}
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("describe retries exhausted for %q: %w", urlStr, err)
}
func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) { func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, err error) (cameradar.Stream, error) {
var badStatus liberrors.ErrClientBadStatusCode var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable { if errors.As(err, &badStatus) && badStatus.Code == base.StatusServiceUnavailable {
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, badStatus.Code))
a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)", a.reporter.Progress(cameradar.StepValidateStreams, fmt.Sprintf("Stream unavailable for %s:%d (RTSP %d)",
stream.Address.String(), stream.Address.String(),
stream.Port, stream.Port,
@@ -436,6 +407,8 @@ func (a Attacker) handleDescribeError(stream cameradar.Stream, urlStr string, er
return stream, nil return stream, nil
} }
a.reporter.Debug(cameradar.StepValidateStreams, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err) return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
} }
+2 -2
View File
@@ -214,7 +214,7 @@ func TestAttacker_Attack_ReturnsErrorWhenRouteMissing(t *testing.T) {
got, err := attacker.Attack(t.Context(), streams) got, err := attacker.Attack(t.Context(), streams)
require.Error(t, err) require.Error(t, err)
assert.ErrorContains(t, err, "detecting authentication methods") assert.ErrorContains(t, err, "validating streams")
require.Len(t, got, 1) require.Len(t, got, 1)
assert.False(t, got[0].RouteFound) assert.False(t, got[0].RouteFound)
} }
@@ -304,7 +304,7 @@ func TestAttacker_Attack_AllowsDummyRoute(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Len(t, got, 1) require.Len(t, got, 1)
assert.True(t, got[0].RouteFound) assert.True(t, got[0].RouteFound)
assert.Equal(t, []string{"/"}, got[0].Routes) assert.Equal(t, []string{""}, got[0].Routes)
assert.True(t, got[0].Available) assert.True(t, got[0].Available)
} }
+68
View File
@@ -0,0 +1,68 @@
package attack
import (
"context"
"fmt"
"github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5/pkg/base"
)
func (a Attacker) detectAuthMethods(ctx context.Context, targets []cameradar.Stream) ([]cameradar.Stream, error) {
streams, err := runParallel(ctx, targets, a.detectAuthMethod)
if err != nil {
return streams, err
}
for i := range streams {
a.reporter.Progress(cameradar.StepDetectAuth, cameradar.ProgressTickMessage())
var authMethod string
switch streams[i].AuthenticationType {
case cameradar.AuthNone:
authMethod = "no"
case cameradar.AuthBasic:
authMethod = "basic"
case cameradar.AuthDigest:
authMethod = "digest"
case cameradar.AuthUnknown:
authMethod = "unknown"
default:
authMethod = fmt.Sprintf("unknown (%d)", streams[i].AuthenticationType)
}
a.reporter.Progress(cameradar.StepDetectAuth, fmt.Sprintf("Detected %s authentication for %s:%d", authMethod, streams[i].Address.String(), streams[i].Port))
}
return streams, nil
}
func (a Attacker) detectAuthMethod(ctx context.Context, stream cameradar.Stream) (cameradar.Stream, error) {
if ctx.Err() != nil {
return stream, ctx.Err()
}
u, urlStr, err := buildRTSPURL(stream, stream.Route(), "", "")
if err != nil {
return stream, fmt.Errorf("building rtsp url: %w", err)
}
statusCode, headers, err := a.probeDescribeHeaders(ctx, u, urlStr)
if err != nil {
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > error: %v", urlStr, err))
stream.AuthenticationType = cameradar.AuthUnknown
return stream, fmt.Errorf("performing describe request at %q: %w", urlStr, err)
}
a.reporter.Debug(cameradar.StepDetectAuth, fmt.Sprintf("DESCRIBE %s RTSP/1.0 > %d", urlStr, statusCode))
values := headerValues(headers, "WWW-Authenticate")
switch statusCode {
case base.StatusOK:
stream.AuthenticationType = cameradar.AuthNone
case base.StatusUnauthorized:
stream.AuthenticationType = authTypeFromHeaders(values)
default:
stream.AuthenticationType = cameradar.AuthUnknown
}
return stream, nil
}
+207
View File
@@ -0,0 +1,207 @@
package attack
import (
"bufio"
"fmt"
"net"
"net/netip"
"strings"
"testing"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type testDictionary struct {
routes []string
usernames []string
passwords []string
}
func (d testDictionary) Routes() []string {
return d.routes
}
func (d testDictionary) Usernames() []string {
return d.usernames
}
func (d testDictionary) Passwords() []string {
return d.passwords
}
func TestAuthTypeFromHeaders(t *testing.T) {
tests := []struct {
name string
values base.HeaderValue
want cameradar.AuthType
}{
{
name: "digest wins over basic",
values: base.HeaderValue{
headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal()[0],
headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal()[0],
},
want: cameradar.AuthDigest,
},
{
name: "basic auth",
values: headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
want: cameradar.AuthBasic,
},
{
name: "digest auth",
values: headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
want: cameradar.AuthDigest,
},
{
name: "unknown with empty values",
values: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown with unsupported header",
values: base.HeaderValue{"Bearer abc"},
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, authTypeFromHeaders(test.values))
})
}
}
func TestDetectAuthMethod(t *testing.T) {
tests := []struct {
name string
statusCode base.StatusCode
headers base.Header
want cameradar.AuthType
}{
{
name: "no auth when status ok",
statusCode: base.StatusOK,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthNone,
},
{
name: "basic auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodBasic, Realm: "cam"}.Marshal(),
},
want: cameradar.AuthBasic,
},
{
name: "digest auth on unauthorized",
statusCode: base.StatusUnauthorized,
headers: base.Header{
"WWW-Authenticate": headers.Authenticate{Method: headers.AuthMethodDigest, Realm: "cam", Nonce: "nonce"}.Marshal(),
},
want: cameradar.AuthDigest,
},
{
name: "unknown auth on unauthorized without www-authenticate",
statusCode: base.StatusUnauthorized,
headers: nil,
want: cameradar.AuthUnknown,
},
{
name: "unknown auth on other status",
statusCode: base.StatusNotFound,
headers: nil,
want: cameradar.AuthUnknown,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, port := startRTSPProbeServer(t, test.statusCode, test.headers)
attacker, err := New(testDictionary{}, 0, time.Second, ui.NopReporter{})
require.NoError(t, err)
stream := cameradar.Stream{
Address: addr,
Port: port,
}
got, err := attacker.detectAuthMethod(t.Context(), stream)
require.NoError(t, err)
assert.Equal(t, test.want, got.AuthenticationType)
})
}
}
func startRTSPProbeServer(t *testing.T, statusCode base.StatusCode, headers base.Header) (netip.Addr, uint16) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(time.Second))
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(line) == "" {
break
}
}
statusText := statusTextFromCode(statusCode)
var builder strings.Builder
_, _ = fmt.Fprintf(&builder, "RTSP/1.0 %d %s\r\n", statusCode, statusText)
builder.WriteString("CSeq: 1\r\n")
for key, values := range headers {
for _, value := range values {
_, _ = fmt.Fprintf(&builder, "%s: %s\r\n", key, value)
}
}
builder.WriteString("Content-Length: 0\r\n\r\n")
_, _ = conn.Write([]byte(builder.String()))
}()
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
return netip.MustParseAddr("127.0.0.1"), uint16(tcpAddr.Port)
}
func statusTextFromCode(code base.StatusCode) string {
switch code {
case base.StatusOK:
return "OK"
case base.StatusUnauthorized:
return "Unauthorized"
case base.StatusNotFound:
return "Not Found"
default:
return "Unknown"
}
}
+85 -7
View File
@@ -1,10 +1,16 @@
package attack package attack
import ( import (
"bufio"
"context"
"errors" "errors"
"fmt"
"net" "net"
"net/textproto"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time"
"github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6"
"github.com/bluenviron/gortsplib/v5" "github.com/bluenviron/gortsplib/v5"
@@ -39,7 +45,7 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
_, res, err := client.Describe(u) _, res, err := client.Describe(u)
if err != nil { if err != nil {
var badStatus liberrors.ErrClientBadStatusCode var badStatus liberrors.ErrClientBadStatusCode
if errors.As(err, &badStatus) && res != nil { if errors.As(err, &badStatus) {
return badStatus.Code, nil return badStatus.Code, nil
} }
return 0, err return 0, err
@@ -51,9 +57,69 @@ func (a Attacker) describeStatus(u *base.URL) (base.StatusCode, error) {
return res.StatusCode, nil return res.StatusCode, nil
} }
// probeDescribeHeaders performs a manual DESCRIBE request and returns the status code and headers.
//
// NOTE: We do not use gortsplib here because it does not expose response headers when the status code is 401 Unauthorized,
// which is exactly what we need in order to detect authentication methods.
func (a Attacker) probeDescribeHeaders(ctx context.Context, u *base.URL, urlStr string) (base.StatusCode, base.Header, error) {
dialer := &net.Dialer{Timeout: a.timeout}
conn, err := dialer.DialContext(ctx, "tcp", u.Host)
if err != nil {
return 0, nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(a.timeout)
}
err = conn.SetDeadline(deadline)
if err != nil {
return 0, nil, err
}
request := fmt.Sprintf(
"DESCRIBE %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: cameradar\r\nAccept: application/sdp\r\nHost: %s\r\n\r\n",
urlStr,
u.Host,
)
_, err = conn.Write([]byte(request))
if err != nil {
return 0, nil, err
}
reader := textproto.NewReader(bufio.NewReader(conn))
statusLine, err := reader.ReadLine()
if err != nil {
return 0, nil, err
}
fields := strings.Fields(statusLine)
if len(fields) < 2 {
return 0, nil, fmt.Errorf("invalid RTSP status line: %q", statusLine)
}
code, err := strconv.Atoi(fields[1])
if err != nil {
return 0, nil, fmt.Errorf("parsing RTSP status code %q: %w", fields[1], err)
}
mimeHeader, err := reader.ReadMIMEHeader()
if err != nil {
return 0, nil, err
}
headers := make(base.Header)
for key, values := range mimeHeader {
headers[key] = append(base.HeaderValue(nil), values...)
}
return base.StatusCode(code), headers, nil
}
func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType { func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
if len(values) == 0 { if len(values) == 0 {
return cameradar.AuthNone return cameradar.AuthUnknown
} }
var hasBasic bool var hasBasic bool
@@ -63,6 +129,9 @@ func authTypeFromHeaders(values base.HeaderValue) cameradar.AuthType {
var authHeader headers.Authenticate var authHeader headers.Authenticate
err := authHeader.Unmarshal(base.HeaderValue{value}) err := authHeader.Unmarshal(base.HeaderValue{value})
if err != nil { if err != nil {
lower := strings.ToLower(value)
hasDigest = hasDigest || strings.Contains(lower, "digest")
hasBasic = hasBasic || strings.Contains(lower, "basic")
continue continue
} }
@@ -80,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",
+86
View File
@@ -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)
})
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
/live/ch01_0
live/ch01_0
0/1:1/main 0/1:1/main
0/usrnm:pwd/main 0/usrnm:pwd/main
0/video1 0/video1
+1 -4
View File
@@ -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 != "") {
+23
View File
@@ -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
} }
scanner := strings.ToLower(strings.TrimSpace(config.Scanner))
if scanner == "" {
scanner = ScannerNmap
}
switch scanner {
case ScannerNmap:
return nmap.New(config.ScanSpeed, expandedTargets, config.Ports, reporter) 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)
}
} }
+28
View File
@@ -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)
}
+109
View File
@@ -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)
}
+133
View File
@@ -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() {}
+48
View File
@@ -0,0 +1,48 @@
package ui
import "strings"
// BuildInfo represents build metadata injected at link time.
type BuildInfo struct {
Version string
Commit string
Date string
}
// DisplayVersion returns the version prefixed with "v" when needed.
func (b BuildInfo) DisplayVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
version = "dev"
}
if strings.HasPrefix(version, "v") {
return version
}
return "v" + version
}
// LogVersion returns the version without a leading "v".
func (b BuildInfo) LogVersion() string {
version := strings.TrimSpace(b.Version)
if version == "" {
return "dev"
}
return strings.TrimPrefix(version, "v")
}
// ShortCommit returns a shortened commit hash suitable for display.
func (b BuildInfo) ShortCommit() string {
commit := strings.TrimSpace(b.Commit)
if commit == "" || commit == "none" || commit == "unknown" {
return "unknown"
}
if len(commit) > 7 {
return commit[:7]
}
return commit
}
// TUIHeader returns the header used by the TUI.
func (b BuildInfo) TUIHeader() string {
return "Cameradar — " + b.DisplayVersion() + " (" + b.ShortCommit() + ")"
}
+175
View File
@@ -0,0 +1,175 @@
package ui_test
import (
"testing"
"github.com/Ullaakut/cameradar/v6/internal/ui"
"github.com/stretchr/testify/assert"
)
func TestBuildInfo_DisplayVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev with prefix",
version: "",
want: "vdev",
},
{
name: "dev without prefix",
version: "dev",
want: "vdev",
},
{
name: "already prefixed",
version: "v1.2.3",
want: "v1.2.3",
},
{
name: "adds prefix",
version: "1.2.3",
want: "v1.2.3",
},
{
name: "trims spaces with prefix",
version: " v2.0 ",
want: "v2.0",
},
{
name: "trims spaces without prefix",
version: " 2.0 ",
want: "v2.0",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.DisplayVersion())
})
}
}
func TestBuildInfo_LogVersion(t *testing.T) {
tests := []struct {
name string
version string
want string
}{
{
name: "empty defaults to dev",
version: "",
want: "dev",
},
{
name: "removes leading v",
version: "v1.2.3",
want: "1.2.3",
},
{
name: "keeps version without prefix",
version: "1.2.3",
want: "1.2.3",
},
{
name: "trims spaces and removes prefix",
version: " v2.0 ",
want: "2.0",
},
{
name: "removes only first prefix",
version: "vv1",
want: "v1",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version}
assert.Equal(t, test.want, info.LogVersion())
})
}
}
func TestBuildInfo_ShortCommit(t *testing.T) {
tests := []struct {
name string
commit string
want string
}{
{
name: "empty defaults to unknown",
commit: "",
want: "unknown",
},
{
name: "none defaults to unknown",
commit: "none",
want: "unknown",
},
{
name: "unknown defaults to unknown",
commit: "unknown",
want: "unknown",
},
{
name: "short commit preserved",
commit: "abcdef",
want: "abcdef",
},
{
name: "seven chars preserved",
commit: "abcdefg",
want: "abcdefg",
},
{
name: "long commit shortened",
commit: "abcdefghi",
want: "abcdefg",
},
{
name: "trims spaces before shortening",
commit: " 1234567890 ",
want: "1234567",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Commit: test.commit}
assert.Equal(t, test.want, info.ShortCommit())
})
}
}
func TestBuildInfo_TUIHeader(t *testing.T) {
tests := []struct {
name string
version string
commit string
want string
}{
{
name: "uses display version and short commit",
version: "1.2.3",
commit: "abcdefghi",
want: "Cameradar — v1.2.3 (abcdefg)",
},
{
name: "uses defaults for empty values",
version: "",
commit: "",
want: "Cameradar — vdev (unknown)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info := ui.BuildInfo{Version: test.version, Commit: test.commit}
assert.Equal(t, test.want, info.TUIHeader())
})
}
}
+33 -4
View File
@@ -22,9 +22,22 @@ func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
} }
} }
// PrintStartup prints build metadata and configuration options.
func (r *PlainReporter) PrintStartup(buildInfo BuildInfo, options []string) {
step := cameradar.Step("Startup")
message := fmt.Sprintf("Running cameradar version %s, commit %s", buildInfo.LogVersion(), buildInfo.ShortCommit())
r.print(step, "INFO", message)
if len(options) == 0 {
return
}
for _, option := range options {
r.print(step, "INFO", option)
}
}
// Start prints the beginning of a step. // Start prints the beginning of a step.
func (r *PlainReporter) Start(step cameradar.Step, message string) { func (r *PlainReporter) Start(step cameradar.Step, message string) {
r.print(step, "START", message) r.print(step, "STEP", message)
} }
// Done prints the completion of a step. // Done prints the completion of a step.
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
if !r.debug { if !r.debug {
return return
} }
r.print(step, "DEBUG", message) r.print(step, "DBUG", message)
} }
// Error prints an error message. // Error prints an error message.
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
if err == nil { if err == nil {
return return
} }
r.print(step, "ERROR", err.Error()) r.print(step, "EROR", err.Error())
} }
// Summary prints the final summary. // Summary prints the final summary.
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
return return
} }
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339)) level = normalizeLevel(level)
_, _ = fmt.Fprintf(r.out, "%s [%s] %s: %s\n", time.Now().Format(time.RFC3339), level, cameradar.StepLabel(step), message)
}
func normalizeLevel(level string) string {
switch level {
case "DEBUG":
return "DBUG"
case "ERROR":
return "EROR"
case "START", "STEP":
return "STEP"
}
if len(level) >= 4 {
return level[:4]
}
return fmt.Sprintf("%-4s", level)
} }
+34 -6
View File
@@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Summary([]cameradar.Stream{}, nil) reporter.Summary([]cameradar.Stream{}, nil)
content := out.String() content := out.String()
assert.Contains(t, content, "[START] Scan targets: starting") assert.Contains(t, content, " [STEP] Scan targets: starting")
assert.Contains(t, content, "[INFO] Scan targets: working") assert.Contains(t, content, " [INFO] Scan targets: working")
assert.Contains(t, content, "[DEBUG] Scan targets: details") assert.Contains(t, content, " [DBUG] Scan targets: details")
assert.Contains(t, content, "[DONE] Scan targets: finished") assert.Contains(t, content, " [DONE] Scan targets: finished")
assert.Contains(t, content, "[ERROR] Scan targets: boom") assert.Contains(t, content, " [EROR] Scan targets: boom")
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0") assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
}) })
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
reporter.Error(cameradar.StepScan, nil) reporter.Error(cameradar.StepScan, nil)
content := out.String() content := out.String()
assert.NotContains(t, content, "DEBUG") assert.NotContains(t, content, "DBUG")
assert.Equal(t, "", strings.TrimSpace(content)) assert.Equal(t, "", strings.TrimSpace(content))
}) })
} }
func TestPlainReporter_PrintStartup(t *testing.T) {
t.Run("prints build info and options", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "v1.2.3", Commit: "abcdefghi"}, []string{
"targets: 127.0.0.1",
"ports: 554",
})
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version 1.2.3, commit abcdefg")
assert.Contains(t, content, " [INFO] Startup: targets: 127.0.0.1")
assert.Contains(t, content, " [INFO] Startup: ports: 554")
})
t.Run("prints only build info when options empty", func(t *testing.T) {
out := &bytes.Buffer{}
reporter := ui.NewPlainReporter(out, false)
reporter.PrintStartup(ui.BuildInfo{Version: "", Commit: "none"}, nil)
content := out.String()
assert.Contains(t, content, " [INFO] Startup: Running cameradar version dev, commit unknown")
assert.Equal(t, 1, strings.Count(content, " Startup: "))
})
}
+4 -3
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -20,7 +21,7 @@ type Reporter interface {
} }
// NewReporter creates a Reporter based on the requested mode. // NewReporter creates a Reporter based on the requested mode.
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) { func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool, buildInfo BuildInfo, cancel context.CancelFunc) (Reporter, error) {
if debug { if debug {
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
} }
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
if !interactive { if !interactive {
return nil, errors.New("tui mode requires an interactive terminal") return nil, errors.New("tui mode requires an interactive terminal")
} }
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
case cameradar.ModeAuto: case cameradar.ModeAuto:
if interactive { if interactive {
return NewTUIReporter(debug, out) return NewTUIReporter(debug, out, buildInfo, cancel)
} }
return NewPlainReporter(out, debug), nil return NewPlainReporter(out, debug), nil
default: default:
+1 -1
View File
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive) reporter, err := ui.NewReporter(test.mode, false, out, test.interactive, ui.BuildInfo{Version: "dev", Commit: "none"}, func() {})
if test.wantErrContains != "" { if test.wantErrContains != "" {
require.Error(t, err) require.Error(t, err)
+143 -37
View File
@@ -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")
for i, summary := range m.summary {
if summary.title != "" {
builder.WriteString(subsectionStyle.Render(summary.title))
builder.WriteString("\n")
}
if summary.emptyMessage != "" {
builder.WriteString(dimStyle.Render(summary.emptyMessage))
builder.WriteString("\n")
} else {
builder.WriteString(summaryTableStyle.Render(summary.table.View())) builder.WriteString(summaryTableStyle.Render(summary.table.View()))
builder.WriteString("\n") if i < len(summaryTables)-1 {
}
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")
}
-1
View File
@@ -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"))
+1 -4
View File
@@ -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 != "" {
+172 -16
View File
@@ -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 {
+2 -1
View File
@@ -9,7 +9,8 @@ type AuthType int
// Supported authentication methods. // Supported authentication methods.
const ( const (
AuthNone AuthType = iota AuthUnknown AuthType = iota
AuthNone
AuthBasic AuthBasic
AuthDigest AuthDigest
) )