Architecture
This document describes the architecture of the subenum tool, a Go-based command-line utility for subdomain enumeration.
1. Overview
The subenum tool operates through a sequence of steps to discover valid subdomains for a given target domain:
- Initialization: Parses command-line arguments, including the target domain, path to the wordlist file, concurrency level, and DNS timeout.
- Wildcard Detection: Resolves two random subdomains to detect wildcard DNS. If detected, exits unless
-forceis set. - Wordlist Ingestion: Reads the wordlist file into memory, deduplicating entries in a single pass.
- Concurrent Resolution: A pool of worker goroutines is established. Each worker takes a prefix from the wordlist, constructs a full subdomain string (e.g.,
prefix.targetdomain.com), and attempts to resolve it using DNS. - Output: Resolved subdomains are printed to stdout (pipe-friendly); all progress, verbose, and diagnostic output goes to stderr.
- Completion: The tool waits for all DNS lookups to complete before exiting.
This architecture is designed to be efficient by performing multiple DNS lookups concurrently, while also providing control over the level of concurrency and timeout settings.
Package Structure
main.go - CLI entry point (flag parsing, wiring, -tui dispatch)
internal/scan/runner.go - Scan engine: Config, Event types, Run(ctx, cfg, events)
internal/dns/resolver.go - ResolveTypes, ResolveDomainWithRetry, CheckWildcard, ParseTypes
internal/dns/simulate.go - SimulateResolve
internal/output/writer.go - Thread-safe Writer (results→stdout, diagnostics→stderr)
internal/wordlist/reader.go - LoadWordlist (dedup + sanitize)
internal/tui/model.go - Root Bubble Tea model (form → scan state machine)
internal/tui/form.go - Config form screen (textinput fields + toggles)
internal/tui/scan_view.go - Live results screen (viewport + progress bar)
internal/tui/config.go - Session persistence (load/save ~/.config/subenum/last.json)
2. Key Components / Modules
2.1. Argument Parsing
- Purpose: This component is responsible for processing the command-line arguments provided by the user when
subenumis executed. It extracts the target domain, the path to the wordlist file, the desired number of concurrent workers, and the DNS lookup timeout. - Implementation: Utilizes Go’s standard
flagpackage.parseFlags()binds every flag into acliFlagsstruct using theflag.*Varforms, then callsflag.Parse().flag.StringVar(&f.wordlistFile, "w", "", ...): Binds the wordlist file flag.flag.IntVar(&f.concurrency, "t", 100, ...): Binds the concurrency level flag.flag.IntVar(&f.timeoutMs, "timeout", 1000, ...): Binds the DNS timeout flag.flag.StringVar(&f.dnsServer, "dns-server", DefaultDNSServer, ...): Binds the custom DNS server flag.flag.BoolVar(&f.verbose, "v", false, ...): Binds the verbose flag.flag.BoolVar(&f.showProgress, "progress", true, ...): Binds the progress reporting flag.flag.BoolVar(&f.showVersion, "version", false, ...): Binds the version flag.flag.StringVar(&f.outputFile, "o", "", ...): Binds the output file flag.flag.IntVar(&f.attempts, "attempts", 0, ...): Binds the attempt count flag.flag.IntVar(&f.retries, "retries", 0, ...): Binds the deprecated retry flag.flag.BoolVar(&f.force, "force", false, ...): Binds the force flag.flag.Parse(): Parses the provided arguments into thecliFlagsstruct.flag.Arg(0): Retrieves the positional argument (the target domain).
- Interactions: The parsed values are used to configure the subsequent components, such as the Wordlist Processing and DNS Resolution Engine. Input validation is performed to ensure valid values for critical parameters like concurrency, timeout, DNS server format (validated via
validateDNSServer), and domain syntax (validated viavalidateDomain).
2.2. Wordlist Processing (internal/wordlist)
- Purpose: This component is responsible for opening, reading, sanitizing, and deduplicating the subdomain prefixes from the user-specified wordlist file.
- Implementation:
wordlist.LoadWordlist(path) ([]string, int, error): Reads the entire file in a single pass, trims whitespace from each line, removes blank lines, and deduplicates entries using a map while preserving first-occurrence order. Returns the deduplicated slice, the count of removed duplicates, and any I/O error.wordlist.SanitizeLine(s) string: Trims whitespace from a single wordlist entry.
- Interactions: The deduplicated entries are fed into the
subdomainschannel from a slice (no file re-read needed). The duplicate count is reported in verbose mode.
2.3. DNS Resolution Engine (internal/dns)
- Purpose: This is the core component responsible for performing the actual DNS lookup for each constructed subdomain (e.g.,
prefix.targetdomain.com). It determines if a subdomain has a valid DNS record (typically A or CNAME, though the current implementation checks for any successful resolution). It also provides wildcard DNS detection. - Implementation:
- Function:
dns.ResolveTypes(ctx, domain, timeout, dnsServer, types) ([]Record, time.Duration, error)- performs per-type lookups and returns typedRecord{Type, Value}results. It issuesLookupIPip4/ip6 for A/AAAA andLookupCNAMEfor CNAME, filtering to the requested types (default A,AAAA via-type).dns.ResolveWithLog(...)wraps it with the verbose stderr logging shared by the CLI and TUI. - Function:
dns.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool- convenience wrapper returning a boolean, used by wildcard detection. - Function:
dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts, types) ([]Record, bool)- wraps the lookup with configurable retry logic and linear backoff between attempts, returning the resolved records. - Function:
dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)- resolves two random subdomains to detect wildcard DNS records. net.Resolver{}: A custom DNS resolver is configured.PreferGo: true: Instructs the resolver to use the pure Go DNS client.Dial func(ctx context.Context, network, address string) (net.Conn, error): A custom dial function is provided to control the connection to the DNS server, using the user-specifieddnsServeraddress.net.Dialer{Timeout: timeout}: ADialeris created with the user-specified timeout.d.DialContext(ctx, "udp", dnsServer): Establishes a UDP connection to the configured DNS server.
resolver.LookupIP/resolver.LookupCNAME(insideResolveTypes): Perform the per-type DNS lookups for the requested record types. The context is derived from the caller viacontext.WithTimeout(ctx, timeout), so both the per-query timeout and SIGINT cancellation are respected.- A subdomain is treated as resolved when at least one record is returned for the requested types;
ResolveDomaincollapses this to a boolean for wildcard detection.
- Function:
- Interactions: Workers call
dns.ResolveDomainWithRetry, which delegates todns.ResolveDomainwith 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.
2.4. Concurrency Management (internal/scan)
- Purpose: To efficiently perform DNS lookups for a large number of potential subdomains,
subenumemploys a worker pool pattern. This allows multiple DNS queries to be in flight concurrently, significantly speeding up the enumeration process compared to sequential lookups. - Implementation: The worker pool logic lives in
internal/scan/runner.goasscan.Run(ctx, cfg, events). Both the CLI (run()inmain.go) and the TUI (internal/tui) call this function.scan.Config: A struct carrying all scan parameters (domain, entries slice, concurrency, timeout, DNS server, simulate flag, etc.).scan.Event/scan.EventKind: Typed events emitted on achan<- scan.Event-EventResult,EventProgress,EventWildcard,EventError,EventDone.- Dispatcher and work queue: A dispatcher goroutine owns an internal
jobschannel, the queue of pendingjob{domain, depth}items, a visited set, and a pending-work counter. It seeds the queue from the wordlist slice and feeds workers. Workers submit newly discovered children back to the dispatcher over anenqueuechannel and signal each finished job over acompletedchannel. The dispatcher closesjobsonly when the pending counter reaches zero (or the context is cancelled). This lifecycle lets resolved subdomains enqueue children safely (recursive mode) without risking a send on a closed channel. var wg sync.WaitGroup: Async.WaitGroupwaits for all worker goroutines to finish.- Worker Goroutines Loop:
cfg.Concurrencygoroutines are launched. Each reads a job fromjobs, constructs nothing further (the job already holds the full domain), and callsdns.ResolveDomainWithRetry()(ordns.SimulateResolve()in simulate mode). - Recursive enumeration (optional): when
cfg.Recursiveis set and a job at depthd < cfg.Depthresolves, the worker enqueues one child per wordlist entry at depthd+1. The dispatcher’s visited set deduplicates domains (loop and duplicate protection), and the progress total grows as new work is admitted. - Progress ticker: A separate goroutine fires every second and emits
EventProgressevents so callers can update their display. The total is read atomically since recursion can expand it mid-scan. - Rate limiter (optional): when
cfg.Rate > 0, a sharedtime.Tickergate paces total DNS queries per second across the whole pool. Each worker waits on the gate before issuing a query, selecting onctx.Done()so cancellation stays responsive.0means unlimited. - Completion:
wg.Wait()blocks until all workers exit (after the dispatcher closesjobs), then the progress ticker is stopped andEventDoneis emitted.
- Interactions:
scan.Runis 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.
2.5. Output Formatting (internal/output)
- Purpose: Thread-safe output that keeps stdout pipe-clean. Resolved subdomains go to stdout; everything else (progress, verbose diagnostics, errors) goes to stderr.
- Implementation:
output.Writerstruct with mutex-protected methods:Result(domain, records)- intextformat printsFound: <domain>to stdout (and the output file if configured); injsonformat buffers{"subdomain", "records"}objects and writes a single array at completion; incsvformat streamssubdomain,type,valuerows with a header. The format is selected with-format text|json|csv(defaulttext, which is byte-for-byte identical to prior behavior). The JSON array is buffered because it is a single document and does not stream; JSONL would be the streaming-friendly alternative if needed. Output formats are CLI-only for now (TUI-pending).Progress(pct, processed, total, found)- writes a carriage-return progress line to stderr.Info(format, args...)- writes an informational line to stderr.Error(format, args...)- writes an error line to stderr.
- Verbose Output (when
-vflag is enabled):- Configuration summary, per-query DNS resolution info, and final scan statistics - all via
Infoto stderr.
- Configuration summary, per-query DNS resolution info, and final scan statistics - all via
- Progress Reporting (when
-progressflag is enabled):- A dedicated goroutine using a 1-second ticker calls
Progresson stderr.
- A dedicated goroutine using a 1-second ticker calls
- Interactions: All components route output through the
Writer. Since results are the only thing on stdout, piping (| cut -d' ' -f2) works without-progress=false.
2.6. Progress Monitoring
- Purpose: This component tracks the progress of the subdomain enumeration process and provides real-time feedback to the user via stderr.
- Implementation:
- Total Count: The total word count comes from the length of the deduplicated wordlist slice (no separate file pass needed).
- Atomic Counters:
processedWords: An atomic counter that’s incremented each time a subdomain is checked.foundSubdomains: An atomic counter that’s incremented each time a valid subdomain is found.
- Progress Display (on stderr):
- A dedicated goroutine using a ticker (running every 1 second) calls
Writer.Progress - Uses
\rcarriage return to update the same line repeatedly - Shows percentage completion, processed count, and found count
- A dedicated goroutine using a ticker (running every 1 second) calls
- 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.
2.7. Session Persistence (internal/tui/config.go)
- 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.
- Implementation:
savedConfigstruct mirrorsformValueswith JSON tags.configPath()- returnsos.UserConfigDir()/subenum/last.json(e.g.~/.config/subenum/last.jsonon Linux/macOS,%AppData%\subenum\last.jsonon Windows).saveConfig(fv formValues) error- marshalsformValuesto JSON and writes it atomically withos.WriteFile. Called inbeginScan()immediately before launching the scan goroutine. Errors are silently discarded so a write failure never blocks the scan.loadSavedConfig() (savedConfig, bool)- reads and unmarshals the file. Returnsfalseif the file doesn’t exist or is unreadable, causingnewFormModelto fall back to hardcoded defaults.
- Interactions:
tui.New()callsloadSavedConfig()on startup and passes the result tonewFormModel. Therkeybind (new scan) also callsloadSavedConfig()so the form is pre-filled with the values from the scan that just completed.
3. Data Flow
The flow of data through the subenum application can be summarized as follows:
- Input: The user provides command-line arguments: the target domain, the path to a wordlist file (
-w), a concurrency level (-t), a DNS timeout (-timeout), a DNS server (-dns-server), attempts (-attempts), output options (-o,-format), and scan tuning (-rate,-type,-recursive,-depth), plus flags for verbose mode (-v), progress reporting (-progress), and force mode (-force). The TUI (-tui) gathers the equivalent values from its form instead. - Configuration: These arguments are parsed and validated by the Argument Parsing component and assembled into a
scan.Config, which is the single input to the scan engine. - Wildcard Detection: Inside
scan.Run, two random subdomains are resolved against the target domain (skipped in simulation mode). If either resolves, wildcard DNS is detected and anEventWildcardis emitted; the scan aborts with anEventErrorunless-forceis set. - Wordlist Loading:
wordlist.LoadWordlistreads the file in a single pass, sanitizes lines, and deduplicates entries into a slice, which is passed toscan.RunviaConfig.Entries. - Dispatch: A dispatcher goroutine seeds its queue from the entry slice (constructing
prefix.domainjobs at depth 1, deduplicated through a visited set) and feeds the internaljobschannel. It owns the queue, the visited set, and a pending-work counter; it does not closejobsuntil the counter drains to zero or the context is cancelled. - Resolution:
cfg.Concurrencyworker goroutines read jobs fromjobs(each job already holds the full domain) and calldns.ResolveDomainWithRetry(ordns.SimulateResolvein simulation mode) for the requested record types, honoring the optional rate-limiter gate. - Result Emission: On a successful resolution the worker increments the found counter and emits an
EventResultcarrying the domain and its typed records. The caller (CLIWriteror TUI scan view) renders it; theWriterroutes results to stdout and any-ofile in the selected-format. - Recursive Expansion (optional): when
-recursiveis set and a resolved job is below the-depthcap, the worker submits one child per wordlist entry back to the dispatcher over theenqueuechannel. The dispatcher’s visited set deduplicates them and grows the progress total as new work is admitted. - Progress Tracking: Workers increment atomic processed/found counters; a separate ticker goroutine emits
EventProgressonce per second so the caller can update its display. - Termination: Each worker signals completion of a job over the
completedchannel. When the pending counter reaches zero the dispatcher closesjobs, the workers exit, andscan.Runwaits on thesync.WaitGroup, stops the progress ticker, emitsEventDone, and closes the events channel. OnSIGINT/SIGTERMthe context is cancelled, the dispatcher closesjobsearly, and the same drain-and-finish path runs with partial counts.
Visually, this can be seen as:
User Input -> Argument Parser -> scan.Config -> scan.Run() [Dispatcher -> jobs -> Worker Pool -> DNS Resolver] -> Event Channel -> Output (if resolved)
4. Error Handling Strategy
subenum handles different types of errors at various stages of its operation:
4.1. User Input Errors
- Missing Required Arguments: When the user doesn’t provide a wordlist file (
-wflag) or a target domain, the tool prints a usage message followed by the description of all flags, and then exits with a non-zero status code (os.Exit(1)). - Validation: The tool validates:
- Concurrency level and timeout must be positive integers.
- DNS server must be a valid
ip:portformat with proper IP address and port range (1-65535), validated byvalidateDNSServer. - Target domain must conform to DNS naming rules, validated by
validateDomain. - Hit rate (simulation mode) must be 1-100.
- Retry count must be at least 1.
4.2. File Operation Errors
- File Not Found or Can’t Be Read: If the wordlist file specified by the
-wflag cannot be opened (e.g., it doesn’t exist, permissions are insufficient, or the path is invalid), the tool prints an error message (fmt.Printf("Error opening wordlist file: %v\n", err)) and exits with a non-zero status code (os.Exit(1)). - File Reading Errors: If an error occurs while reading the file (e.g., the scanner encounters an error), the tool prints an error message (
fmt.Printf("Error reading wordlist file: %v\n", err)) but does not exit immediately. It continues to process any words it has already read before the error.
4.3. DNS Resolution Errors
- Lookup Failure: When a DNS lookup fails (e.g., the subdomain doesn’t exist, there’s a DNS server problem, or the timeout is exceeded), the tool silently ignores the failure and doesn’t print any message. This is by design, as the tool is only interested in reporting successful subdomain resolutions.
- Timeout Handling: The user-specified timeout (
-timeoutflag) is used to limit how long each DNS query can take. If a query exceeds this timeout, it’s considered a failure and is treated as if the subdomain doesn’t exist. This prevents the tool from hanging indefinitely on slow or unresponsive DNS servers.
4.4. Concurrency-Related Issues
- Channel Operations: The scan engine uses three internal channels (
jobs,enqueue,completed) plus the outboundeventschannel. To avoid the classic send-on-closed and double-close panics, only the dispatcher closesjobs, and it does so exactly once (when the pending counter drains to zero or the context is cancelled);enqueueandcompletedare never closed. Theeventschannel is closed byscan.Runonly after the workerWaitGroupreturns and the progress ticker has confirmed its exit, so no in-flight send can race the close. Callers must draineventsuntil it is closed: the result and done sends are not individually guarded against a consumer that stops reading early, so a consumer that wants to stop on cancellation should keep draining until close rather than abandoning the channel. - Worker Goroutine Errors: Each worker goroutine processes DNS lookups independently. If an error occurs within a worker (outside of the expected DNS resolution failures), it can cause the entire goroutine to terminate. The current implementation doesn’t have specific handling for such scenarios.
4.5. Graceful Shutdown
The tool listens for SIGINT and SIGTERM signals. Upon receiving an interrupt, it cancels the work context, drains in-flight workers, and exits cleanly with a summary of results processed so far.
4.6. Output File Support
When the -o flag is provided, resolved subdomains are written to the specified file (one per line) in addition to stdout. A mutex protects concurrent writes to both stdout and the output file.
4.7. Retry Mechanism
The -attempts flag (default: 1) controls the total number of DNS resolution attempts per subdomain. A value of 1 means no retries. A short linear backoff delay is applied between attempts to handle transient DNS failures. The deprecated -retries flag is still accepted as an alias but prints a warning to stderr.