From f192139cc33164e0ebc66b1c1707be3e11786f1e Mon Sep 17 00:00:00 2001 From: Brendan Le Glaunec Date: Sun, 1 Feb 2026 22:30:55 +0100 Subject: [PATCH] feat: small UI improvements (#394) --- .goreleaser.yml | 22 ++--- cmd/cameradar/cameradar.go | 72 +++++++++++++- cmd/cameradar/main.go | 10 +- internal/ui/build.go | 48 ++++++++++ internal/ui/build_test.go | 175 +++++++++++++++++++++++++++++++++++ internal/ui/plain.go | 37 +++++++- internal/ui/plain_test.go | 40 ++++++-- internal/ui/reporter.go | 7 +- internal/ui/reporter_test.go | 2 +- internal/ui/state.go | 13 ++- internal/ui/tui.go | 5 +- 11 files changed, 402 insertions(+), 29 deletions(-) create mode 100644 internal/ui/build.go create mode 100644 internal/ui/build_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index dbe2bf5..e746c56 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -46,21 +46,21 @@ archives: dockers: - image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64" - "ullaakut/{{ .ProjectName }}:latest-amd64" dockerfile: Dockerfile use: buildx goos: linux goarch: amd64 - image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386" - "ullaakut/{{ .ProjectName }}:latest-386" dockerfile: Dockerfile use: buildx goos: linux goarch: 386 - image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6" - "ullaakut/{{ .ProjectName }}:latest-armv6" dockerfile: Dockerfile use: buildx @@ -68,7 +68,7 @@ dockers: goarch: arm goarm: 6 - image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7" - "ullaakut/{{ .ProjectName }}:latest-armv7" dockerfile: Dockerfile use: buildx @@ -76,7 +76,7 @@ dockers: goarch: arm goarm: 7 - image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64" - "ullaakut/{{ .ProjectName }}:latest-arm64" dockerfile: Dockerfile use: buildx @@ -84,13 +84,13 @@ dockers: goarch: arm64 docker_manifests: - - name_template: "ullaakut/{{ .ProjectName }}:{{ .Version }}" + - name_template: "ullaakut/{{ .ProjectName }}:v{{ .Version }}" image_templates: - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-amd64" - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-386" - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv6" - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-armv7" - - "ullaakut/{{ .ProjectName }}:{{ .Version }}-arm64" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-amd64" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-386" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv6" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-armv7" + - "ullaakut/{{ .ProjectName }}:v{{ .Version }}-arm64" - name_template: "ullaakut/{{ .ProjectName }}:latest" image_templates: - "ullaakut/{{ .ProjectName }}:latest-amd64" diff --git a/cmd/cameradar/cameradar.go b/cmd/cameradar/cameradar.go index d449d49..a5fb766 100644 --- a/cmd/cameradar/cameradar.go +++ b/cmd/cameradar/cameradar.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "strconv" "strings" + "time" "github.com/Ullaakut/cameradar/v6" "github.com/Ullaakut/cameradar/v6/internal/attack" @@ -17,7 +19,11 @@ import ( "golang.org/x/term" ) +//nolint:cyclop // Splitting this function does not make it clearer. func runCameradar(ctx context.Context, cmd *cli.Command) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + targetInputs := cmd.StringSlice(flagTargets) if len(targetInputs) == 0 { return errors.New("at least one target must be specified") @@ -60,10 +66,27 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error { } 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 { return err } + if plainReporter, ok := reporter.(*ui.PlainReporter); ok { + resolvedMode := resolveMode(mode, interactive) + plainReporter.PrintStartup(buildInfo, buildStartupOptions( + targets, + ports, + routesPath, + credsPath, + outputPath, + cmd.Int16(flagScanSpeed), + cmd.Duration(flagAttackInterval), + cmd.Duration(flagTimeout), + cmd.Bool(flagSkipScan), + cmd.Bool(flagDebug), + resolvedMode, + )) + } if outputPath != "" { reporter = output.NewM3UReporter(reporter, outputPath) } @@ -102,6 +125,53 @@ func runCameradar(ctx context.Context, cmd *cli.Command) error { 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, + 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"), + "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 { if !term.IsTerminal(int(os.Stdout.Fd())) { return false diff --git a/cmd/cameradar/main.go b/cmd/cameradar/main.go index e777cbf..0316c23 100644 --- a/cmd/cameradar/main.go +++ b/cmd/cameradar/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "os/signal" @@ -28,7 +29,11 @@ const ( flagOutput = "output" ) -var version = "dev" +var ( + version = "dev" + commit = "none" + date = "unknown" +) var flags = cmd.Flags{ &cli.StringSliceFlag{ @@ -128,6 +133,9 @@ func realMain() (code int) { err := app.Run(ctx, os.Args) if err != nil { + if errors.Is(err, context.Canceled) { + return 1 + } _, _ = fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) return 1 } diff --git a/internal/ui/build.go b/internal/ui/build.go new file mode 100644 index 0000000..2002da9 --- /dev/null +++ b/internal/ui/build.go @@ -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() + ")" +} diff --git a/internal/ui/build_test.go b/internal/ui/build_test.go new file mode 100644 index 0000000..09153a0 --- /dev/null +++ b/internal/ui/build_test.go @@ -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()) + }) + } +} diff --git a/internal/ui/plain.go b/internal/ui/plain.go index 12ddd15..43098cd 100644 --- a/internal/ui/plain.go +++ b/internal/ui/plain.go @@ -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. 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. @@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) { if !r.debug { return } - r.print(step, "DEBUG", message) + r.print(step, "DBUG", message) } // Error prints an error message. @@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) { if err == nil { return } - r.print(step, "ERROR", err.Error()) + r.print(step, "EROR", err.Error()) } // Summary prints the final summary. @@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) { 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) } diff --git a/internal/ui/plain_test.go b/internal/ui/plain_test.go index 1891476..a9a2683 100644 --- a/internal/ui/plain_test.go +++ b/internal/ui/plain_test.go @@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) { reporter.Summary([]cameradar.Stream{}, nil) content := out.String() - assert.Contains(t, content, "[START] Scan targets: starting") - assert.Contains(t, content, "[INFO] Scan targets: working") - assert.Contains(t, content, "[DEBUG] Scan targets: details") - assert.Contains(t, content, "[DONE] Scan targets: finished") - assert.Contains(t, content, "[ERROR] Scan targets: boom") + assert.Contains(t, content, " [STEP] Scan targets: starting") + assert.Contains(t, content, " [INFO] Scan targets: working") + assert.Contains(t, content, " [DBUG] Scan targets: details") + assert.Contains(t, content, " [DONE] Scan targets: finished") + assert.Contains(t, content, " [EROR] Scan targets: boom") assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0") }) @@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) { reporter.Error(cameradar.StepScan, nil) content := out.String() - assert.NotContains(t, content, "DEBUG") + assert.NotContains(t, content, "DBUG") 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: ")) + }) +} diff --git a/internal/ui/reporter.go b/internal/ui/reporter.go index 60c5ded..d26526e 100644 --- a/internal/ui/reporter.go +++ b/internal/ui/reporter.go @@ -1,6 +1,7 @@ package ui import ( + "context" "errors" "fmt" "io" @@ -20,7 +21,7 @@ type Reporter interface { } // 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 { return NewPlainReporter(out, debug), nil } @@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo if !interactive { return nil, errors.New("tui mode requires an interactive terminal") } - return NewTUIReporter(debug, out) + return NewTUIReporter(debug, out, buildInfo, cancel) case cameradar.ModeAuto: if interactive { - return NewTUIReporter(debug, out) + return NewTUIReporter(debug, out, buildInfo, cancel) } return NewPlainReporter(out, debug), nil default: diff --git a/internal/ui/reporter_test.go b/internal/ui/reporter_test.go index ce02768..89f4434 100644 --- a/internal/ui/reporter_test.go +++ b/internal/ui/reporter_test.go @@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) { t.Run(test.name, func(t *testing.T) { 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 != "" { require.Error(t, err) diff --git a/internal/ui/state.go b/internal/ui/state.go index 487559e..a7a2499 100644 --- a/internal/ui/state.go +++ b/internal/ui/state.go @@ -1,6 +1,7 @@ package ui import ( + "context" "strings" "github.com/Ullaakut/cameradar/v6" @@ -16,6 +17,8 @@ type modelState struct { summary []summaryTable summaryStreams []cameradar.Stream summaryFinal bool + buildInfo BuildInfo + cancel context.CancelFunc debug bool spinner spinner.Model progress progress.Model @@ -45,6 +48,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.handleProgressMsg(typed) case closeMsg: 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: cmds = m.handleSpinnerMsg(typed) case tea.WindowSizeMsg: @@ -129,7 +140,7 @@ func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) { func (m *modelState) View() string { var builder strings.Builder - builder.WriteString(sectionStyle.Render("Steps")) + builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader())) builder.WriteString("\n") builder.WriteString(renderProgress(m)) builder.WriteString("\n") diff --git a/internal/ui/tui.go b/internal/ui/tui.go index fab320b..fd14933 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "io" "strings" @@ -72,7 +73,7 @@ type TUIReporter struct { } // 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 = spinner.Dot spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) @@ -88,6 +89,8 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) { 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),