15 Commits
v1.0.1 ... main

Author SHA1 Message Date
86553b5d61 Documentation improved with -j (stdout jsonl lines) and -debug information about CAIDA style error addresses 2025-01-20 13:51:00 -05:00
860241572c added round counting for when -l (loop) is enabled to track how many completions are processed 2025-01-20 13:47:16 -05:00
bc203bfb79 added fail reason to in-addr.arpa domain for logging 2025-01-20 02:52:11 -05:00
14955ffb2b added -j -json arg for cli json output for live pipeline ingestion usage 2025-01-05 07:05:27 -05:00
c78e1dd1b3 added -j -json arg for cli json output for live pipeline ingestion usage 2025-01-05 07:05:00 -05:00
ce0d30666b Fixed -debug output showing the error code and not the reason 2025-01-05 04:48:27 -05:00
d7fc56cb79 Better preview picture 2025-01-05 04:38:28 -05:00
ea6fe3ddc6 Fixed padding, updated preview picture 2025-01-05 04:36:53 -05:00
2f4438c213 added elapsed timed and ttl colors based on the values 2025-01-05 04:31:09 -05:00
2669371ef4 added preview back 2025-01-05 04:11:28 -05:00
e4a083b01a Readme revamp 2025-01-05 04:09:02 -05:00
5cc5349f4b Added TTL values 2025-01-05 03:59:48 -05:00
6eab4a29f4 Improved DNS handling, pull servers from trickest when none supplied, added CNAME handling, updated preview picture 2025-01-05 03:51:45 -05:00
bd6b92f179 Added preview gif 2024-12-31 18:53:18 -05:00
0b17ea524d Fixed go installs 2024-12-31 18:43:51 -05:00
5 changed files with 703 additions and 168 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

149
README.md
View File

