Files
cameradar/internal/ui/tui.go
T
Lawrence Arryl Lopez 8531c006d4 feat: support http tunneled rtsp (#419)
* enhancement: supporting http tunneled rtsp

* refactor: simplify HTTP tunnel support per review feedback

- Extract streamCandidate() for nmap port classification
- Add isCommonHTTPPort() for masscan and nmap fallback
- Move URL building to Stream.String() and Stream.URL()
- Pass Stream directly to attack methods instead of individual args
- Add TLS config for HTTPS tunnel support
- Make auth detection non-fatal for tunneled streams
- Rename HTTPTunnel to UseHTTPTunnel

* - Testing the auth workflow for the tunneled streams is not blocking the rest of the pipeline since I changed the return values to Auth unknown and nil
- added extra ports in the test according to suggestions

* fixing some lint errors

* removing the unused buildrtspurl

* delayed the urlstream call for clarity

removed error messages

refactored the test that used the deprecated buildTRSPurl to use stream.URL and stream.String() methods

* extracting iscommonHTTP port to pkg/ports (package ports)

switching on u.scheme to create proper schemes for http and https

* refactor: replace HTTP tunnel bool with scheme-based detection; enable TLS only for HTTPS-tunneled streams

* chore: simnplify InferTunnelScheme and newRTSPClient

* fix: remove rendundant check in streamCandidate

* fix: typo in parseScheme

* tests: coverage for new schemes

* fix: use RTSP and not RTSPS for HTTPS URLs

* fix: tunneled RTSP scheme handling and auth detection fallback

* ui: render empty credentials as none in summary and TUI

* chore: ignore duplicate-string warning for none literal

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: rtsps probe headers

---------

Co-authored-by: Brendan Le Glaunec <brendan@glaulabs.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 06:41:20 +01:00

715 lines
16 KiB
Go

package ui
import (
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/Ullaakut/cameradar/v6"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type state int
const (
statePending state = iota
stateActive
stateDone
stateError
)
type logLevel int
const (
logInfo logLevel = iota
logDebug
logError
)
type stepMsg struct {
step cameradar.Step
state state
message string
}
type logMsg struct {
level logLevel
step cameradar.Step
message string
}
type progressMsg struct {
step cameradar.Step
total int
increment int
}
type closeMsg struct{}
type summaryMsg struct {
streams []cameradar.Stream
final bool
}
type summaryTable struct {
table table.Model
}
const (
summaryMinHeight = 8
summaryMaxHeight = 10
summaryColumnCount = 8
)
// TUIReporter renders a Bubble Tea based UI.
type TUIReporter struct {
program *tea.Program
debug bool
once sync.Once
closed chan struct{}
mu sync.Mutex
last []cameradar.Stream
}
// NewTUIReporter creates a new Bubble Tea reporter.
func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) {
spin := spinner.New()
spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
prog := progress.New(
progress.WithDefaultGradient(),
progress.WithFillCharacters('━', '·'),
progress.WithoutPercentage(),
progress.WithWidth(28),
)
initial := &modelState{
steps: cameradar.Steps(),
status: make(map[cameradar.Step]state),
debug: debug,
buildInfo: buildInfo,
cancel: cancel,
spinner: spin,
progress: prog,
progressTotals: make(map[cameradar.Step]int),
progressCounts: make(map[cameradar.Step]int),
}
p := tea.NewProgram(initial, tea.WithInputTTY(), tea.WithOutput(out), tea.WithAltScreen())
reporter := &TUIReporter{program: p, debug: debug, closed: make(chan struct{})}
go func() {
model, err := p.Run()
if err != nil {
_, _ = fmt.Fprintf(out, "Error running TUI: %v\n", err)
close(reporter.closed)
return
}
if rendered, ok := model.(*modelState); ok {
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)
}()
return reporter, nil
}
// Start implements Reporter.
func (r *TUIReporter) Start(step cameradar.Step, message string) {
r.send(stepMsg{step: step, state: stateActive, message: message})
}
// Done implements Reporter.
func (r *TUIReporter) Done(step cameradar.Step, message string) {
r.send(stepMsg{step: step, state: stateDone, message: message})
}
// Progress implements Reporter.
func (r *TUIReporter) Progress(step cameradar.Step, message string) {
if kind, value, ok := cameradar.ParseProgressMessage(message); ok {
msg := progressMsg{step: step}
if kind == "total" {
msg.total = value
}
if kind == "tick" {
msg.increment = value
}
r.send(msg)
return
}
r.send(logMsg{level: logInfo, step: step, message: message})
}
// Debug implements Reporter.
func (r *TUIReporter) Debug(step cameradar.Step, message string) {
if !r.debug {
return
}
r.send(logMsg{level: logDebug, step: step, message: message})
}
// Error implements Reporter.
func (r *TUIReporter) Error(step cameradar.Step, err error) {
if err == nil {
return
}
r.send(stepMsg{step: step, state: stateError, message: err.Error()})
}
// Summary implements Reporter.
func (r *TUIReporter) Summary(streams []cameradar.Stream, _ error) {
cloned := copyStreams(streams)
r.recordSummary(cloned)
r.send(summaryMsg{streams: cloned, final: true})
}
// UpdateSummary updates the summary section with partial results.
func (r *TUIReporter) UpdateSummary(streams []cameradar.Stream) {
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.
func (r *TUIReporter) Close() {
r.once.Do(func() {
r.send(closeMsg{})
})
// Timeout after 2 seconds to avoid hanging forever.
select {
case <-r.closed:
case <-time.After(2 * time.Second):
}
}
func (r *TUIReporter) send(msg tea.Msg) {
if r.program == nil {
return
}
r.program.Send(msg)
}
func renderStep(step cameradar.Step, state state, spinnerView string) string {
label := cameradar.StepLabel(step)
symbol := "·"
style := dimStyle
switch state {
case stateActive:
symbol = spinnerView
style = activeStyle
case stateDone:
symbol = "✓"
style = successStyle
case stateError:
symbol = "✗"
style = errorStyle
}
return style.Render(fmt.Sprintf("%s %s", symbol, label))
}
func renderLog(entry logMsg) string {
prefix := "INFO"
style := infoStyle
if entry.level == logDebug {
prefix = "DEBUG"
style = debugStyle
}
if entry.level == logError {
prefix = "ERROR"
style = errorStyle
}
return style.Render(fmt.Sprintf("[%s] %s: %s", prefix, cameradar.StepLabel(entry.step), entry.message))
}
func renderProgress(m *modelState) string {
completed, total := progressCounts(m.steps, m.status)
percent := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts)
countLabel := dimStyle.Render(fmt.Sprintf("%3.0f%% %d/%d complete", percent*100, completed, total))
return fmt.Sprintf("%s %s", m.progress.ViewAs(m.progressVisible), countLabel)
}
func progressCounts(steps []cameradar.Step, status map[cameradar.Step]state) (int, int) {
if len(steps) == 0 {
return 0, 0
}
completed := 0
for _, step := range steps {
switch status[step] {
case stateDone, stateError:
completed++
}
}
return completed, len(steps)
}
func progressPercent(steps []cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 {
weights := stepWeights()
percent := 0.0
for _, step := range steps {
weight := weights[step]
if weight <= 0 {
continue
}
percent += weight * stepProgress(step, status, totals, counts)
}
if percent > 1 {
return 1
}
return percent
}
func stepWeights() map[cameradar.Step]float64 {
return map[cameradar.Step]float64{
cameradar.StepScan: 0.15,
cameradar.StepAttackRoutes: 0.25,
cameradar.StepDetectAuth: 0.05,
cameradar.StepAttackCredentials: 0.35,
cameradar.StepValidateStreams: 0.2,
cameradar.StepSummary: 0.0,
}
}
func stepProgress(step cameradar.Step, status map[cameradar.Step]state, totals, counts map[cameradar.Step]int) float64 {
if total := totals[step]; total > 0 {
count := counts[step]
if count >= total {
return 1
}
return float64(count) / float64(total)
}
switch status[step] {
case stateDone, stateError:
return 1
default:
return 0
}
}
func queueProgressUpdate(m *modelState) {
desired := progressPercent(m.steps, m.status, m.progressTotals, m.progressCounts)
if desired <= m.progressTarget {
return
}
m.progressTarget = desired
}
func advanceProgress(m *modelState) {
if m.progressVisible >= m.progressTarget {
return
}
remaining := m.progressTarget - m.progressVisible
step := remaining * 0.2
if step < 0.02 {
step = 0.02
}
if m.quitting && step < 0.08 {
step = 0.08
}
if remaining < step {
m.progressVisible = m.progressTarget
return
}
m.progressVisible += step
}
func progressComplete(m modelState) bool {
return m.progressVisible >= m.progressTarget
}
func markStepComplete(m *modelState, step cameradar.Step) {
if m.progressTotals[step] == 0 {
m.progressTotals[step] = 1
}
if m.progressCounts[step] < m.progressTotals[step] {
m.progressCounts[step] = m.progressTotals[step]
}
}
func progressWidth(width int) int {
if width <= 0 {
return 28
}
if width < 60 {
return 20
}
if width < 100 {
return 28
}
return 36
}
func buildSummaryTables(streams []cameradar.Stream, width int, status map[cameradar.Step]state, maxRows int) []summaryTable {
visibility := summaryVisibility(status)
accessible, others := partitionStreams(streams)
rows := append(buildSummaryRows(accessible, visibility), buildSummaryRows(others, visibility)...)
if len(rows) == 0 {
rows = []table.Row{emptySummaryRow()}
}
if maxRows > 0 {
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)
}
}
columns := summaryColumns(width, rows)
model := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(false),
table.WithHeight(len(rows)),
)
model.SetStyles(summaryTableStyles())
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 = "—"
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 {
rows := make([]table.Row, 0, len(streams))
for _, stream := range streams {
target := fmt.Sprintf("%s:%d", stream.Address.String(), stream.Port)
device := emptyEntry
if visibility.showDevice && stream.Device != "" {
device = stream.Device
}
routes := emptyEntry
if visibility.showRoutes && len(stream.Routes) > 0 {
routes = strings.Join(stream.Routes, ", ")
}
credentials := emptyEntry
if visibility.showCredentials && stream.CredentialsFound {
credentials = formatCredentials(stream)
}
available := emptyEntry
if visibility.showAvailable {
available = "no"
if stream.Available {
available = "yes"
}
}
rtspURL := emptyEntry
if visibility.showCredentials && stream.RouteFound && stream.CredentialsFound {
rtspURL = formatRTSPURL(stream)
}
authType := emptyEntry
if visibility.showAuth {
authType = authTypeLabel(stream.AuthenticationType)
}
rows = append(rows, table.Row{
target,
device,
authType,
routes,
credentials,
available,
rtspURL,
adminPanelLabel(stream, visibility),
})
}
return rows
}
func summaryColumns(width int, rows []table.Row) []table.Column {
columns := []table.Column{
{Title: "Target", Width: 18},
{Title: "Device", Width: 14},
{Title: "Auth", Width: 8},
{Title: "Routes", Width: 18},
{Title: "Credentials", Width: 16},
{Title: "Available", Width: 9},
{Title: "RTSP URL", Width: 30},
{Title: "Admin", Width: 24},
}
columns[6].Width = maxColumnWidth(columns[6].Title, rows, 6, columns[6].Width)
columns[7].Width = maxColumnWidth(columns[7].Title, rows, 7, columns[7].Width)
if width <= 0 {
return columns
}
columns = clampColumns(columns, max(width-2, 60))
return columns
}
func clampColumns(columns []table.Column, maxWidth int) []table.Column {
padding := 2 * len(columns)
contentWidth := 0
for _, col := range columns {
contentWidth += col.Width
}
contentWidth += padding
if contentWidth <= maxWidth {
return columns
}
over := contentWidth - maxWidth
shrinkOrder := []int{7, 3, 4, 1}
minWidths := map[int]int{
7: 10,
3: 10,
4: 10,
1: 10,
}
for over > 0 {
changed := false
for _, idx := range shrinkOrder {
minWidth := minWidths[idx]
if columns[idx].Width > minWidth {
columns[idx].Width--
over--
changed = true
if over == 0 {
break
}
}
}
if !changed {
break
}
}
return columns
}
func summaryTableStyles() table.Styles {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = lipgloss.NewStyle()
styles.Cell = styles.Cell.Padding(0, 1)
return styles
}
func maxColumnWidth(title string, rows []table.Row, idx, minWidth int) int {
width := max(len(title), minWidth)
for _, row := range rows {
if idx >= len(row) {
continue
}
if len(row[idx]) > width {
width = len(row[idx])
}
}
return width
}
func adminPanelLabel(stream cameradar.Stream, visibility summaryVisibilityState) string {
if !visibility.showCredentials || !stream.CredentialsFound {
return emptyEntry
}
return formatAdminPanelURL(stream)
}
type summaryVisibilityState struct {
showDevice bool
showRoutes bool
showAuth bool
showCredentials bool
showAvailable bool
}
func summaryVisibility(status map[cameradar.Step]state) summaryVisibilityState {
return summaryVisibilityState{
showDevice: stepComplete(status, cameradar.StepScan),
showRoutes: stepComplete(status, cameradar.StepAttackRoutes),
showAuth: stepComplete(status, cameradar.StepDetectAuth),
showCredentials: stepComplete(status, cameradar.StepAttackCredentials),
showAvailable: stepComplete(status, cameradar.StepValidateStreams),
}
}
func stepComplete(status map[cameradar.Step]state, step cameradar.Step) bool {
if status == nil {
return false
}
switch status[step] {
case stateDone, stateError:
return true
default:
return false
}
}
func copyStreams(streams []cameradar.Stream) []cameradar.Stream {
if len(streams) == 0 {
return nil
}
cloned := make([]cameradar.Stream, len(streams))
copy(cloned, streams)
return cloned
}