Skip to content

Commit 30f3936

Browse files
committed
feat: interactive TUI with session persistence and UX polish
Made-with: Cursor
1 parent 0644b21 commit 30f3936

File tree

14 files changed

+1142
-120
lines changed

14 files changed

+1142
-120
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Interactive terminal UI (`-tui` flag) built with Bubble Tea — form-based config screen and live-scrolling results view; no CLI arguments required to launch
12+
- `make tui` Makefile target for one-command TUI launch
13+
- `internal/scan` package: extracted scan engine (`scan.Run`) with typed `Event` channel, usable by both CLI and TUI
14+
- TUI session persistence: last-used form values written to `~/.config/subenum/last.json` and restored on next launch or after pressing `r` (new scan)
15+
16+
### Changed
17+
- CLI scan loop in `main.go` now delegates to `scan.Run()` instead of containing the worker pool inline
18+
- External dependencies added: `github.com/charmbracelet/bubbletea` and `github.com/charmbracelet/bubbles` (TUI only; CLI path has zero external dependencies)
19+
- TUI form field order: Simulate toggle promoted to field 3 (was field 8); Hit Rate row is hidden when Simulate is OFF
20+
- TUI now shows a blinking cursor inside the active text input
21+
- Pressing `r` on the scan results screen returns to the form with last-used values pre-filled (was reset to defaults)
22+
1023
## [0.4.0] - 2026-03-14
1124

1225
### Added

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: build test clean lint run docker-build docker-run wordlist wordlist-gen simulate simulate-verbose
1+
.PHONY: build test clean lint run tui docker-build docker-run wordlist wordlist-gen simulate simulate-verbose
22

33
# Default Go parameters
44
GOCMD=go
@@ -54,6 +54,10 @@ wordlist: wordlist-gen
5454
@echo "Generated wordlist: $(WL_OUTPUT)"
5555
@echo "Use it with: make WORDLIST=$(WL_OUTPUT) DOMAIN=$(WL_DOMAIN) run-verbose"
5656