@@ -1,6 +1,20 @@
# PTR Stream
# PTRStream
> High-performance distributed PTR record scanner with real-time streaming output
PTR Stream is a high-performance reverse DNS *(PTR record)* lookup tool written in Go. It efficiently processes the entire IPv4 address space, performing concurrent DNS lookups with support for custom DNS servers, output logging, and real-time progress visualization.
![](./.screens/preview.gif)
PTRStream is a fast and efficient PTR record scanner designed for distributed scanning operations. It uses a Linear Congruential Generator *(LCG)* for deterministic IP generation, allowing for easy distribution of work across multiple machines while maintaining pseudo-random ordering.
## Features
- Memory-efficient IP range processing using [GoLCG](https://github.com/acidvegas/golcg)
- Distributed scanning support via sharding
- Real-time NDJSON output for streaming to data pipelines
- Support for both PTR and CNAME records
- Automatic DNS server rotation from public resolvers
- Progress tracking with detailed statistics
- Colorized terminal output
- CAIDA-style error formatting (with -debug flag)
## Installation
@@ -8,32 +22,123 @@ PTR Stream is a high-performance reverse DNS *(PTR record)* lookup tool written
go install github.com/acidvegas/ptrstream@latest
```
Or, build from source:
```bash
git clone https://github.com/acidvegas/ptrstream
cd ptrstream
go build
```
## Options
| Flag | Type | Default | Description |
|----------|----------|---------|--------------------------------------------|
| `-c` | `int` | `100` | Concurrency level |
| `-debug` | `bool` | `false` | Show unsuccessful lookups |
| `-dns` | `string` | | File containing DNS servers |
| `-j` | `bool` | `false` | Output NDJSON to stdout (no TUI) |
| `-l` | `bool` | `false` | Loop continuously after completion |
| `-o` | `string` | | Path to NDJSON output file |
| `-r` | `int` | `2` | Number of retries for failed lookups |
| `-s` | `int` | `0` | Seed for IP generation *(0 for random)* |
| `-shard` | `string` | | Shard specification *(index/total format)* |
| `-t` | `int` | `2` | Timeout for DNS queries |
## Usage
```bash
ptrstream [options]
# Basic usage
ptrstream -o output.json
# Use specific DNS servers
ptrstream -dns resolvers.txt -o output.json
# Increase concurrency
ptrstream -c 200 -o output.json
# Distributed scanning (4 machines)
# Machine 1:
ptrstream -shard 1/4 -s 12345 -o shard1.json
# Machine 2:
ptrstream -shard 2/4 -s 12345 -o shard2.json
# Machine 3:
ptrstream -shard 3/4 -s 12345 -o shard3.json
# Machine 4:
ptrstream -shard 4/4 -s 12345 -o shard4.json
```
###### Command Line Arguments
| Flag | Description | Default | Example |
|---------|--------------------------------------|---------|------------------------|
| `-c` | Concurrency level | `100` | `-c 200` |
| `-t` | Timeout for DNS queries | `2s` | `-t 5s` |
| `-r` | Number of retries for failed lookups | `2` | `-r 3` |
| `-dns` | File containing DNS servers | | `-dns nameservers.txt` |
| `-debug`| Show unsuccessful lookups | `False` | `-debug` |
| `-o` | Path to NDJSON output file | | `-o results.json` |
| `-s` | Seed for IP generation | Random | `-s 12345` |
| `-shard`| Shard specification | | `-shard 1/4` |
## Distributed Scanning
---
PTRStream supports distributed scanning through its sharding system. By using the same seed value across multiple instances with different shard specifications, you can distribute the workload across multiple machines while ensuring:
- No IP address is scanned twice
- Even distribution of work
- Deterministic results
- Pseudo-random scanning patterns
For example, to split the work across 4 machines:
```bash
# Each machine uses the same seed but different shard
ptrstream -shard 1/4 -s 12345 # Machine 1
ptrstream -shard 2/4 -s 12345 # Machine 2
ptrstream -shard 3/4 -s 12345 # Machine 3
ptrstream -shard 4/4 -s 12345 # Machine 4
```
## Real-time Data Pipeline Integration
PTRStream outputs NDJSON *(Newline Delimited JSON)* format, making it perfect for real-time data pipeline integration. Each line contains a complete JSON record with:
- Timestamp
- IP Address
- DNS Server used
- Record Type *(PTR/CNAME)*
- PTR Record
- CNAME Target *(if applicable)*
- TTL Value
Example using named pipe to Elasticsearch:
```bash
# Create a named pipe
mkfifo /tmp/ptrstream
# Start Elasticsearch ingestion in background
cat /tmp/ptrstream | elasticsearch-bulk-import &
# Run PTRStream with pipe output
ptrstream -o /tmp/ptrstream
```
## CNAME Support
PTRStream properly handles CNAME records in PTR responses, providing:
- Detection of CNAME chains
- Original hostname and target tracking
- TTL values for both record types
- Distinct coloring in terminal output
- CNAME statistics tracking
Example NDJSON output:
```json
{"timestamp":"2024-01-05T12:34:56Z","ip_addr":"1.2.3.4","dns_server":"8.8.8.8","ptr_record":"example.com","record_type":"PTR","ttl":3600}
{"timestamp":"2024-01-05T12:34:57Z","ip_addr":"5.6.7.8","dns_server":"1.1.1.1","ptr_record":"original.com","record_type":"CNAME","target":"target.com","ttl":600}
```
## Debug Mode
When running with `-debug`, failed lookups are displayed and logged using CAIDA-style error formatting. Each error is represented as a special `.in-addr.arpa` address:
```
2024-01-05 12:34:56 │ 1.2.3.4 │ 8.8.8.8 │ ERR │ │ FAIL.TIMEOUT.in-addr.arpa
2024-01-05 12:34:57 │ 5.6.7.8 │ 1.1.1.1 │ ERR │ │ FAIL.SERVER-FAILURE.in-addr.arpa
2024-01-05 12:34:58 │ 9.10.11.12 │ 8.8.4.4 │ ERR │ │ FAIL.NON-AUTHORITATIVE.in-addr.arpa
```
Error types include:
- `FAIL.TIMEOUT.in-addr.arpa` - DNS query timed out
- `FAIL.SERVER-FAILURE.in-addr.arpa` - DNS server returned an error
- `FAIL.NON-AUTHORITATIVE.in-addr.arpa` - No authoritative answer
- `FAIL.REFUSED.in-addr.arpa` - Query was refused
- `FAIL.NO-PTR-RECORD.in-addr.arpa` - No PTR record exists
- And more...
These errors are also included in the NDJSON output when using `-debug` with either `-o` or `-j`:
```json
{"seen":"2024-01-05T12:34:56Z","ip":"1.2.3.4","nameserver":"8.8.8.8","record":"FAIL.TIMEOUT.in-addr.arpa","record_type":"ERR","ttl":0}
```
___
###### Mirrors: [acid.vegas](https://git.acid.vegas/ptrstream) • [SuperNETs](https://git.supernets.org/acidvegas/ptrstream) • [GitHub](https://github.com/acidvegas/ptrstream) • [GitLab](https://gitlab.com/acidvegas/ptrstream) • [Codeberg](https://codeberg.org/acidvegas/ptrstream)

13
go.mod
View File

@@ -1,9 +1,10 @@
module ptrstream
module github.com/acidvegas/ptrstream
go 1.23.3
require (
github.com/acidvegas/golcg v1.0.1
github.com/miekg/dns v1.1.62
github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592
)
@@ -13,7 +14,11 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/term v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/tools v0.22.0 // indirect
)

19
go.sum
View File

@@ -8,6 +8,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592 h1:YIJ+B1hePP6AgynC5TcqpO0H9k3SSoZa2BGyL6vDUzM=
github.com/rivo/tview v0.0.0-20241103174730-c76f7879f592/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -19,34 +21,45 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -2,11 +2,11 @@ package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
@@ -16,18 +16,24 @@ import (
"time"
"github.com/acidvegas/golcg"
"github.com/miekg/dns"
"github.com/rivo/tview"
)
const defaultResolversURL = "https://raw.githubusercontent.com/trickest/resolvers/refs/heads/main/resolvers.txt"
type Config struct {
concurrency int
timeout time.Duration
retries int
dnsServers []string
serverIndex int
debug bool
outputFile *os.File
mu sync.Mutex
concurrency int
timeout time.Duration
retries int
dnsServers []string
serverIndex int
debug bool
outputFile *os.File
mu sync.Mutex
lastDNSUpdate time.Time
updateMu sync.Mutex
loop bool
}
type Stats struct {
@@ -35,10 +41,13 @@ type Stats struct {
total uint64
lastProcessed uint64
lastCheckTime time.Time
startTime time.Time
success uint64
failed uint64
cnames uint64
speedHistory []float64
mu sync.Mutex
round uint64
}
func (s *Stats) increment() {
@@ -53,7 +62,15 @@ func (s *Stats) incrementFailed() {
atomic.AddUint64(&s.failed, 1)
}
func (s *Stats) incrementCNAME() {
atomic.AddUint64(&s.cnames, 1)
}
func (c *Config) getNextServer() string {
if err := c.updateDNSServers(); err != nil {
fmt.Printf("Failed to update DNS servers: %v\n", err)
}
c.mu.Lock()
defer c.mu.Unlock()
@@ -66,14 +83,44 @@ func (c *Config) getNextServer() string {
return server
}
func loadDNSServers(filename string) ([]string, error) {
if filename == "" {
return nil, nil
func fetchDefaultResolvers() ([]string, error) {
resp, err := http.Get(defaultResolversURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch default resolvers: %v", err)
}
defer resp.Body.Close()
var resolvers []string
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
resolver := strings.TrimSpace(scanner.Text())
if resolver != "" {
resolvers = append(resolvers, resolver)
}
}
file, err := os.Open(filename)
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading default resolvers: %v", err)
}
return resolvers, nil
}
func loadDNSServers(dnsFile string) ([]string, error) {
if dnsFile == "" {
resolvers, err := fetchDefaultResolvers()
if err != nil {
return nil, err
}
if len(resolvers) == 0 {
return nil, fmt.Errorf("no default resolvers found")
}
return resolvers, nil
}
file, err := os.Open(dnsFile)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to open DNS servers file: %v", err)
}
defer file.Close()
@@ -81,63 +128,128 @@ func loadDNSServers(filename string) ([]string, error) {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
server := strings.TrimSpace(scanner.Text())
if server != "" && !strings.HasPrefix(server, "#") {
if !strings.Contains(server, ":") {
server += ":53"
}
if server != "" {
servers = append(servers, server)
}
}
if len(servers) == 0 {
return nil, fmt.Errorf("no valid DNS servers found in file")
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading DNS servers file: %v", err)
}
return servers, scanner.Err()
if len(servers) == 0 {
return nil, fmt.Errorf("no DNS servers found in file")
}
return servers, nil
}
func lookupWithRetry(ip string, cfg *Config) ([]string, string, error) {
server := cfg.getNextServer()
if server == "" {
return nil, "", fmt.Errorf("no DNS servers available")
}
type DNSResponse struct {
Names []string
Server string
RecordType string // "PTR" or "CNAME"
Target string // For CNAME records, stores the target
TTL uint32 // Add TTL field
}
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: cfg.timeout,
}
return d.DialContext(ctx, "udp", server)
},
func translateRcode(rcode int) string {
switch rcode {
case dns.RcodeSuccess:
return "Success"
case dns.RcodeFormatError:
return "Format Error"
case dns.RcodeServerFailure:
return "Server Failure"
case dns.RcodeNameError: // NXDOMAIN
return "No Such Domain"
case dns.RcodeNotImplemented:
return "Not Implemented"
case dns.RcodeRefused:
return "Query Refused"
default:
return fmt.Sprintf("DNS Error %d", rcode)
}
}
func lookupWithRetry(ip string, cfg *Config) (DNSResponse, string, error) {
var lastErr error
var lastServer string
for i := 0; i < cfg.retries; i++ {
ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout)
names, err := r.LookupAddr(ctx, ip)
cancel()
server := cfg.getNextServer()
if server == "" {
return DNSResponse{}, "", fmt.Errorf("no DNS servers available")
}
lastServer = server
if err == nil {
return names, server, nil
// Create DNS message
m := new(dns.Msg)
arpa, err := dns.ReverseAddr(ip)
if err != nil {
return DNSResponse{}, "", err
}
m.SetQuestion(arpa, dns.TypePTR)
m.RecursionDesired = true
// Create DNS client
c := new(dns.Client)
c.Timeout = cfg.timeout
// Make the query
r, _, err := c.Exchange(m, server)
if err != nil {
lastErr = err
continue
}
if i < cfg.retries-1 {
server = cfg.getNextServer()
if server == "" {
return nil, "", fmt.Errorf("no more DNS servers available")
if r.Rcode != dns.RcodeSuccess {
lastErr = fmt.Errorf("%s", translateRcode(r.Rcode))
continue
}
// Process the response
if len(r.Answer) > 0 {
var names []string
var ttl uint32
var isCNAME bool
var target string
for _, ans := range r.Answer {
switch rr := ans.(type) {
case *dns.PTR:
names = append(names, rr.Ptr)
ttl = rr.Hdr.Ttl
case *dns.CNAME:
isCNAME = true
names = append(names, rr.Hdr.Name)
target = rr.Target
ttl = rr.Hdr.Ttl
}
}
r = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: cfg.timeout,
}
return d.DialContext(ctx, "udp", server)
},
if len(names) > 0 {
if isCNAME {
return DNSResponse{
Names: names,
Server: server,
RecordType: "CNAME",
Target: strings.TrimSuffix(target, "."),
TTL: ttl,
}, server, nil
}
return DNSResponse{
Names: names,
Server: server,
RecordType: "PTR",
TTL: ttl,
}, server, nil
}
}
lastErr = fmt.Errorf("no PTR records found")
}
return nil, "", fmt.Errorf("lookup failed after %d retries", cfg.retries)
return DNSResponse{}, lastServer, lastErr
}
func reverse(ss []string) []string {
@@ -179,7 +291,7 @@ func colorizeIPInPtr(ptr, ip string) string {
matches := re.FindAllStringIndex(ptr, -1)
if matches == nil {
return "[green]" + ptr
return "[white]" + ptr
}
var result strings.Builder
@@ -187,7 +299,7 @@ func colorizeIPInPtr(ptr, ip string) string {
for _, match := range matches {
if match[0] > lastEnd {
result.WriteString("[green]")
result.WriteString("[white]")
result.WriteString(ptr[lastEnd:match[0]])
}
result.WriteString("[aqua]")
@@ -196,14 +308,21 @@ func colorizeIPInPtr(ptr, ip string) string {
}
if lastEnd < len(ptr) {
result.WriteString("[green]")
result.WriteString("[white]")
result.WriteString(ptr[lastEnd:])
}
finalResult := result.String()
finalResult = strings.ReplaceAll(finalResult, ".in-addr.arpa", ".[blue]in-addr.arpa")
finalResult = strings.ReplaceAll(finalResult, ".gov", ".[red]gov")
finalResult = strings.ReplaceAll(finalResult, ".mil", ".[red]mil")
if strings.HasSuffix(finalResult, ".in-addr.arpa") {
finalResult = finalResult[:len(finalResult)-13] + ".[blue]in-addr.arpa"
}
if strings.HasSuffix(finalResult, ".gov") {
finalResult = finalResult[:len(finalResult)-4] + ".[red]gov"
}
if strings.HasSuffix(finalResult, ".mil") {
finalResult = finalResult[:len(finalResult)-4] + ".[red]mil"
}
return finalResult
}
@@ -219,18 +338,21 @@ const maxBufferLines = 1000
func worker(jobs <-chan string, wg *sync.WaitGroup, cfg *Config, stats *Stats, textView *tview.TextView, app *tview.Application) {
defer wg.Done()
for ip := range jobs {
var names []string
var server string
var err error
timestamp := time.Now()
var response DNSResponse
var err error
var server string
if len(cfg.dnsServers) > 0 {
names, server, err = lookupWithRetry(ip, cfg)
response, server, err = lookupWithRetry(ip, cfg)
if idx := strings.Index(server, ":"); idx != -1 {
server = server[:idx]
}
} else {
names, err = net.LookupAddr(ip)
names, err := net.LookupAddr(ip)
if err == nil {
response = DNSResponse{Names: names, RecordType: "PTR"}
}
}
stats.increment()
@@ -238,32 +360,36 @@ func worker(jobs <-chan string, wg *sync.WaitGroup, cfg *Config, stats *Stats, t
if err != nil {
stats.incrementFailed()
if cfg.debug {
timestamp := time.Now().Format("2006-01-02 15:04:05")
errMsg := err.Error()
if idx := strings.LastIndex(errMsg, ": "); idx != -1 {
errMsg = errMsg[idx+2:]
}
debugLine := fmt.Sprintf("[gray]%s[-] [purple]%15s[-] [gray]│[-] [red]%s[-]\n",
timestamp,
errRecord := formatErrorAsHostname(err)
timeStr := time.Now().Format("2006-01-02 15:04:05")
line := fmt.Sprintf("[gray]%s [gray]│[-] [purple]%15s[-] [gray]│[-] [aqua]%-15s[-] [gray]│[-] [red] ERR [-] [gray]│[-] [gray]%-6s[-] [gray]│[-] [gray]%s[-]\n",
timeStr,
ip,
errMsg)
server,
"",
errRecord)
app.QueueUpdateDraw(func() {
fmt.Fprint(textView, debugLine)
fmt.Fprint(textView, line)
textView.ScrollToEnd()
})
// Write to NDJSON if enabled
writeNDJSON(cfg, time.Now(), ip, server, errRecord, "ERR", "", 0)
}
continue
}
if len(names) == 0 {
if len(response.Names) == 0 {
stats.incrementFailed()
if cfg.debug {
timestamp := time.Now().Format("2006-01-02 15:04:05")
debugLine := fmt.Sprintf("[gray]%s[-] [purple]%15s[-] [gray]│[-] [red]No PTR record[-]\n",
timestamp,
ip)
timeStr := time.Now().Format("2006-01-02 15:04:05")
line := fmt.Sprintf("[gray]%s [gray]│[-] [purple]%15s[-] [gray]│[-] [aqua]%-15s[-] [gray]│[-] [red] ERR [-] [gray]│[-] [gray]%-6s[-] [gray]│[-] [red]No PTR record[-]\n",
timeStr,
ip,
server,
"")
app.QueueUpdateDraw(func() {
fmt.Fprint(textView, debugLine)
fmt.Fprint(textView, line)
textView.ScrollToEnd()
})
}
@@ -273,9 +399,9 @@ func worker(jobs <-chan string, wg *sync.WaitGroup, cfg *Config, stats *Stats, t
stats.incrementSuccess()
ptr := ""
for _, name := range names {
for _, name := range response.Names {
if cleaned := strings.TrimSpace(strings.TrimSuffix(name, ".")); cleaned != "" {
ptr = cleaned
ptr = strings.ToLower(cleaned)
break
}
}
@@ -284,21 +410,31 @@ func worker(jobs <-chan string, wg *sync.WaitGroup, cfg *Config, stats *Stats, t
continue
}
writeNDJSON(cfg, timestamp, ip, server, ptr)
writeNDJSON(cfg, timestamp, ip, server, ptr, response.RecordType, response.Target, response.TTL)
timeStr := time.Now().Format("2006-01-02 15:04:05")
recordTypeColor := "[blue] PTR [-]"
if response.RecordType == "CNAME" {
stats.incrementCNAME()
recordTypeColor = "[fuchsia]CNAME[-]"
ptr = fmt.Sprintf("%s -> %s", strings.ToLower(ptr), strings.ToLower(response.Target))
}
var line string
if len(cfg.dnsServers) > 0 {
line = fmt.Sprintf("[gray]%s[-] [purple]%15s[-] [gray]│[-] [yellow]%15s[-] [gray]│[-] %s\n",
line = fmt.Sprintf("[gray]%s [gray]│[-] [purple]%15s[-] [gray]│[-] [aqua]%-15s[-] [gray]│[-] %-5s [gray]│[-] %s [gray]│[-] %s\n",
timeStr,
ip,
server,
recordTypeColor,
colorizeTTL(response.TTL),
colorizeIPInPtr(ptr, ip))
} else {
line = fmt.Sprintf("[gray]%s[-] [purple]%15s[-] [gray]│[-] %s\n",
line = fmt.Sprintf("[gray]%s [gray]│[-] [purple]%15s[-] [gray]│[-] %-5s [gray]│[-] %s [gray]│[-] %s\n",
timeStr,
ip,
recordTypeColor,
colorizeTTL(response.TTL),
colorizeIPInPtr(ptr, ip))
}
@@ -343,6 +479,38 @@ func parseShardArg(shard string) (int, int, error) {
return shardNum, totalShards, nil
}
func (c *Config) updateDNSServers() error {
c.updateMu.Lock()
defer c.updateMu.Unlock()
if time.Since(c.lastDNSUpdate) < 24*time.Hour {
return nil
}
resolvers, err := fetchDefaultResolvers()
if err != nil {
return err
}
if len(resolvers) == 0 {
return fmt.Errorf("no resolvers found in update")
}
for i, server := range resolvers {
if !strings.Contains(server, ":") {
resolvers[i] = server + ":53"
}
}
c.mu.Lock()
c.dnsServers = resolvers
c.serverIndex = 0
c.lastDNSUpdate = time.Now()
c.mu.Unlock()
return nil
}
func main() {
concurrency := flag.Int("c", 100, "Concurrency level")
timeout := flag.Duration("t", 2*time.Second, "Timeout for DNS queries")
@@ -352,6 +520,8 @@ func main() {
outputPath := flag.String("o", "", "Path to NDJSON output file")
seed := flag.Int64("s", 0, "Seed for IP generation (0 for random)")
shard := flag.String("shard", "", "Shard specification (e.g., 1/4 for first shard of 4)")
loop := flag.Bool("l", false, "Loop continuously after completion")
jsonOutput := flag.Bool("j", false, "Output NDJSON to stdout (no TUI)")
flag.Parse()
shardNum, totalShards, err := parseShardArg(*shard)
@@ -364,11 +534,26 @@ func main() {
*seed = time.Now().UnixNano()
}
servers, err := loadDNSServers(*dnsFile)
if err != nil {
fmt.Printf("Error loading DNS servers: %v\n", err)
return
}
for i, server := range servers {
if !strings.Contains(server, ":") {
servers[i] = server + ":53"
}
}
cfg := &Config{
concurrency: *concurrency,
timeout: *timeout,
retries: *retries,
debug: *debug,
concurrency: *concurrency,
timeout: *timeout,
retries: *retries,
debug: *debug,
dnsServers: servers,
lastDNSUpdate: time.Now(),
loop: *loop,
}
if *outputPath != "" {
@@ -381,16 +566,6 @@ func main() {
defer f.Close()
}
if *dnsFile != "" {
servers, err := loadDNSServers(*dnsFile)
if err != nil {
fmt.Printf("Error loading DNS servers: %v\n", err)
return
}
cfg.dnsServers = servers
fmt.Printf("Loaded %d DNS servers\n", len(servers))
}
app := tview.NewApplication()
textView := tview.NewTextView().
@@ -409,11 +584,12 @@ func main() {
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(textView, 0, 1, false).
AddItem(progress, 3, 0, false)
AddItem(progress, 4, 0, false)
stats := &Stats{
total: 1 << 32,
lastCheckTime: time.Now(),
startTime: time.Now(),
}
go func() {
@@ -456,23 +632,25 @@ func main() {
return
}
statsText := fmt.Sprintf(" [aqua]Count:[:-] [white]%s [gray]│[-] [aqua]Progress:[:-] [darkgray]%7.2f%%[-] [gray]│[-] [aqua]Rate:[:-] %s [gray]│[-] [aqua]Successful:[:-] [green]✓%s [-][darkgray](%5.1f%%)[-] [gray]│[-] [aqua]Failed:[:-] [red]✗%s [-][darkgray](%5.1f%%)[-] ",
// First line: stats
statsLine := fmt.Sprintf(" [aqua]Elapsed:[:-] [white]%s [gray]│[-] [aqua]Round:[:-] [white]%d [gray]│[-] [aqua]Count:[:-] [white]%s [gray]│[-] [aqua]Progress:[:-] [darkgray]%.2f%%[-] [gray]│[-] [aqua]Rate:[:-] %s [gray]│[-] [aqua]CNAMEs:[:-] [yellow]%s[-][darkgray] (%.1f%%)[-] [gray]│[-] [aqua]Successful:[:-] [green]✓ %s[-][darkgray] (%.1f%%)[-] [gray]│[-] [aqua]Failed:[:-] [red]✗ %s[-][darkgray] (%.1f%%)[-]\n",
formatDuration(time.Since(stats.startTime)),
atomic.LoadUint64(&stats.round)+1,
formatNumber(processed),
percent,
colorizeSpeed(avgSpeed),
formatNumber(atomic.LoadUint64(&stats.cnames)),
float64(atomic.LoadUint64(&stats.cnames))/float64(processed)*100,
formatNumber(success),
float64(success)/float64(processed)*100,
formatNumber(failed),
float64(failed)/float64(processed)*100)
textWidth := visibleLength(statsText)
barWidth := width - textWidth - 2 // -2 for the [] characters
// Ensure barWidth is at least 1
// Second line: progress bar
barWidth := width - 3 // -3 for the [] and space
if barWidth < 1 {
// If there's not enough space, just show the stats without the progress bar
progress.Clear()
fmt.Fprint(progress, statsText)
fmt.Fprint(progress, statsLine)
return
}
@@ -481,15 +659,13 @@ func main() {
filled = barWidth
}
bar := strings.Builder{}
bar.WriteString(statsText)
bar.WriteString("[")
bar.WriteString(strings.Repeat("█", filled))
bar.WriteString(strings.Repeat("░", barWidth-filled))
bar.WriteString("]")
barLine := fmt.Sprintf(" [%s%s]",
strings.Repeat("█", filled),
strings.Repeat("░", barWidth-filled))
// Combine both lines with explicit newline
progress.Clear()
fmt.Fprint(progress, bar.String())
fmt.Fprintf(progress, "%s%s", statsLine, barLine)
})
}
@@ -497,27 +673,169 @@ func main() {
}
}()
stream, err := golcg.IPStream("0.0.0.0/0", shardNum, totalShards, int(*seed), nil)
if err != nil {
fmt.Printf("Error creating IP stream: %v\n", err)
if *jsonOutput {
// JSON-only mode
jobs := make(chan string, cfg.concurrency)
var wg sync.WaitGroup
// Start workers
for i := 0; i < cfg.concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ip := range jobs {
var response DNSResponse
var err error
var server string
if len(cfg.dnsServers) > 0 {
response, server, err = lookupWithRetry(ip, cfg)
if idx := strings.Index(server, ":"); idx != -1 {
server = server[:idx]
}
} else {
names, err := net.LookupAddr(ip)
if err == nil {
response = DNSResponse{Names: names, RecordType: "PTR"}
}
}
if err != nil {
if cfg.debug {
errRecord := formatErrorAsHostname(err)
record := struct {
Seen string `json:"seen"`
IP string `json:"ip"`
Nameserver string `json:"nameserver"`
Record string `json:"record"`
RecordType string `json:"record_type"`
TTL uint32 `json:"ttl"`
}{
Seen: time.Now().Format(time.RFC3339),
IP: ip,
Nameserver: server,
Record: errRecord,
RecordType: "ERR",
TTL: 0,
}
if data, err := json.Marshal(record); err == nil {
fmt.Println(string(data))
}
}
continue
}
if len(response.Names) == 0 {
if cfg.debug {
record := struct {
Seen string `json:"seen"`
IP string `json:"ip"`
Nameserver string `json:"nameserver"`
Record string `json:"record"`
RecordType string `json:"record_type"`
TTL uint32 `json:"ttl"`
}{
Seen: time.Now().Format(time.RFC3339),
IP: ip,
Nameserver: server,
Record: "FAIL.NO-PTR-RECORD.in-addr.arpa",
RecordType: "ERR",
TTL: 0,
}
if data, err := json.Marshal(record); err == nil {
fmt.Println(string(data))
}
}
continue
}
ptr := ""
for _, name := range response.Names {
if cleaned := strings.TrimSpace(strings.TrimSuffix(name, ".")); cleaned != "" {
ptr = cleaned
break
}
}
if ptr == "" {
continue
}
record := struct {
Seen string `json:"seen"`
IP string `json:"ip"`
Nameserver string `json:"nameserver"`
Record string `json:"record"`
RecordType string `json:"record_type"`
TTL uint32 `json:"ttl"`
}{
Seen: time.Now().Format(time.RFC3339),
IP: ip,
Nameserver: server,
Record: response.Target,
RecordType: response.RecordType,
TTL: response.TTL,
}
if response.RecordType != "CNAME" {
record.Record = ptr
}
if data, err := json.Marshal(record); err == nil {
fmt.Println(string(data))
}
}
}()
}
// Feed IPs to workers
for {
stream, err := golcg.IPStream("0.0.0.0/0", shardNum, totalShards, int(*seed), nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating IP stream: %v\n", err)
return
}
for ip := range stream {
jobs <- ip
}
if !cfg.loop {
break
}
}
close(jobs)
wg.Wait()
return
}
jobs := make(chan string, cfg.concurrency)
go func() {
for {
stream, err := golcg.IPStream("0.0.0.0/0", shardNum, totalShards, int(*seed), nil)
if err != nil {
fmt.Printf("Error creating IP stream: %v\n", err)
return
}
for ip := range stream {
jobs <- ip
}
if !cfg.loop {
break
}
}
close(jobs)
}()
var wg sync.WaitGroup
for i := 0; i < cfg.concurrency; i++ {
wg.Add(1)
go worker(jobs, &wg, cfg, stats, textView, app)
}
go func() {
for ip := range stream {
jobs <- ip
}
close(jobs)
}()
go func() {
wg.Wait()
app.Stop()
@@ -538,14 +856,7 @@ func formatNumber(n uint64) string {
}
parts = append([]string{s[start:i]}, parts...)
}
formatted := strings.Join(parts, ",")
totalWidth := len(fmt.Sprint(1<<32)) + 3
for len(formatted) < totalWidth {
formatted = " " + formatted
}
return formatted
return strings.Join(parts, ",")
}
func colorizeSpeed(speed float64) string {
@@ -568,21 +879,30 @@ func visibleLength(s string) int {
return len(noColors)
}
func writeNDJSON(cfg *Config, timestamp time.Time, ip, server, ptr string) {
func writeNDJSON(cfg *Config, timestamp time.Time, ip, server, ptr, recordType, target string, ttl uint32) {
if cfg.outputFile == nil {
return
}
record := struct {
Timestamp string `json:"timestamp"`
IPAddr string `json:"ip_addr"`
DNSServer string `json:"dns_server"`
PTRRecord string `json:"ptr_record"`
Seen string `json:"seen"`
IP string `json:"ip"`
Nameserver string `json:"nameserver"`
Record string `json:"record"`
RecordType string `json:"record_type"`
TTL uint32 `json:"ttl"`
}{
Timestamp: timestamp.Format(time.RFC3339),
IPAddr: ip,
DNSServer: server,
PTRRecord: ptr,
Seen: timestamp.Format(time.RFC3339),
IP: ip,
Nameserver: server,
Record: target, // For CNAME records, use the target
RecordType: recordType,
TTL: ttl,
}
// If it's not a CNAME, use the PTR record
if recordType != "CNAME" {
record.Record = ptr
}
if data, err := json.Marshal(record); err == nil {
@@ -592,3 +912,95 @@ func writeNDJSON(cfg *Config, timestamp time.Time, ip, server, ptr string) {
cfg.mu.Unlock()
}
}
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
days := d / (24 * time.Hour)
d -= days * 24 * time.Hour
hours := d / time.Hour
d -= hours * time.Hour
minutes := d / time.Minute
d -= minutes * time.Minute
seconds := d / time.Second
var result string
if days > 0 {
if hours > 0 && minutes > 0 {
result = fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
} else if hours > 0 {
result = fmt.Sprintf("%dd %dh", days, hours)
} else {
result = fmt.Sprintf("%dd", days)
}
} else if hours > 0 {
if minutes > 0 {
result = fmt.Sprintf("%dh %dm", hours, minutes)
} else {
result = fmt.Sprintf("%dh", hours)
}
} else if minutes > 0 {
if seconds > 0 {
result = fmt.Sprintf("%dm %ds", minutes, seconds)
} else {
result = fmt.Sprintf("%dm", minutes)
}
} else {
result = fmt.Sprintf("%ds", seconds)
}
return result
}
func colorizeTTL(ttl uint32) string {
switch {
case ttl >= 86400: // 1 day or more
return fmt.Sprintf("[#00FF00::b]%-6d[-]", ttl) // Bright green with bold
case ttl >= 3600: // 1 hour or more
return fmt.Sprintf("[yellow]%-6d[-]", ttl)
case ttl >= 300: // 5 minutes or more
return fmt.Sprintf("[orange]%-6d[-]", ttl)
case ttl >= 60: // 1 minute or more
return fmt.Sprintf("[red]%-6d[-]", ttl)
default: // Less than 60 seconds
return fmt.Sprintf("[gray]%-6d[-]", ttl)
}
}
func formatErrorAsHostname(err error) string {
errMsg := err.Error()
if idx := strings.LastIndex(errMsg, ": "); idx != -1 {
errMsg = errMsg[idx+2:]
}
switch {
case strings.Contains(errMsg, "i/o timeout"):
return "FAIL.TIMEOUT.in-addr.arpa"
case strings.Contains(errMsg, "Server Failure"):
return "FAIL.SERVER-FAILURE.in-addr.arpa"
case strings.Contains(errMsg, "No Such Domain"):
return "FAIL.NON-AUTHORITATIVE.in-addr.arpa"
case strings.Contains(errMsg, "refused"):
return "FAIL.REFUSED.in-addr.arpa"
case strings.Contains(errMsg, "no such host"):
return "FAIL.NO-SUCH-HOST.in-addr.arpa"
case strings.Contains(errMsg, "connection refused"):
return "FAIL.CONNECTION-REFUSED.in-addr.arpa"
case strings.Contains(errMsg, "network is unreachable"):
return "FAIL.NETWORK-UNREACHABLE.in-addr.arpa"
case strings.Contains(errMsg, "no route to host"):
return "FAIL.NO-ROUTE.in-addr.arpa"
case strings.Contains(errMsg, "Format error"):
return "FAIL.FORMAT-ERROR.in-addr.arpa"
case strings.Contains(errMsg, "Not Implemented"):
return "FAIL.NOT-IMPLEMENTED.in-addr.arpa"
case strings.Contains(errMsg, "truncated"):
return "FAIL.TRUNCATED.in-addr.arpa"
default:
return fmt.Sprintf("FAIL.%s.in-addr.arpa", strings.ReplaceAll(strings.ToUpper(errMsg), " ", "-"))
}
}