57+
# Launch the interactive TUI (one-click, no arguments needed)
58+
tui: build
59+
./$(BINARY_NAME) -tui
60+
5761
# Run with default parameters
5862
run: build
5963
./$(BINARY_NAME) -w $(WORDLIST) -t $(CONCURRENCY) -timeout $(TIMEOUT) -dns-server $(DNS_SERVER) $(DOMAIN)
@@ -98,6 +102,7 @@ help:
98102
@echo " make test-short - Run short tests"
99103
@echo " make clean - Clean build artifacts"
100104
@echo " make lint - Run linter"
105+
@echo " make tui - Launch the interactive terminal UI (no arguments needed)"
101106
@echo ""
102107
@echo " LIVE MODE (performs real DNS queries):"
103108
@echo " make run - Build and run with default parameters"

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
[![Release](https://img.shields.io/github/v/release/TMHSDigital/subenum?style=for-the-badge)](https://github.com/TMHSDigital/subenum/releases)
1515
[![Go Report Card](https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=for-the-badge&v=0.4.0)](https://goreportcard.com/report/github.com/TMHSDigital/subenum)
1616

17-
`Concurrent Workers` · `Context-Aware Cancellation` · `Retry with Backoff` · `Wildcard Detection` · `Simulation Mode` · `Zero Dependencies`
17+
`Concurrent Workers` · `Context-Aware Cancellation` · `Retry with Backoff` · `Wildcard Detection` · `Simulation Mode` · `Interactive TUI`
1818

1919
[Quick Start](#installation) | [Documentation](./docs) | [Architecture](#system-architecture) | [Changelog](./CHANGELOG.md)
2020

@@ -39,6 +39,7 @@
3939
| Simulation Mode | Generate synthetic DNS results at a configurable hit rate without network I/O. |
4040
| Output Pipeline | Stream resolved domains to stdout (pipe-friendly); progress and diagnostics go to stderr. |
4141
| Progress Reporting | Live terminal progress with atomic counters, updated on a 2-second ticker. |
42+
| Interactive TUI | Form-based config screen and live-scrolling results view via `-tui` flag (Bubble Tea). No arguments required. |
4243

4344
<br>
4445

@@ -141,6 +142,7 @@ make help # list all targets
141142
| `-progress` | `true` | Live progress line on stderr (disable with `-progress=false`) |
142143
| `-simulate` | `false` | Simulation mode: no real DNS queries |
143144
| `-hit-rate <n>` | `15` | Simulated resolution rate, percent (1-100) |
145+
| `-tui` | `false` | Launch the interactive Terminal UI (no other flags required) |
144146
| `-version` | -- | Print version and exit |
145147
| `-retries <n>` | -- | **Deprecated:** alias for `-attempts`, prints a warning |
146148

@@ -194,6 +196,25 @@ subenum -w <wordlist> [flags] <domain>
194196

195197
**Graceful shutdown:** press `Ctrl+C` at any time. In-flight queries drain, partial results are flushed.
196198

199+
**Interactive TUI (no arguments):**
200+
201+
```bash
202+
./subenum -tui
203+
# or
204+
make tui
205+
```
206+
207+
Fill in the form, press `ctrl+r` to scan. Last-used values are saved to `~/.config/subenum/last.json` and restored on next launch.
208+
209+
| Key | Action |
210+
| :--- | :--- |
211+
| `tab` / `shift+tab` / `↑↓` | Navigate fields |
212+
| `space` | Toggle Simulate / Force |
213+
| `ctrl+r` | Start scan |
214+
| `ctrl+c` | Abort scan (in scan view) / quit (in form) |
215+
| `r` | New scan — returns to form with last values pre-filled |
216+
| `q` | Quit after scan completes |
217+
197218
<br>
198219

199220
---

docs/ARCHITECTURE.md

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,16 @@ This architecture is designed to be efficient by performing multiple DNS lookups
2323
### Package Structure
2424

2525
```
26-
main.go — CLI entry point (flag parsing, wiring, worker loop)
27-
internal/dns/resolver.go — ResolveDomain, ResolveDomainWithRetry, CheckWildcard
28-
internal/dns/simulate.go — SimulateResolution
29-
internal/output/writer.go — Thread-safe Writer (results→stdout, diagnostics→stderr)
30-
internal/wordlist/reader.go — LoadWordlist (dedup + sanitize)
26+
main.go — CLI entry point (flag parsing, wiring, -tui dispatch)
27+
internal/scan/runner.go — Scan engine: Config, Event types, Run(ctx, cfg, events)
28+
internal/dns/resolver.go — ResolveDomain, ResolveDomainWithRetry, CheckWildcard
29+
internal/dns/simulate.go — SimulateResolution
30+
internal/output/writer.go — Thread-safe Writer (results→stdout, diagnostics→stderr)
31+
internal/wordlist/reader.go — LoadWordlist (dedup + sanitize)
32+
internal/tui/model.go — Root Bubble Tea model (form → scan state machine)
33+
internal/tui/form.go — Config form screen (textinput fields + toggles)
34+
internal/tui/scan_view.go — Live results screen (viewport + progress bar)
35+
internal/tui/config.go — Session persistence (load/save ~/.config/subenum/last.json)
3136
```
3237

3338
## 2. Key Components / Modules
@@ -75,20 +80,18 @@ internal/wordlist/reader.go — LoadWordlist (dedup + sanitize)
7580
* The function returns `true` if `LookupHost` returns no error (i.e., the domain resolved), and `false` otherwise.
7681
* **Interactions**: Workers call `dns.ResolveDomainWithRetry`, which delegates to `dns.ResolveDomain` with retry logic. It takes a fully qualified domain name, timeout duration, DNS server address, verbose flag, and retry count as input. It outputs a boolean indicating whether the domain resolved successfully. The result is used to decide if the domain should be printed to the console and/or written to the output file.
7782

78-
### 2.4. Concurrency Management (Worker Pool)
83+
### 2.4. Concurrency Management (`internal/scan`)
7984

8085
* **Purpose**: To efficiently perform DNS lookups for a large number of potential subdomains, `subenum` employs a worker pool pattern. This allows multiple DNS queries to be in flight concurrently, significantly speeding up the enumeration process compared to sequential lookups.
81-
* **Implementation**:
82-
* **`subdomains := make(chan string)`**: A buffered channel (though currently unbuffered in `main.go`, could be buffered for performance tuning) is created to act as a work queue. Subdomain prefixes read from the wordlist are sent to this channel.
83-
* **`var wg sync.WaitGroup`**: A `sync.WaitGroup` is used to wait for all worker goroutines to complete their tasks before the main function exits.
84-
* **Worker Goroutines Loop (`for i := 0; i < *concurrency; i++`)**: A loop launches a number of goroutines specified by the `-t` (concurrency) flag. Each goroutine acts as a worker.
85-
* `wg.Add(1)`: Increments the `WaitGroup` counter for each worker started.
86-
* `go func() { ... }()`: Each worker runs in its own goroutine.
87-
* `defer wg.Done()`: Decrements the `WaitGroup` counter when the goroutine exits.
88-
* `for subdomainPrefix := range subdomains { ... }`: Each worker continuously reads subdomain prefixes from the `subdomains` channel until the channel is closed. For each prefix, it constructs the full domain and calls `dns.ResolveDomainWithRetry()`.
89-
* **Closing the Channel (`close(subdomains)`)**: After all subdomain prefixes from the wordlist have been sent to the `subdomains` channel, the channel is closed. This signals to the worker goroutines that no more work will be added.
90-
* **Waiting for Completion (`wg.Wait()`)**: The main goroutine blocks until all worker goroutines have called `wg.Done()`, ensuring all lookups are finished.
91-
* **Interactions**: This component orchestrates the parallel execution of DNS lookups. It receives subdomain prefixes from the Wordlist Processing component (via the `subdomains` channel) and utilizes the DNS Resolution Engine within each worker goroutine. The number of workers is controlled by the Argument Parsing component.
86+
* **Implementation**: The worker pool logic lives in `internal/scan/runner.go` as `scan.Run(ctx, cfg, events)`. Both the CLI (`run()` in `main.go`) and the TUI (`internal/tui`) call this function.
87+
* **`scan.Config`**: A struct carrying all scan parameters (domain, entries slice, concurrency, timeout, DNS server, simulate flag, etc.).
88+
* **`scan.Event` / `scan.EventKind`**: Typed events emitted on a `chan<- scan.Event``EventResult`, `EventProgress`, `EventWildcard`, `EventError`, `EventDone`.
89+
* **`subdomains := make(chan string)`**: An internal channel acts as a work queue. Entries from the pre-loaded wordlist slice are fed into it.
90+
* **`var wg sync.WaitGroup`**: A `sync.WaitGroup` waits for all worker goroutines to finish.
91+
* **Worker Goroutines Loop**: `cfg.Concurrency` goroutines are launched. Each reads prefixes from the channel, constructs the full domain, and calls `dns.ResolveDomainWithRetry()` (or `dns.SimulateResolution()` in simulate mode).
92+
* **Progress ticker**: A separate goroutine fires every second and emits `EventProgress` events so callers can update their display.
93+
* **Closing the Channel**: After all entries are sent, the channel is closed, signalling workers to exit. `wg.Wait()` blocks until all workers are done, then `EventDone` is emitted.
94+
* **Interactions**: `scan.Run` is the single entry point for scanning used by both the CLI output pipeline and the Bubble Tea TUI. It decouples the scan engine from any specific display layer.
9295

9396
### 2.5. Output Formatting (`internal/output`)
9497

@@ -119,6 +122,16 @@ internal/wordlist/reader.go — LoadWordlist (dedup + sanitize)
119122
* Shows percentage completion, processed count, and found count
120123
* **Interactions**: The Progress Monitoring component works alongside the worker goroutines, using atomic operations to safely track counts across multiple goroutines. Writing to stderr keeps stdout pipe-clean.
121124

125+
### 2.7. Session Persistence (`internal/tui/config.go`)
126+
127+
* **Purpose**: Remember the last-used TUI form values across sessions so users don't have to re-type domain, wordlist path, and scan parameters every time.
128+
* **Implementation**:
129+
* `savedConfig` struct mirrors `formValues` with JSON tags.
130+
* `configPath()` — returns `os.UserConfigDir()/subenum/last.json` (e.g. `~/.config/subenum/last.json` on Linux/macOS, `%AppData%\subenum\last.json` on Windows).
131+
* `saveConfig(fv formValues) error` — marshals `formValues` to JSON and writes it atomically with `os.WriteFile`. Called in `beginScan()` immediately before launching the scan goroutine. Errors are silently discarded so a write failure never blocks the scan.
132+
* `loadSavedConfig() (savedConfig, bool)` — reads and unmarshals the file. Returns `false` if the file doesn't exist or is unreadable, causing `newFormModel` to fall back to hardcoded defaults.
133+
* **Interactions**: `tui.New()` calls `loadSavedConfig()` on startup and passes the result to `newFormModel`. The `r` keybind (new scan) also calls `loadSavedConfig()` so the form is pre-filled with the values from the scan that just completed.
134+
122135
## 3. Data Flow
123136

124137
The flow of data through the `subenum` application can be summarized as follows:
@@ -151,7 +164,7 @@ The flow of data through the `subenum` application can be summarized as follows:
151164

152165
Visually, this can be seen as:
153166

154-
`User Input -> Argument Parser -> [Wordlist File] -> Wordlist Processor -> subdomains channel -> Worker Goroutines -> DNS Resolver -> Output (if resolved)`
167+
`User Input -> Argument Parser -> [Wordlist File] -> Wordlist Processor -> scan.Run() -> Worker Goroutines -> DNS Resolver -> Event Channel -> Output (if resolved)`
155168

156169
## 4. Error Handling Strategy
157170

docs/DEVELOPER_GUIDE.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ To work with `subenum`, you'll need:
4848
4949
# Or with custom parameters
5050
./subenum -w path/to/wordlist.txt -t 50 -timeout 2000 yourtarget.com
51+
52+
# Launch the interactive TUI (no flags required)
53+
./subenum -tui
54+
# or via Make
55+
make tui
5156
```
5257

5358
## Project Structure
@@ -92,6 +97,13 @@ subenum/
9297
│ ├── output/
9398
│ │ ├── writer.go # Thread-safe output (results→stdout, rest→stderr)
9499
│ │ └── writer_test.go # Output writer tests
100+
│ ├── scan/
101+
│ │ └── runner.go # Scan engine: Config, Event types, Run(ctx, cfg, events)
102+
│ ├── tui/
103+
│ │ ├── model.go # Root Bubble Tea model (form → scan state machine)
104+
│ │ ├── form.go # Config form screen (textinput fields + toggles)
105+
│ │ ├── scan_view.go # Live results screen (viewport + progress bar)
106+
│ │ └── config.go # Session persistence: load/save ~/.config/subenum/last.json
95107
│ └── wordlist/
96108
│ ├── reader.go # LoadWordlist (dedup + sanitize)
97109
│ └── reader_test.go # Wordlist loading and dedup tests
@@ -102,7 +114,7 @@ subenum/
102114
├── .golangci.yml # Linter configuration (golangci-lint v2)
103115
├── main.go # CLI entry point: flag parsing, wiring
104116
├── main_test.go # CLI-level tests: validation, flag logic
105-
├── go.mod # Go module (zero external dependencies)
117+
├── go.mod # Go module (Bubble Tea for TUI; zero deps in CLI-only builds)
106118
├── Dockerfile # Multi-stage Alpine build
107119
├── docker-compose.yml # Compose orchestration
108120
├── Makefile # Build, test, lint, simulate, Docker targets
@@ -226,7 +238,14 @@ Please follow these style guidelines when contributing:
226238

227239
## Dependencies Management
228240

229-
`subenum` aims to minimize external dependencies, relying primarily on the Go standard library. If you need to add a dependency:
241+
`subenum` aims to minimize external dependencies, relying primarily on the Go standard library.
242+
243+
The CLI path (`run()`) has zero external dependencies. The TUI path (`-tui` flag) adds:
244+
245+
- [`github.com/charmbracelet/bubbletea`](https://github.com/charmbracelet/bubbletea) — Elm-architecture terminal UI framework
246+
- [`github.com/charmbracelet/bubbles`](https://github.com/charmbracelet/bubbles) — reusable TUI components (textinput, viewport, progress bar)
247+
248+
If you need to add a further dependency:
230249

231250
1. Evaluate whether it's truly necessary or if the functionality can be implemented using the standard library.
232251
2. If a dependency is needed, add it with:
@@ -239,6 +258,7 @@ Please follow these style guidelines when contributing:
239258
240259
Areas for potential enhancement include:
241260
261+
* **Terminal UI**: An interactive TUI (`-tui` flag) built with Bubble Tea. Provides a form-based config screen and a live-scrolling results view — no arguments required to launch. Last-used values persist to `~/.config/subenum/last.json` across sessions.
242262
* **Output Formats**: Supporting different output formats (JSON, CSV) in addition to the current plain text output file (`-o`).
243263
* **Result Filtering**: Allowing users to filter results based on DNS record types.
244264
* **Recursive Enumeration**: Adding support for recursive subdomain enumeration (e.g., finding subdomains of discovered subdomains).

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ A Go-based CLI tool for subdomain enumeration designed for educational purposes
2222
- Graceful shutdown on Ctrl+C (SIGINT/SIGTERM)
2323
- Simulation mode for safe testing without network access
2424
- Domain and DNS server input validation
25+
- Interactive TUI (`-tui` flag) — form-based config and live-scrolling results, no arguments required; saves last-used values to `~/.config/subenum/last.json`
2526
- Docker support for containerized usage
2627
- Extensive documentation and examples
2728

@@ -73,6 +74,9 @@ make help
7374
# Build and run with default settings
7475
make run
7576

77+
# Launch the interactive TUI
78+
make tui
79+
7680
# Build and run with verbose output
7781
make run-verbose
7882

go.mod

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,31 @@
11
module github.com/TMHSDigital/subenum
22

3-
go 1.22
3+
go 1.24.2
4+
5+
require (
6+
github.com/atotto/clipboard v0.1.4 // indirect
7+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
8+
github.com/charmbracelet/bubbles v1.0.0 // indirect
9+
github.com/charmbracelet/bubbletea v1.3.10 // indirect
10+
github.com/charmbracelet/colorprofile v0.4.1 // indirect
11+
github.com/charmbracelet/harmonica v0.2.0 // indirect
12+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
13+
github.com/charmbracelet/x/ansi v0.11.6 // indirect
14+
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
15+
github.com/charmbracelet/x/term v0.2.2 // indirect
16+
github.com/clipperhouse/displaywidth v0.9.0 // indirect
17+
github.com/clipperhouse/stringish v0.1.1 // indirect
18+
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
19+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
20+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
21+
github.com/mattn/go-isatty v0.0.20 // indirect
22+
github.com/mattn/go-localereader v0.0.1 // indirect
23+
github.com/mattn/go-runewidth v0.0.19 // indirect
24+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
25+
github.com/muesli/cancelreader v0.2.2 // indirect
26+
github.com/muesli/termenv v0.16.0 // indirect
27+
github.com/rivo/uniseg v0.4.7 // indirect
28+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
29+
golang.org/x/sys v0.38.0 // indirect
30+
golang.org/x/text v0.3.8 // indirect
31+
)

0 commit comments

Comments
 (0)