├── .github
├── FUNDING.yml
├── codecov.yml
├── dependabot.yml
├── images
│ ├── demo.gif
│ ├── demo.gif-terminalizer.yml
│ ├── help.png
│ └── social.png
└── workflows
│ ├── codeql.yml
│ └── tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── integration
├── release
│ └── build-releases.sh
└── tests
│ ├── integration_test.go
│ └── main_test.go
├── internal
├── config
│ ├── config.go
│ ├── config_test.go
│ ├── constants.go
│ ├── options.go
│ ├── options_test.go
│ ├── serverlist.go
│ ├── serverlist_test.go
│ └── settings.go
├── dns
│ ├── dnsanswer.go
│ ├── dnsanswer_test.go
│ ├── resolver.go
│ ├── resolver_test.go
│ ├── servercontext.go
│ ├── servercontext_test.go
│ ├── template.go
│ └── template_test.go
├── dnsanitize
│ ├── dnsanitize.go
│ ├── dnsanitize_test.go
│ ├── ratelimiter.go
│ ├── ratelimiter_test.go
│ └── serverpool.go
├── report
│ ├── logging.go
│ ├── status.go
│ └── status_test.go
└── tty
│ ├── tty.go
│ └── tty_test.go
└── main.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: nil0x42
2 | custom: ["exdemia.com/donate-bitcoin", "paypal.me/nil0x42"]
3 |
--------------------------------------------------------------------------------
/.github/codecov.yml:
--------------------------------------------------------------------------------
1 | # Codecov configuration for DNSanity
2 | codecov:
3 | notify:
4 | require_ci_to_pass: yes # Only notify when CI succeeds
5 |
6 | coverage:
7 | precision: 2 # Two‑digit percentage precision
8 | round: down
9 | range: "75...100" # Acceptable project coverage range
10 | threshold: 1% # Allow 1 % drop before failing
11 |
12 | status:
13 | project: yes # Global project status
14 | patch: yes # Status for new/modified code
15 | changes: no # Ignore “lines changed” status
16 | ignore: # Extra ignore list for status calculation
17 | - "integration/release/*"
18 | - "binaries/*"
19 |
20 | comment:
21 | # Compact layout that still shows the essentials
22 | layout: "condensed_header, diff, flags, condensed_files, condensed_footer"
23 | behavior: default
24 | require_changes: no # Post a comment even if coverage unchanged
25 |
26 | ignore:
27 | # Ignore pre‑compiled binaries and release scripts
28 | - "**/binaries/*"
29 | - "integration/release/*"
30 | - "integration/tests/dnsanity_bin"
31 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/images/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nil0x42/dnsanity/6aef8044a0ea705c3fcaad709e93ea3837c58dcb/.github/images/demo.gif
--------------------------------------------------------------------------------
/.github/images/demo.gif-terminalizer.yml:
--------------------------------------------------------------------------------
1 | # The configurations that used for the recording, feel free to edit them
2 | config:
3 |
4 | # Specify a command to be executed
5 | # like `/bin/bash -l`, `ls`, or any other commands
6 | # the default is bash for Linux
7 | # or powershell.exe for Windows
8 | command: /bin/bash -l
9 |
10 | # Specify the current working directory path
11 | # the default is the current working directory path
12 | cwd: /tmp/yyy
13 |
14 | # Export additional ENV variables
15 | env:
16 | recording: true
17 |
18 | # Explicitly set the number of columns
19 | # or use `auto` to take the current
20 | # number of columns of your shell
21 | cols: 72
22 |
23 | # Explicitly set the number of rows
24 | # or use `auto` to take the current
25 | # number of rows of your shell
26 | rows: 21
27 |
28 | # Amount of times to repeat GIF
29 | # If value is -1, play once
30 | # If value is 0, loop indefinitely
31 | # If value is a positive number, loop n times
32 | repeat: 0
33 |
34 | # Quality
35 | # 1 - 100
36 | quality: 100
37 |
38 | # Delay between frames in ms
39 | # If the value is `auto` use the actual recording delays
40 | frameDelay: auto
41 |
42 | # Maximum delay between frames in ms
43 | # Ignored if the `frameDelay` isn't set to `auto`
44 | # Set to `auto` to prevent limiting the max idle time
45 | maxIdleTime: 10000
46 |
47 | # The surrounding frame box
48 | # The `type` can be null, window, floating, or solid`
49 | # To hide the title use the value null
50 | # Don't forget to add a backgroundColor style with a null as type
51 | frameBox:
52 | type: floating
53 | title: github.com/nil0x42/DNSanity
54 | style:
55 | backgroundColor: black
56 | border: 0px black solid
57 | # boxShadow: none
58 | # margin: 0px
59 |
60 | # Add a watermark image to the rendered gif
61 | # You need to specify an absolute path for
62 | # the image on your machine or a URL, and you can also
63 | # add your own CSS styles
64 | watermark:
65 | imagePath: null
66 | style:
67 | position: absolute
68 | right: 15px
69 | bottom: 15px
70 | width: 100px
71 | opacity: 0.9
72 |
73 | # Cursor style can be one of
74 | # `block`, `underline`, or `bar`
75 | cursorStyle: block
76 |
77 | # Font family
78 | # You can use any font that is installed on your machine
79 | # in CSS-like syntax
80 | fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace"
81 |
82 | # The size of the font
83 | fontSize: 18
84 |
85 | # The height of lines
86 | lineHeight: 1
87 |
88 | # The spacing between letters
89 | letterSpacing: 0
90 |
91 | # Theme
92 | # theme:
93 | # background: "transparent"
94 | # foreground: "#afafaf"
95 | # cursor: "#c7c7c7"
96 | # black: "#232628"
97 | # red: "#fc4384"
98 | # green: "#b3e33b"
99 | # yellow: "#ffa727"
100 | # blue: "#75dff2"
101 | # magenta: "#ae89fe"
102 | # cyan: "#708387"
103 | # white: "#d5d5d0"
104 | # brightBlack: "#626566"
105 | # brightRed: "#ff7fac"
106 | # brightGreen: "#c8ed71"
107 | # brightYellow: "#ebdf86"
108 | # brightBlue: "#75dff2"
109 | # brightMagenta: "#ae89fe"
110 | # brightCyan: "#b1c6ca"
111 | # brightWhite: "#f9f9f4"
112 |
113 | theme:
114 | # background & texte
115 | background: "transparent"
116 | foreground: "#afafaf" # gris clair
117 | cursor: "#FFFFFF" # blanc
118 |
119 | # Couleurs standard
120 | black: "#000000"
121 | red: "#AA0000"
122 | green: "#00AA00"
123 | yellow: "#AA5500"
124 | blue: "#0000AA"
125 | magenta: "#AA00AA"
126 | cyan: "#00AAAA"
127 | white: "#AAAAAA"
128 |
129 | # Couleurs vives (bright)
130 | brightBlack: "#444444"
131 | brightRed: "#FF4444"
132 | brightGreen: "#44FF44"
133 | brightYellow: "#FFFF44"
134 | brightBlue: "#4444FF"
135 | brightMagenta: "#FF44FF"
136 | brightCyan: "#44FFFF"
137 | brightWhite: "#FFFFFF"
138 |
139 | # Records, feel free to edit them
140 | records:
141 | - delay: 1
142 | content: "\r\e[35mDNSanity@localhost $ \e[33md"
143 | - delay: 77
144 | content: 'ns'
145 | - delay: 128
146 | content: 'an'
147 | - delay: 56
148 | content: 'it'
149 | - delay: 119
150 | content: 'y '
151 | - delay: 194
152 | content: '-l'
153 | - delay: 65
154 | content: 'is'
155 | - delay: 160
156 | content: 't '
157 | - delay: 101
158 | content: 'un'
159 | - delay: 283
160 | content: 'tr'
161 | - delay: 124
162 | content: 'us'
163 | - delay: 144
164 | content: 'te'
165 | - delay: 127
166 | content: 'd.'
167 | - delay: 87
168 | content: 'tx'
169 | - delay: 144
170 | content: "t\r\n\e[0;90m ▗▄▄▄ ▗▖ ▗▖ ▗▄▄▖ ▗▄▖ ▗▖ ▗▖▗▄▄▄▖▗▄▄▄▖▗▖ ▗▖\r\n ▐▌ █▐▛▚▖▐▌▐▌ ▐▌ ▐▌▐▛▚▖▐▌ █ █ ▝▚▞▘\r\n ▐▌ █▐▌ ▝▜▌ ▝▀▚▖▐▛▀▜▌▐▌ ▝▜▌ █ █ ▐▌\r\n ▐▙▄▄▀▐▌ ▐▌▗▄▄▞▘▐▌ ▐▌▐▌ ▐▌▗▄█▄▖ █ ▐▌\e[0m\r\n\r\n\r\n\e[1;97m* [step 1/2] Template validation\e[2;37m ⏳ 0s - ETA: --\r\n█ Run: 3 servers * 10 tests, max 500 req/s, 10000 jobs (0 busy)\r\n▏ Per server: max 10 req/s, never dropped (0 in pool)\r\n█ Per test: 2s timeout, up to 2 attempts -> 0% done (0/30)\r\n▏ │\e[32mOK: 0 (0%) \e[2;37m 0 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▋ │\e[32m \e[31m\e[2;37m│\e[0m"
171 | - delay: 501
172 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 1/2] Template validation\e[2;37m ⏳ 0s - ETA: <1m\r\n▎ Run: 3 servers * 10 tests, max 500 req/s, 10000 jobs (8 busy)\r\n▌ Per server: max 10 req/s, never dropped (3 in pool)\r\n█ Per test: 2s timeout, up to 2 attempts -> 30% done (9/30)\r\n▊ │\e[32mOK: 0 (0%) \e[2;37m 15 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▉ │\e[32m \e[31m\e[2;37m│\e[0m"
173 | - delay: 500
174 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 1/2] Template validation\e[2;37m ⏳ 1s - ETA: <1m\r\n█ Run: 3 servers * 10 tests, max 500 req/s, 10000 jobs (6 busy)\r\n▋ Per server: max 10 req/s, never dropped (3 in pool)\r\n▌ Per test: 2s timeout, up to 2 attempts -> 80% done (24/30)\r\n█ │\e[32mOK: 0 (0%) \e[2;37m 30 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▋ │\e[32m \e[31m\e[2;37m│\e[0m"
175 | - delay: 142
176 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 1/2] Template validation\e[2;37m ⏳ 1s - DONE\r\n█ Run: 3 servers * 10 tests, max 500 req/s, 10000 jobs (6 busy)\r\n▋ Per server: max 10 req/s, never dropped (3 in pool)\r\n▌ Per test: 2s timeout, up to 2 attempts -> 100% done (30/30)\r\n█ │\e[32mOK: 3 (100%) \e[2;37m 24 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▋ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m\e[2;37m│\e[0m\r\n\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 0s - ETA: --\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (0 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (0 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 0% done (0/10000)\r\n▏ │\e[32mOK: 0 (0%) \e[2;37m 0 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▋ │\e[32m \e[31m\e[2;37m│\e[0m"
177 | - delay: 501
178 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 0s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (500 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (500 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 3% done (378/10198)\r\n▊ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▉ │\e[32m \e[31m\e[2;37m│\e[0m"
179 | - delay: 500
180 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 1s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (500 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (500 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 4% done (442/10201)\r\n█ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 0 (0%)\e[2;37m│\r\n▋ │\e[32m \e[31m\e[2;37m│\e[0m"
181 | - delay: 500
182 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 1s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (558 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (558 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 9% done (781/8477)\r\n▋ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 194 (19%)\e[2;37m│\r\n▊ │\e[32m \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
183 | - delay: 499
184 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 2s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (558 busy)\r\n▊ Per server: max 2 req/s, dropped if any test fails (558 in pool)\r\n▎ Per test: 4s timeout, up to 2 attempts -> 10% done (910/8424)\r\n▋ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 200 (20%)\e[2;37m│\r\n▊ │\e[32m \e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
185 | - delay: 500
186 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 2s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (588 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (588 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 15% done (1295/8309)\r\n▉ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 222 (22%)\e[2;37m│\r\n▎ │\e[32m \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
187 | - delay: 502
188 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 3s - ETA: <1m\r\n▏ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (588 busy)\r\n▎ Per server: max 2 req/s, dropped if any test fails (588 in pool)\r\n▊ Per test: 4s timeout, up to 2 attempts -> 16% done (1390/8301)\r\n▎ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 223 (22%)\e[2;37m│\r\n▍ │\e[32m \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
189 | - delay: 499
190 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 3s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (610 busy)\r\n█ Per server: max 2 req/s, dropped if any test fails (610 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 21% done (1624/7583)\r\n▋ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 305 (30%)\e[2;37m│\r\n█ │\e[32m \e[31m⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
191 | - delay: 500
192 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 4s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (610 busy)\r\n▉ Per server: max 2 req/s, dropped if any test fails (610 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 23% done (1770/7565)\r\n▍ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 307 (30%)\e[2;37m│\r\n▍ │\e[32m \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
193 | - delay: 499
194 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 4s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (707 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 30% done (2255/7509)\r\n▌ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 327 (32%)\e[2;37m│\r\n▌ │\e[32m \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
195 | - delay: 499
196 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 5s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (707 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 31% done (2357/7509)\r\n▊ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 327 (32%)\e[2;37m│\r\n▋ │\e[32m \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
197 | - delay: 500
198 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 5s - ETA: <1m\r\n▍ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (639 busy)\r\n▊ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 33% done (2484/7357)\r\n▉ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 346 (34%)\e[2;37m│\r\n▌ │\e[32m \e[31m⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
199 | - delay: 501
200 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 6s - ETA: <1m\r\n▉ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (639 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▊ Per test: 4s timeout, up to 2 attempts -> 38% done (2717/6972)\r\n▉ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 389 (38%)\e[2;37m│\r\n▉ │\e[32m \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
201 | - delay: 500
202 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 6s - ETA: <1m\r\n▍ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (778 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▏ Per test: 4s timeout, up to 2 attempts -> 42% done (2944/6930)\r\n▊ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 398 (39%)\e[2;37m│\r\n▎ │\e[32m \e[31m⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
203 | - delay: 500
204 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 7s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (778 busy)\r\n█ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 46% done (3183/6913)\r\n▌ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 401 (40%)\e[2;37m│\r\n▏ │\e[32m \e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
205 | - delay: 500
206 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 7s - ETA: <1m\r\n▌ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (791 busy)\r\n▎ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▉ Per test: 4s timeout, up to 2 attempts -> 52% done (3639/6925)\r\n▎ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 401 (40%)\e[2;37m│\r\n▊ │\e[32m \e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
207 | - delay: 500
208 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 8s - ETA: <1m\r\n▌ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (791 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 53% done (3724/6925)\r\n▍ │\e[32mOK: 0 (0%) \e[2;37m 0 req/s\e[31m KO: 401 (40%)\e[2;37m│\r\n▏ │\e[32m \e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
209 | - delay: 499
210 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 8s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (773 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 60% done (4122/6802)\r\n▎ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 421 (42%)\e[2;37m│\r\n▊ │\e[32m \e[31m⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
211 | - delay: 501
212 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 9s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (773 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▉ Per test: 4s timeout, up to 2 attempts -> 62% done (4218/6802)\r\n▌ │\e[32mOK: 0 (0%) \e[2;37m 500 req/s\e[31m KO: 421 (42%)\e[2;37m│\r\n▎ │\e[32m \e[31m⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
213 | - delay: 500
214 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K172.64.36.59\r\n172.64.36.44\r\n172.64.36.9\r\n178.33.164.91\r\n84.233.182.251\r\n156.154.71.12\r\n103.86.99.100\r\n172.64.36.208\r\n195.63.103.144\r\n83.167.155.134\r\n212.122.52.11\r\n185.228.168.9\r\n194.168.4.123\r\n45.90.28.247\r\n45.90.28.227\r\n8.26.56.135\r\n195.214.240.136\r\n172.64.37.117\r\n95.174.101.212\r\n83.172.181.252\r\n8.26.56.25\r\n80.14.67.41\r\n172.64.37.139\r\n79.98.147.146\r\n80.66.120.155\r\n162.159.36.247\r\n80.87.33.242\r\n94.18.210.70\r\n78.155.172.11\r\n8.26.56.206\r\n8.26.56.186\r\n172.64.36.57\r\n198.153.194.1\r\n185.133.208.32\r\n93.240.228.186\r\n92.42.210.43\r\n8.20.247.85\r\n94.24.54.252\r\n185.93.180.140\r\n81.169.201.113\r\n89.109.232.83\r\n161.97.172.85\r\n85.249.6.122\r\n73.99.96.122\r\n45.90.30.248\r\n45.90.28.8\r\n47.176.183.12\r\n81.130.148.128\r\n81.201.51.140\r\n107.0.218.126\r\n85.214.52.216\r\n172.64.36.215\r\n64.16.44.102\r\n51.68.141.96\r\n213.125.56.198\r\n46.227.67.134\r\n45.90.30.250\r\n89.114.155.129\r\n93.190.224.140\r\n92.38.43.2\r\n194.50.50.3\r\n91.209.233.20\r\n195.136.206.181\r\n46.107.27.230\r\n103.196.38.8\r\n195.201.192.29\r\n91.228.126.134\r\n85.143.164.154\r\n12.171.191.58\r\n216.250.141.137\r\n204.106.240.4\r\n162.159.27.162\r\n123.176.98.140\r\n152.200.186.94\r\n41.23.113.90\r\n103.146.84.23\r\n61.238.97.254\r\n103.85.197.37\r\n78.107.30.33\r\n177.221.44.173\r\n8.20.247.51\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 9s - ETA: <1m\r\n▋ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (779 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▏ Per test: 4s timeout, up to 2 attempts -> 70% done (4635/6593)\r\n▋ │\e[32mOK: 81 (8%) \e[2;37m 500 req/s\e[31m KO: 447 (44%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣷ \e[31m⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
215 | - delay: 500
216 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K183.104.157.72\r\n78.110.157.178\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 10s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (779 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 71% done (4708/6593)\r\n▍ │\e[32mOK: 83 (8%) \e[2;37m 500 req/s\e[31m KO: 447 (44%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣿ \e[31m⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
217 | - delay: 500
218 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K156.154.71.22\r\n178.62.197.147\r\n172.64.37.133\r\n45.90.30.178\r\n45.90.28.208\r\n83.175.105.182\r\n156.154.71.25\r\n172.64.37.204\r\n172.64.37.88\r\n8.26.56.24\r\n217.113.195.244\r\n45.90.28.3\r\n77.43.73.228\r\n45.90.28.171\r\n45.90.28.186\r\n45.90.29.77\r\n90.183.151.107\r\n45.90.30.6\r\n89.25.240.230\r\n94.187.158.243\r\n195.21.137.153\r\n31.156.55.106\r\n109.5.33.66\r\n212.5.221.202\r\n45.90.30.189\r\n95.97.79.58\r\n172.64.36.79\r\n77.53.35.136\r\n173.223.99.3\r\n46.40.0.5\r\n45.90.29.88\r\n85.58.120.134\r\n46.226.143.86\r\n45.90.30.119\r\n8.26.56.182\r\n92.241.86.102\r\n81.93.141.162\r\n194.44.139.88\r\n185.124.200.195\r\n45.90.28.86\r\n172.64.37.13\r\n8.26.56.35\r\n96.7.137.162\r\n88.221.163.203\r\n13.76.130.172\r\n172.64.37.227\r\n176.107.118.206\r\n45.90.30.227\r\n92.182.5.167\r\n156.154.70.7\r\n77.51.186.203\r\n5.196.43.50\r\n8.43.56.34\r\n188.6.161.26\r\n91.194.138.18\r\n85.200.209.58\r\n66.11.107.97\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 10s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (681 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 76% done (4968/6512)\r\n▊ │\e[32mOK: 140 (14%) \e[2;37m 458 req/s\e[31m KO: 460 (46%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⡆ \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
219 | - delay: 499
220 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K126.249.83.70\r\n209.239.11.98\r\n103.146.84.39\r\n8.36.139.1\r\n200.111.82.197\r\n8.26.56.23\r\n103.93.150.184\r\n23.216.53.230\r\n202.87.213.253\r\n23.59.248.145\r\n8.20.247.170\r\n210.181.1.24\r\n85.175.98.233\r\n121.121.32.209\r\n89.236.235.54\r\n209.90.160.221\r\n8.20.247.79\r\n223.6.6.184\r\n112.172.7.207\r\n85.143.24.62\r\n203.230.220.2\r\n210.225.153.177\r\n182.156.93.102\r\n202.164.153.108\r\n202.87.214.253\r\n168.243.48.33\r\n123.200.11.90\r\n223.6.6.169\r\n96.7.136.77\r\n202.14.14.97\r\n219.163.11.226\r\n125.7.139.15\r\n182.237.214.37\r\n103.118.178.18\r\n223.5.5.41\r\n223.5.5.31\r\n172.64.36.187\r\n223.5.5.124\r\n101.198.198.198\r\n103.92.207.46\r\n45.90.29.119\r\n152.228.172.176\r\n194.2.0.50\r\n45.90.29.209\r\n217.27.214.8\r\n58.69.9.25\r\n8.20.247.251\r\n58.71.125.1\r\n8.242.6.242\r\n83.244.182.72\r\n31.22.0.186\r\n82.119.154.40\r\n64.45.190.98\r\n8.26.56.108\r\n122.55.34.214\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 11s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (487 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 79% done (5191/6509)\r\n▏ │\e[32mOK: 195 (19%) \e[2;37m 500 req/s\e[31m KO: 462 (46%)\e[2;37m│\r\n▎ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧ \e[31m⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
221 | - delay: 501
222 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K45.90.30.181\r\n45.76.35.187\r\n88.221.163.146\r\n8.26.56.222\r\n172.64.37.100\r\n45.90.30.132\r\n172.64.37.131\r\n86.32.120.135\r\n162.159.46.197\r\n91.229.62.10\r\n8.20.247.49\r\n45.90.30.23\r\n80.50.129.22\r\n92.61.44.7\r\n5.9.44.83\r\n167.71.34.203\r\n89.212.52.44\r\n92.182.113.155\r\n8.26.56.56\r\n172.64.47.102\r\n172.64.36.87\r\n129.7.81.40\r\n172.64.37.165\r\n45.90.30.219\r\n76.76.2.33\r\n37.9.170.176\r\n207.191.50.250\r\n172.64.46.22\r\n96.7.137.250\r\n172.64.37.77\r\n172.64.36.49\r\n92.45.47.114\r\n45.90.30.172\r\n172.64.36.174\r\n88.221.162.27\r\n45.90.29.163\r\n89.140.253.194\r\n50.192.13.172\r\n209.144.50.123\r\n176.9.11.56\r\n81.145.61.20\r\n41.87.147.158\r\n82.140.114.174\r\n212.5.196.234\r\n45.55.147.169\r\n205.171.2.65\r\n94.243.131.250\r\n212.58.26.33\r\n189.10.242.138\r\n62.183.54.78\r\n176.222.250.62\r\n65.114.81.96\r\n8.25.185.131\r\n96.95.146.25\r\n98.164.41.54\r\n169.255.135.218\r\n176.62.79.18\r\n64.72.212.20\r\n103.178.73.58\r\n8.20.247.149\r\n8.20.247.36\r\n3.105.53.51\r\n96.7.136.188\r\n103.178.73.249\r\n203.187.253.106\r\n8.20.247.139\r\n202.83.43.81\r\n103.145.164.50\r\n190.148.193.146\r\n41.174.182.214\r\n45.236.206.12\r\n223.6.6.68\r\n196.12.156.66\r\n113.161.180.214\r\n59.125.7.96\r\n103.145.164.153\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 11s - ETA: <1m\r\n▌ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (540 busy)\r\n▊ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▉ Per test: 4s timeout, up to 2 attempts -> 86% done (5461/6345)\r\n▏ │\e[32mOK: 271 (27%) \e[2;37m 381 req/s\e[31m KO: 484 (48%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄ \e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
223 | - delay: 500
224 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K117.2.18.50\r\n103.135.172.31\r\n103.166.75.136\r\n190.60.84.243\r\n77.39.229.161\r\n103.135.172.28\r\n45.117.63.46\r\n103.106.57.16\r\n203.150.199.17\r\n180.226.237.234\r\n124.105.154.238\r\n85.115.130.4\r\n103.237.147.46\r\n14.241.225.212\r\n80.15.95.220\r\n172.64.36.118\r\n45.90.28.180\r\n45.90.29.121\r\n112.173.44.139\r\n168.154.245.252\r\n173.223.98.180\r\n51.77.129.112\r\n154.14.16.251\r\n79.143.72.246\r\n45.90.28.77\r\n188.117.137.97\r\n77.51.209.243\r\n62.48.163.166\r\n92.59.185.58\r\n77.79.248.49\r\n5.134.71.229\r\n8.20.247.222\r\n93.92.202.187\r\n91.219.83.40\r\n12.200.199.75\r\n184.177.9.34\r\n162.253.133.97\r\n65.141.96.199\r\n27.131.191.90\r\n50.196.170.172\r\n200.1.104.36\r\n124.35.115.150\r\n122.18.242.92\r\n223.255.176.195\r\n121.58.246.247\r\n103.92.205.106\r\n78.30.245.221\r\n213.163.127.229\r\n183.99.226.197\r\n203.249.161.2\r\n91.226.223.235\r\n8.20.247.175\r\n45.63.86.216\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 12s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (468 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 88% done (5624/6343)\r\n▏ │\e[32mOK: 324 (32%) \e[2;37m 190 req/s\e[31m KO: 485 (48%)\e[2;37m│\r\n▋ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ \e[31m⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
225 | - delay: 500
226 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K103.106.56.186\r\n45.90.29.223\r\n172.64.46.202\r\n51.68.90.1\r\n45.90.30.30\r\n45.90.30.239\r\n5.189.162.97\r\n93.158.218.249\r\n45.90.28.67\r\n141.94.143.35\r\n172.64.36.157\r\n45.90.28.132\r\n96.7.136.199\r\n8.20.247.126\r\n45.90.29.182\r\n1.0.0.1\r\n172.64.46.209\r\n172.64.37.247\r\n45.90.30.62\r\n81.183.248.195\r\n80.147.181.115\r\n82.117.201.6\r\n81.7.16.240\r\n213.81.179.186\r\n37.114.34.106\r\n31.146.5.166\r\n82.71.226.4\r\n89.221.247.15\r\n216.136.82.113\r\n83.211.85.27\r\n85.120.87.50\r\n85.187.244.133\r\n5.28.131.134\r\n192.166.144.12\r\n94.131.210.104\r\n94.76.203.216\r\n67.227.132.89\r\n88.157.113.175\r\n50.198.75.41\r\n12.12.131.134\r\n41.185.21.252\r\n62.201.217.194\r\n218.208.114.28\r\n114.79.144.95\r\n186.236.102.88\r\n103.178.73.5\r\n187.157.84.101\r\n190.96.93.74\r\n122.211.89.209\r\n122.55.31.181\r\n167.157.20.2\r\n103.187.98.221\r\n14.161.36.134\r\n168.154.160.4\r\n223.5.5.159\r\n179.61.90.22\r\n103.140.24.148\r\n91.203.36.75\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 12s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (482 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 92% done (5787/6285)\r\n▊ │\e[32mOK: 382 (38%) \e[2;37m 344 req/s\e[31m KO: 494 (49%)\e[2;37m│\r\n▉ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
227 | - delay: 500
228 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K175.29.178.211\r\n172.64.36.144\r\n172.64.37.250\r\n8.20.247.202\r\n45.90.29.210\r\n172.64.36.235\r\n8.20.247.87\r\n172.64.46.62\r\n144.91.100.75\r\n8.26.56.221\r\n176.102.137.49\r\n88.119.142.115\r\n89.222.168.18\r\n78.131.87.208\r\n8.26.56.160\r\n77.241.17.70\r\n87.197.140.2\r\n91.191.248.98\r\n154.73.180.11\r\n197.159.180.2\r\n79.98.222.23\r\n189.8.108.104\r\n79.140.22.186\r\n94.20.230.175\r\n103.146.84.20\r\n168.227.41.231\r\n210.172.1.251\r\n191.36.233.100\r\n116.121.27.10\r\n8.20.247.201\r\n103.151.171.88\r\n218.146.34.200\r\n81.42.248.139\r\n122.54.69.130\r\n168.126.63.2\r\n103.145.165.91\r\n190.123.85.117\r\n118.103.239.9\r\n59.1.58.227\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 13s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (413 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 93% done (5859/6285)\r\n█ │\e[32mOK: 421 (42%) \e[2;37m 294 req/s\e[31m KO: 494 (49%)\e[2;37m│\r\n▋ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄ \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
229 | - delay: 500
230 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K82.223.149.133\r\n85.187.221.8\r\n213.223.36.242\r\n91.217.40.81\r\n190.144.158.18\r\n177.22.203.220\r\n47.49.148.38\r\n179.191.66.250\r\n203.237.176.1\r\n123.30.27.24\r\n183.81.163.114\r\n120.72.85.167\r\n18.163.103.200\r\n103.140.24.124\r\n190.151.76.90\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 13s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (339 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 95% done (5923/6184)\r\n▋ │\e[32mOK: 436 (43%) \e[2;37m 171 req/s\e[31m KO: 507 (50%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀ \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
231 | - delay: 500
232 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K210.121.229.1\r\n45.90.28.40\r\n103.20.28.2\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 14s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (334 busy)\r\n▊ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▎ Per test: 4s timeout, up to 2 attempts -> 95% done (5936/6184)\r\n▋ │\e[32mOK: 439 (43%) \e[2;37m 93 req/s\e[31m KO: 507 (50%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
233 | - delay: 499
234 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K178.134.248.178\r\n77.75.129.206\r\n93.159.183.102\r\n103.186.53.55\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 14s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (267 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 98% done (5982/6089)\r\n▉ │\e[32mOK: 443 (44%) \e[2;37m 52 req/s\e[31m KO: 522 (52%)\e[2;37m│\r\n▎ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇ \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
235 | - delay: 500
236 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K109.125.204.16\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 15s - ETA: <1m\r\n▏ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (257 busy)\r\n▎ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▊ Per test: 4s timeout, up to 2 attempts -> 98% done (5990/6089)\r\n▎ │\e[32mOK: 444 (44%) \e[2;37m 22 req/s\e[31m KO: 522 (52%)\e[2;37m│\r\n▍ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇ \e[31m⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
237 | - delay: 501
238 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 15s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (195 busy)\r\n█ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 98% done (6013/6097)\r\n▋ │\e[32mOK: 444 (44%) \e[2;37m 17 req/s\e[31m KO: 524 (52%)\e[2;37m│\r\n█ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇ \e[31m⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
239 | - delay: 500
240 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K5.96.198.66\r\n195.211.219.141\r\n109.241.116.68\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 16s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (170 busy)\r\n▉ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 98% done (6036/6100)\r\n▍ │\e[32mOK: 447 (44%) \e[2;37m 28 req/s\e[31m KO: 526 (52%)\e[2;37m│\r\n▍ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷ \e[31m⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
241 | - delay: 500
242 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K210.1.79.37\r\n185.45.244.221\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 16s - ETA: <1m\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (123 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 99% done (6057/6108)\r\n▌ │\e[32mOK: 449 (44%) \e[2;37m 26 req/s\e[31m KO: 528 (52%)\e[2;37m│\r\n▌ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ \e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
243 | - delay: 500
244 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K178.155.72.98\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 17s - ETA: <1m\r\n▊ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (85 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▍ Per test: 4s timeout, up to 2 attempts -> 99% done (6067/6108)\r\n▊ │\e[32mOK: 450 (45%) \e[2;37m 19 req/s\e[31m KO: 530 (53%)\e[2;37m│\r\n▋ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ \e[31m⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
245 | - delay: 500
246 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 17s - ETA: <1m\r\n▍ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (64 busy)\r\n▊ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 99% done (6075/6106)\r\n▉ │\e[32mOK: 450 (45%) \e[2;37m 11 req/s\e[31m KO: 532 (53%)\e[2;37m│\r\n▌ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ \e[31m⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
247 | - delay: 500
248 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K91.90.190.58\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 18s - ETA: <1m\r\n▉ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▌ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▊ Per test: 4s timeout, up to 2 attempts -> 99% done (6081/6109)\r\n▉ │\e[32mOK: 451 (45%) \e[2;37m 7 req/s\e[31m KO: 532 (53%)\e[2;37m│\r\n▉ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ \e[31m⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
249 | - delay: 499
250 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K223.27.110.214\r\n5.149.206.139\r\n\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 18s - ETA: <1m\r\n▍ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▍ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▏ Per test: 4s timeout, up to 2 attempts -> 99% done (6085/6105)\r\n▊ │\e[32mOK: 453 (45%) \e[2;37m 3 req/s\e[31m KO: 534 (53%)\e[2;37m│\r\n▎ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀\e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
251 | - delay: 501
252 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 19s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n█ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n█ Per test: 4s timeout, up to 2 attempts -> 99% done (6085/6105)\r\n▌ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 534 (53%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀\e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
253 | - delay: 500
254 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 19s - ETA: <1m\r\n▌ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▎ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▉ Per test: 4s timeout, up to 2 attempts -> 99% done (6089/6103)\r\n▎ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 538 (53%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
255 | - delay: 499
256 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 20s - ETA: <1m\r\n▌ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▌ Per test: 4s timeout, up to 2 attempts -> 99% done (6095/6098)\r\n▍ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 544 (54%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
257 | - delay: 501
258 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 20s - ETA: <1m\r\n▎ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▋ Per test: 4s timeout, up to 2 attempts -> 99% done (6097/6098)\r\n▎ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 546 (54%)\e[2;37m│\r\n▊ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
259 | - delay: 499
260 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 21s - DONE\r\n█ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▏ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▉ Per test: 4s timeout, up to 2 attempts -> 100% done (6098/6098)\r\n▌ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 547 (54%)\e[2;37m│\r\n▎ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
261 | - delay: 500
262 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 21s - DONE\r\n▋ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▏ Per test: 4s timeout, up to 2 attempts -> 100% done (6098/6098)\r\n▋ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 547 (54%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m"
263 | - delay: 34
264 | content: "\r\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\e[1A\e[2K\r\n\e[1;97m* [step 2/2] Servers sanitization\e[2;37m ⏳ 21s - DONE\r\n▋ Run: 1000 servers * 10 tests, max 500 req/s, 10000 jobs (44 busy)\r\n▋ Per server: max 2 req/s, dropped if any test fails (693 in pool)\r\n▏ Per test: 4s timeout, up to 2 attempts -> 100% done (6098/6098)\r\n▋ │\e[32mOK: 453 (45%) \e[2;37m 0 req/s\e[31m KO: 547 (54%)\e[2;37m│\r\n▏ │\e[32m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[31m⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿\e[2;37m│\e[0m\r\n\r\n\e[1;34m[*] Valid servers: 453/1000 (45.3%)\e[0m\r\n\e[?2004h\e[35mDNSanity@localhost $ \e[33m"
265 | - delay: 5000
266 | content: "\r\n"
267 |
--------------------------------------------------------------------------------
/.github/images/help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nil0x42/dnsanity/6aef8044a0ea705c3fcaad709e93ea3837c58dcb/.github/images/help.png
--------------------------------------------------------------------------------
/.github/images/social.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nil0x42/dnsanity/6aef8044a0ea705c3fcaad709e93ea3837c58dcb/.github/images/social.png
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 | permissions: read-all
14 | on:
15 | push:
16 | branches: [ "master" ]
17 | pull_request:
18 | branches: [ "master" ]
19 | schedule:
20 | - cron: '34 18 * * 0'
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze (${{ matrix.language }})
25 | # Runner size impacts CodeQL analysis time. To learn more, please see:
26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
27 | # - https://gh.io/supported-runners-and-hardware-resources
28 | # - https://gh.io/using-larger-runners (GitHub.com only)
29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31 | permissions:
32 | # required for all workflows
33 | security-events: write
34 |
35 | # required to fetch internal or private CodeQL packs
36 | packages: read
37 |
38 | # only required for workflows in private repositories
39 | actions: read
40 | contents: read
41 |
42 | strategy:
43 | fail-fast: false
44 | matrix:
45 | include:
46 | - language: actions
47 | build-mode: none
48 | - language: go
49 | build-mode: autobuild
50 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
51 | # Use `c-cpp` to analyze code written in C, C++ or both
52 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
53 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
54 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
55 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
56 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
57 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
58 | steps:
59 | - name: Checkout repository
60 | uses: actions/checkout@v4
61 |
62 | # Add any setup steps before running the `github/codeql-action/init` action.
63 | # This includes steps like installing compilers or runtimes (`actions/setup-node`
64 | # or others). This is typically only required for manual builds.
65 | # - name: Setup runtime (example)
66 | # uses: actions/setup-example@v1
67 |
68 | # Initializes the CodeQL tools for scanning.
69 | - name: Initialize CodeQL
70 | uses: github/codeql-action/init@v3
71 | with:
72 | languages: ${{ matrix.language }}
73 | build-mode: ${{ matrix.build-mode }}
74 | # If you wish to specify custom queries, you can do so here or in a config file.
75 | # By default, queries listed here will override any specified in a config file.
76 | # Prefix the list here with "+" to use these queries and those in the config file.
77 |
78 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
79 | # queries: security-extended,security-and-quality
80 |
81 | # If the analyze step fails for one of the languages you are analyzing with
82 | # "We were unable to automatically build your code", modify the matrix above
83 | # to set the build mode to "manual" for that language. Then modify this step
84 | # to build your code.
85 | # ℹ️ Command-line programs to run using the OS shell.
86 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
87 | - if: matrix.build-mode == 'manual'
88 | shell: bash
89 | run: |
90 | echo 'If you are using a "manual" build mode for one or more of the' \
91 | 'languages you are analyzing, replace this with the commands to build' \
92 | 'your code, for example:'
93 | echo ' make bootstrap'
94 | echo ' make release'
95 | exit 1
96 |
97 | - name: Perform CodeQL Analysis
98 | uses: github/codeql-action/analyze@v3
99 | with:
100 | category: "/language:${{matrix.language}}"
101 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | permissions:
3 | actions: write # needed by upload-artifact
4 | contents: read # to checkout
5 | on:
6 | push:
7 | branches: [ master ]
8 | pull_request:
9 | branches: [ master ]
10 | workflow_dispatch:
11 |
12 | jobs:
13 | test:
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | # Operating systems to test on
18 | os: [ ubuntu-24.04, ubuntu-22.04, macos-15, macos-13 ]
19 | # Go versions to validate against
20 | go: [ '1.21', '1.24' ]
21 | runs-on: ${{ matrix.os }}
22 | timeout-minutes: 5
23 |
24 | steps:
25 | # Checkout repository
26 | - uses: actions/checkout@v4
27 | name: Checkout sources
28 |
29 | # Set up Go environment
30 | - uses: actions/setup-go@v5
31 | name: Install Go
32 | with:
33 | go-version: ${{ matrix.go }}
34 | cache: true # Module and build cache
35 |
36 | # Run unit & integration tests with race detector and coverage
37 | - name: Run tests
38 | env:
39 | CGO_ENABLED: 1 # Needed for -race support
40 | run: |
41 | go test ./... -race -covermode=atomic -coverprofile=coverage.out
42 | # Print a quick coverage summary
43 | go tool cover -func=coverage.out | sort -r -k3 | head -n 20
44 |
45 | # Upload coverage file as CI artifact
46 | - uses: actions/upload-artifact@v4
47 | name: Upload coverage artifact
48 | with:
49 | name: coverage-${{ matrix.os }}-go${{ matrix.go }}
50 | path: coverage.out
51 |
52 | # Push coverage to Codecov
53 | - uses: codecov/codecov-action@v5
54 | name: Upload coverage to Codecov
55 | with:
56 | token: ${{ secrets.CODECOV_TOKEN }} # Set this secret in repo settings
57 | files: coverage.out
58 | flags: unit,${{ matrix.os }},go${{ matrix.go }}
59 | fail_ci_if_error: true
60 | verbose: true
61 |
62 | # Push coverage to qlty.sh
63 | - uses: qltysh/qlty-action/coverage@v1
64 | name: Upload coverage to qlty.sh
65 | with:
66 | token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
67 | files: coverage.out
68 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /custom-tests/*
2 | /binaries
3 | coverage.out
4 | integration/tests/dnsanity_bin
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 nil0x42
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
DNSanity :dart:
2 |
3 |
4 | Quickly validate DNS servers at scale
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
48 |
49 |
50 |
51 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
52 |
53 |
54 | ### :book: Overview
55 |
56 | **DNSanity** is a fast DNS resolvers validator, offering deep **customization**
57 | and reliable **concurrency**.
58 |
59 | - **Blazing-Fast**: Test thousand servers in parallel with **global & per-server rate-limiting**.
60 | - **Flexible**: Easily write your own template for custom validation.
61 | - **Reliable**: Automatic template re-validation before every usage.
62 |
63 |
64 |
65 |
66 | ### :arrows_clockwise: Workflow
67 |
68 | **1 – Template Validation**
69 | Run each template query against trusted DNS servers and ensure every answer matches the expected result.
70 |
71 | **2 – Server Validation**
72 | Run the same queries on every candidate server.
73 | Reject any server that fails more checks than the allowed limit.
74 |
75 |
76 |
77 |
78 | ### :bulb: Quick start
79 |
80 | ```bash
81 | go install github.com/nil0x42/dnsanity@latest # go 1.21+ needed
82 | dnsanity --help # show help
83 | dnsanity -list "untrustedDNS.txt" -o "out.txt" # basic usage
84 | ```
85 |
86 |
87 |
88 | ### :card_index: Custom template
89 |
90 | ```bash
91 | #
92 | cr.yp.to A=131.193.32.108 A=131.193.32.109 # two specific A records
93 | wiki.debian.org A=* CNAME=wilder.debian.org. # specific CNAME with any A record
94 | dn05jq2u.fr NXDOMAIN # invalid TLD: NXDOMAIN
95 | invalid.com SERVFAIL||NOERROR||TIMEOUT||FORMERR # allow any of these answers
96 | lists.isc.org A=149.20.* # A record matching pattern
97 | app-c0a801fb.nip.io A=192.168.1.251 # specific single A record
98 | retro.localtest.me A=127.0.0.1 # specific single A record
99 | ```
100 | DNSanity ships with a default template — each line states the expected DNS response for a domain.
101 | Need different rules? Supply your own file with `-template` option.
102 |
103 |
104 |
105 |
106 | ### :mag: Options
107 |
108 |
109 |
110 | ### :factory: Under the Hood
111 |
112 | **DNSanity** aims for maximum speed without sacrificing reliability
113 | or risking blacklisting. Here’s the core approach:
114 |
115 | - **Trusted Validation**
116 | Before checking your untrusted servers, DNSanity verifies the **template**
117 | itself against trusted resolvers (e.g., `8.8.8.8`, `1.1.1.1`).
118 | This ensures your template is valid and consistent.
119 | - **Test-by-Test Concurrency**
120 | For each untrusted server, DNSanity runs tests sequentially in
121 | an efficient pipeline. Once a server accumulates more mismatches than
122 | `-max-mismatches` *(default 0)*, it’s dropped immediately,
123 | saving time & bandwidth.
124 | - **Per-Server Rate Limit**
125 | Use `-ratelimit` so you don’t overload any single DNS server.
126 | This is especially helpful for fragile networks or for preventing
127 | blacklisting on public resolvers.
128 | - **Timeout & Retries**
129 | If a query doesn’t reply within `-timeout` seconds, it fails.
130 | If `-max-attempts` is greater than 1, DNSanity can retry,
131 | up to the specified limit.
132 |
133 |
134 |
135 | ### :information_source: Additional Tips
136 |
137 | - **Craft a Thorough Template**
138 | A varied template (involving A, CNAME, NXDOMAIN, and wildcard matches)
139 | quickly exposes shady or broken resolvers.
140 | - **Geo-Located Domains**
141 | Beware that some domains (e.g., google.com) may return different IP addresses
142 | based on location. This might cause expected results to mismatch.
143 | - **Fine-tune template validation step**
144 | `-trusted-*` flags allow fine-tuning specific limits for this step, which
145 | uses trusted server list (use `--help` for details)
146 |
147 |
148 |
149 | ### :star: Acknowledgments
150 |
151 | - **[dnsvalidator](https://github.com/vortexau/dnsvalidator)** – for the original concept of verifying DNS resolvers.
152 | - **[dnsx](https://github.com/projectdiscovery/dnsx)** – inspiration for a fast, multi-purpose DNS toolkit.
153 | - **[miekg/dns](https://github.com/miekg/dns)** – the Go library powering DNSanity queries under the hood.
154 |
155 | ---
156 |
157 | **Happy Recon & Hacking!**
158 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nil0x42/dnsanity
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.2
6 |
7 | require (
8 | github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2
9 | github.com/miekg/dns v1.1.65
10 | golang.org/x/sys v0.32.0
11 | golang.org/x/term v0.31.0
12 | )
13 |
14 | require (
15 | golang.org/x/mod v0.24.0 // indirect
16 | golang.org/x/net v0.39.0 // indirect
17 | golang.org/x/sync v0.13.0 // indirect
18 | golang.org/x/tools v0.32.0 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
2 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3 | github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 h1:CVuJwN34x4xM2aT4sIKhmeib40NeBPhRihNjQmpJsA4=
4 | github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
5 | github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
6 | github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
7 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
8 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
9 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
10 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
11 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
12 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
13 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
14 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
15 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
16 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
17 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
18 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
19 |
--------------------------------------------------------------------------------
/integration/release/build-releases.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #
3 | # This script builds several variants of the DNSanity binary and places them
4 | # under a "binaries/" subfolder. Each binary is named using the pattern:
5 | # dnsanity---
6 | # Example: dnsanity-mac-x64-v1.0.0
7 |
8 | set -euo pipefail
9 |
10 | # Step 1: Move to the root of the git repository, so that we run "go" in the right place.
11 | cd "$(git rev-parse --show-toplevel)"
12 |
13 | # Step 2: Retrieve the version string by calling "go run . --version"
14 | version="$(go run . --version | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || true)"
15 |
16 | if [[ -z "$version" ]]; then
17 | echo "Failed to extract version string!"
18 | exit 1
19 | fi
20 |
21 | # Step 3: Create the output folder
22 | mkdir -p binaries
23 |
24 | echo "Detected version: $version"
25 | echo "Building release binaries in the ./binaries directory ..."
26 |
27 | # ----- Build for mac-x64 -----
28 | echo "Building dnsanity-mac-x64-$version ..."
29 | GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o "binaries/dnsanity-mac-x64-$version" .
30 | echo " => Success! Created binaries/dnsanity-mac-x64-$version"
31 |
32 | # ----- Build for mac-arm64 -----
33 | echo "Building dnsanity-mac-arm64-$version ..."
34 | GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o "binaries/dnsanity-mac-arm64-$version" .
35 | echo " => Success! Created binaries/dnsanity-mac-arm64-$version"
36 |
37 | # ----- Build for linux-x64 -----
38 | echo "Building dnsanity-linux-x64-$version ..."
39 | GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o "binaries/dnsanity-linux-x64-$version" .
40 | echo " => Success! Created binaries/dnsanity-linux-x64-$version"
41 |
42 | echo "All builds completed successfully."
43 | echo "Generated binaries in ./binaries:"
44 | ls -1 binaries/
45 |
--------------------------------------------------------------------------------
/integration/tests/integration_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "testing"
5 | "os"
6 | "os/exec"
7 | "path/filepath"
8 | "strings"
9 | )
10 |
11 | // TestIntegrationBasic builds the main package of dnsanity and runs it with
12 | // a simple server list input, checking output.
13 | func TestIntegrationBasic(t *testing.T) {
14 | // 1) Build the main package explicitly using the module path or '.'
15 | // Adjust "github.com/nil0x42/dnsanity" to match your module path if needed.
16 | cmdBuild := exec.Command("go", "build", "-o", "dnsanity_bin", "github.com/nil0x42/dnsanity")
17 | cmdBuild.Env = os.Environ()
18 |
19 | // Optionally, ensure we build from the repo's root directory.
20 | // If your test runs from the root by default, you might not need this:
21 | // cmdBuild.Dir = "../.."
22 |
23 | out, err := cmdBuild.CombinedOutput()
24 | if err != nil {
25 | t.Fatalf("Failed to build dnsanity: %v\nOutput:\n%s", err, string(out))
26 | }
27 |
28 | // 2) Create a temporary file with some DNS servers
29 | tempDir := t.TempDir()
30 | testServersPath := filepath.Join(tempDir, "servers.txt")
31 | content := []byte("8.8.8.8\n1.1.1.1\n")
32 | if err := os.WriteFile(testServersPath, content, 0644); err != nil {
33 | t.Fatalf("Cannot write test server file: %v", err)
34 | }
35 |
36 | // 3) Run the newly built binary
37 | // We assume that 'main.go' uses the flags: -list, -o, etc.
38 | cmdRun := exec.Command(
39 | "./dnsanity_bin",
40 | "-list", testServersPath,
41 | "-o", "/dev/stdout",
42 | "-trusted-timeout", "4",
43 | "-trusted-ratelimit", "3",
44 | "-trusted-max-attempts", "3",
45 | )
46 | cmdRun.Env = os.Environ()
47 | runOut, runErr := cmdRun.CombinedOutput()
48 | if runErr != nil {
49 | t.Fatalf("Failed to run dnsanity: %v\nOutput:\n%s", runErr, string(runOut))
50 | }
51 |
52 | // 4) Analyze the output
53 | got := string(runOut)
54 | if !strings.Contains(got, "8.8.8.8") {
55 | t.Errorf("Expected output to contain 8.8.8.8, got:\n%s", got)
56 | }
57 | if !strings.Contains(got, "1.1.1.1") {
58 | t.Errorf("Expected output to contain 1.1.1.1, got:\n%s", got)
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/integration/tests/main_test.go:
--------------------------------------------------------------------------------
1 | // Fichier : integration/tests/main_integration_test.go
2 | package tests
3 |
4 | import (
5 | "bytes"
6 | "os"
7 | "os/exec"
8 | "path/filepath"
9 | "runtime"
10 | "testing"
11 | "time"
12 |
13 | "github.com/nil0x42/dnsanity/internal/config"
14 | )
15 |
16 | // binaryPath holds the path of the test‑compiled dnsanity binary.
17 | var binaryPath string
18 |
19 | // TestMain compiles the dnsanity CLI once for the whole test‑suite.
20 | // This avoids paying the “go run” build cost for every individual
21 | // sub‑test and guarantees we always test the exact same binary.
22 | func TestMain(m *testing.M) {
23 | tmpDir, err := os.MkdirTemp("", "dnsanity‑bin")
24 | if err != nil {
25 | panic(err)
26 | }
27 | defer os.RemoveAll(tmpDir)
28 |
29 | binName := "dnsanity_test_bin"
30 | if runtime.GOOS == "windows" {
31 | binName += ".exe"
32 | }
33 | binaryPath = filepath.Join(tmpDir, binName)
34 |
35 | build := exec.Command("go", "build", "-o", binaryPath, "github.com/nil0x42/dnsanity")
36 | build.Stdout, build.Stderr = os.Stdout, os.Stderr
37 | if err := build.Run(); err != nil {
38 | panic("failed to compile dnsanity test binary: " + err.Error())
39 | }
40 |
41 | os.Exit(m.Run())
42 | }
43 |
44 | // runCLI executes the pre‑built dnsanity binary with the supplied
45 | // arguments and returns (stdout+stderr, exit‑code).
46 | func runCLI(t *testing.T, args ...string) (string, int) {
47 | t.Helper()
48 |
49 | var out bytes.Buffer
50 | cmd := exec.Command(binaryPath, args...)
51 | cmd.Stdout = &out
52 | cmd.Stderr = &out
53 |
54 | // We never want a stray dnsanity instance to hang the test run.
55 | cmd.Env = os.Environ()
56 | err := cmd.Start()
57 | if err != nil {
58 | t.Fatalf("starting command: %v", err)
59 | }
60 |
61 | done := make(chan struct{})
62 | go func() {
63 | _ = cmd.Wait()
64 | close(done)
65 | }()
66 |
67 | select {
68 | case <-done:
69 | // no‑op
70 | case <-time.After(10 * time.Second):
71 | // Kill runaway processes so the test suite always terminates.
72 | _ = cmd.Process.Kill()
73 | t.Fatalf("command timed‑out: dnsanity %v", args)
74 | }
75 |
76 | exitCode := cmd.ProcessState.ExitCode()
77 |
78 | return out.String(), exitCode
79 | }
80 |
81 | /* -------------------------- ACTUAL TESTS -------------------------- */
82 |
83 | func TestHelpOutput(t *testing.T) {
84 | // Both short and long help flags should behave identically.
85 | for _, flag := range []string{"-h", "--help"} {
86 | flag := flag // capture
87 | t.Run(flag, func(t *testing.T) {
88 | out, code := runCLI(t, flag)
89 | if code != 0 {
90 | t.Fatalf("expected exit‑code 0, got %d\n%s", code, out)
91 | }
92 | // Sanity‑check the most important sections so that a future
93 | // maintainer cannot accidentally remove or rename them.
94 | for _, want := range []string{
95 | "Usage:",
96 | "GENERIC OPTIONS:",
97 | "SERVERS SANITIZATION:",
98 | "TEMPLATE VALIDATION:",
99 | } {
100 | if !bytes.Contains([]byte(out), []byte(want)) {
101 | t.Errorf("help text missing %q section\nFull output:\n%s", want, out)
102 | }
103 | }
104 | })
105 | }
106 | }
107 |
108 | func TestVersionMatchesConstant(t *testing.T) {
109 | out, code := runCLI(t, "-version")
110 | if code != 0 {
111 | t.Fatalf("expected exit‑code 0, got %d\n%s", code, out)
112 | }
113 | want := config.VERSION
114 | if !bytes.Contains([]byte(out), []byte(want)) {
115 | t.Fatalf("version output %q does not contain constant %q", out, want)
116 | }
117 | }
118 |
119 | func TestUnknownFlagFails(t *testing.T) {
120 | out, code := runCLI(t, "--definitely‑unknown‑flag")
121 | if code == 0 {
122 | t.Fatalf("expected non‑zero exit‑code with unknown flag\n%s", out)
123 | }
124 | if !bytes.Contains([]byte(out), []byte("flag provided but not defined")) {
125 | t.Errorf("unexpected stderr for unknown flag:\n%s", out)
126 | }
127 | }
128 |
129 | func TestMissingServerListFailsFast(t *testing.T) {
130 | out, code := runCLI(t /* no ‑list flag */)
131 | if code == 0 {
132 | t.Fatalf("expected failure without -list flag\n%s", out)
133 | }
134 | // We check for a fragment rather than the exact phrasing to avoid
135 | // brittle tests if wording changes slightly.
136 | if !bytes.Contains([]byte(out), []byte("server list")) {
137 | t.Errorf("expected error mentioning 'server list', got:\n%s", out)
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | // standard
5 | "os"
6 | "fmt"
7 | "flag"
8 | // external
9 | // local
10 | "github.com/nil0x42/dnsanity/internal/dns"
11 | "github.com/nil0x42/dnsanity/internal/tty"
12 | )
13 |
14 |
15 | type Config struct {
16 | Opts *Options
17 | TrustedDNSList []string
18 | UntrustedDNSList []string
19 | Template dns.Template
20 | OutputFile *os.File
21 | }
22 |
23 |
24 | func exitUsage(format string, a ...interface{}) {
25 | err := fmt.Errorf(format, a...)
26 | fmt.Fprintf(os.Stderr, "Error: %v\n", err)
27 | flag.Usage()
28 | os.Exit(1)
29 | }
30 |
31 |
32 | func Init() *Config {
33 | conf := &Config{}
34 | opts, err := ParseOptions()
35 | if err != nil {
36 | exitUsage("%w", err)
37 | }
38 |
39 | // TEMPLATE VALIDATION --------------------------------------------
40 | // -template
41 | if opts.Template == "" {
42 | conf.Template, err = dns.NewTemplate(DEFAULT_TEMPLATE)
43 | } else {
44 | conf.Template, err = dns.NewTemplateFromFile(opts.Template)
45 | }
46 | if err != nil {
47 | exitUsage("-template: %w", err)
48 | }
49 | // -trusted-list
50 | conf.TrustedDNSList, err = ParseServerList(opts.TrustedDNS)
51 | if err != nil {
52 | exitUsage("-trusted-list: %w", err)
53 | }
54 | // -trusted-timeout
55 | if opts.TrustedTimeout < 1 {
56 | exitUsage("-trusted-timeout: must be >= 1")
57 | }
58 | // -ratelimit
59 | if opts.TrustedRateLimit < 0 {
60 | exitUsage("-trusted-ratelimit: must be >= 0")
61 | }
62 | // -max-attempts
63 | if opts.TrustedAttempts < 1 {
64 | exitUsage("-trusted-max-attempts: must be >= 1")
65 | }
66 |
67 | // SERVERS SANITIZATION -------------------------------------------
68 | // -list
69 | if opts.UntrustedDNS == "/dev/stdin" {
70 | if tty.IsTTY(os.Stdin) {
71 | if opts.Verbose { // show template if -verbose
72 | tty.SmartFprintf(os.Stderr, "%s\n", conf.Template.PrettyDump())
73 | fmt.Fprintf(os.Stderr, "Use `--help` to learn how to use DNSanity\n")
74 | os.Exit(1)
75 | } else {
76 | exitUsage("-list: Required unless passed through STDIN")
77 | }
78 | }
79 | }
80 | conf.UntrustedDNSList, err = ParseServerList(opts.UntrustedDNS)
81 | if err != nil {
82 | exitUsage("-list: %w", err)
83 | }
84 | // -timeout
85 | if opts.Timeout < 1 {
86 | exitUsage("-timeout: must be >= 1")
87 | }
88 | // -ratelimit
89 | if opts.RateLimit < 0 {
90 | exitUsage("-ratelimit: must be >= 0")
91 | }
92 | // -max-attempts
93 | if opts.Attempts < 1 {
94 | exitUsage("-max-attempts: must be >= 1")
95 | }
96 | // -max-mismatches
97 | if opts.MaxMismatches < 0 {
98 | exitUsage("-max-mismatches: must be >= 0")
99 | }
100 |
101 | // GENERIC OPTIONS ------------------------------------------------
102 | // -o
103 | conf.OutputFile, err = OpenFile(opts.OutputFilePath)
104 | if err != nil {
105 | exitUsage("-o: %w", err)
106 | }
107 | // -global-ratelimit
108 | if opts.GlobRateLimit < 1 {
109 | exitUsage("-global-ratelimit: must be >= 1")
110 | }
111 | // -threads
112 | if opts.Threads == -0xdead {
113 | opts.Threads = opts.GlobRateLimit * 20 // default
114 | } else if opts.Threads < 1 {
115 | exitUsage("-threads: must be >= 1")
116 | }
117 | // -max-poolsize
118 | if opts.MaxPoolSize == -0xdead {
119 | opts.MaxPoolSize = opts.GlobRateLimit * 20 // default
120 | } else if opts.MaxPoolSize < 1 {
121 | exitUsage("-max-poolsize: must be >= 1")
122 | }
123 |
124 |
125 | conf.Opts = opts
126 | return conf
127 | }
128 |
129 |
130 | func OpenFile(path string) (*os.File, error) {
131 | if path == "" || path == "-" || path == "/dev/stdout" {
132 | return os.Stdout, nil
133 | }
134 | return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
135 | }
136 |
--------------------------------------------------------------------------------
/internal/config/config_test.go:
--------------------------------------------------------------------------------
1 | // Copyright 2025
2 | // SPDX‑License‑Identifier: MIT
3 |
4 | // Ce fichier teste exhaustivement internal/config/config.go (100 % de couverture).
5 | // Les scénarios incluent :
6 | //
7 | // * Variantes d’OpenFile (stdout, fichier temporaire, erreur).
8 | // * Chemins Init : succès minimal, template externe.
9 | // * Tous les « early‑exit » (exitUsage) : trusted‑list invalide, oubli de -list,
10 | // chemin -o impossible… Les branches exit sont couvertes en sous‑processus
11 | // afin de ne pas interrompre la session de test et pour conserver la
12 | // couverture (via GOCOVERDIR).
13 | //
14 | // Aucune dépendance externe n’est utilisée.
15 |
16 | package config_test
17 |
18 | import (
19 | "flag"
20 | "io"
21 | "os"
22 | "os/exec"
23 | "path/filepath"
24 | "testing"
25 |
26 | "github.com/nil0x42/dnsanity/internal/config"
27 | )
28 |
29 | // helperResetFlags resets the global FlagSet between tests.
30 | func helperResetFlags(args []string) {
31 | os.Args = args
32 | flag.CommandLine = flag.NewFlagSet(args[0], flag.ExitOnError)
33 | }
34 |
35 | // ---------------------------------------------------------------------------
36 | // OpenFile() unit‑tests
37 | // ---------------------------------------------------------------------------
38 |
39 | func TestOpenFileVariants(t *testing.T) {
40 | stdoutCases := []string{"", "-", "/dev/stdout"}
41 | for _, p := range stdoutCases {
42 | f, err := config.OpenFile(p)
43 | if err != nil {
44 | t.Fatalf("OpenFile(%q) returned error: %v", p, err)
45 | }
46 | if f != os.Stdout {
47 | t.Fatalf("OpenFile(%q) expected Stdout, got %+v", p, f)
48 | }
49 | }
50 |
51 | // Successful creation on a real file.
52 | filePath := filepath.Join(t.TempDir(), "out.txt")
53 | f, err := config.OpenFile(filePath)
54 | if err != nil {
55 | t.Fatalf("OpenFile(tempFile) unexpected error: %v", err)
56 | }
57 | defer f.Close()
58 | if _, err := io.WriteString(f, "hello"); err != nil {
59 | t.Fatalf("cannot write temp file: %v", err)
60 | }
61 |
62 | // Error path: directory does not exist.
63 | badPath := filepath.Join(t.TempDir(), "no", "such", "dir", "file.txt")
64 | if _, err := config.OpenFile(badPath); err == nil {
65 | t.Fatalf("OpenFile(%q) should fail but succeeded", badPath)
66 | }
67 | }
68 |
69 | // ---------------------------------------------------------------------------
70 | // Successful Init() paths (run in‑process)
71 | // ---------------------------------------------------------------------------
72 |
73 | func TestInitSuccessBasic(t *testing.T) {
74 | // Prepare a minimal untrusted list file.
75 | listFile := filepath.Join(t.TempDir(), "list.txt")
76 | if err := os.WriteFile(listFile, []byte("8.8.8.8\n"), 0644); err != nil {
77 | t.Fatalf("cannot write list file: %v", err)
78 | }
79 | outFile := filepath.Join(t.TempDir(), "out.txt")
80 |
81 | helperResetFlags([]string{
82 | "dnsanity",
83 | "-list", listFile,
84 | "-o", outFile,
85 | })
86 | conf := config.Init()
87 | if len(conf.UntrustedDNSList) != 1 || conf.UntrustedDNSList[0] != "8.8.8.8" {
88 | t.Fatalf("unexpected UntrustedDNSList: %+v", conf.UntrustedDNSList)
89 | }
90 | if conf.OutputFile == os.Stdout {
91 | t.Fatalf("OutputFile should be custom, got Stdout")
92 | }
93 | }
94 |
95 | func TestInitWithExternalTemplate(t *testing.T) {
96 | // Template with a single entry.
97 | tplFile := filepath.Join(t.TempDir(), "tpl.txt")
98 | templ := "example.com A=1.2.3.4\n"
99 | if err := os.WriteFile(tplFile, []byte(templ), 0644); err != nil {
100 | t.Fatalf("cannot write template file: %v", err)
101 | }
102 | listFile := filepath.Join(t.TempDir(), "list.txt")
103 | if err := os.WriteFile(listFile, []byte("1.1.1.1\n"), 0644); err != nil {
104 | t.Fatalf("cannot write list file: %v", err)
105 | }
106 |
107 | helperResetFlags([]string{
108 | "dnsanity",
109 | "-list", listFile,
110 | "-template", tplFile,
111 | })
112 | conf := config.Init()
113 | if conf.Template == nil || len(conf.Template) != 1 {
114 | t.Fatalf("template not loaded correctly, len=%d", len(conf.Template))
115 | }
116 | }
117 |
118 | // ---------------------------------------------------------------------------
119 | // exitUsage() branches – covered via helper process
120 | // ---------------------------------------------------------------------------
121 |
122 | func TestExitUsageScenarios(t *testing.T) {
123 | tmpDir := t.TempDir()
124 | scenarios := []struct {
125 | name string
126 | args []string
127 | }{
128 | {
129 | name: "invalid_trusted_list",
130 | args: []string{
131 | "-trusted-list", "256.256.256.256",
132 | "-list", "8.8.8.8",
133 | },
134 | },
135 | {
136 | name: "missing_list_stdin",
137 | args: []string{}, // No -list flag triggers /dev/stdin branch then failure
138 | },
139 | {
140 | name: "bad_output_path",
141 | args: []string{
142 | "-list", "8.8.8.8",
143 | "-o", filepath.Join(tmpDir, "no", "perm", "out.txt"),
144 | },
145 | },
146 | }
147 |
148 | for _, sc := range scenarios {
149 | t.Run(sc.name, func(t *testing.T) {
150 | // Re‑invoke the current test binary in a child process.
151 | cmdArgs := []string{
152 | "-test.run=TestHelperProcess",
153 | "--",
154 | }
155 | cmdArgs = append(cmdArgs, sc.args...)
156 | cmd := exec.Command(os.Args[0], cmdArgs...)
157 |
158 | // Propagate coverage directory for Go ≥1.20.
159 | if covDir := os.Getenv("GOCOVERDIR"); covDir != "" {
160 | cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1", "GOCOVERDIR="+covDir)
161 | } else {
162 | cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
163 | }
164 |
165 | err := cmd.Run()
166 | if err == nil {
167 | t.Fatalf("scenario %q: expected failure, got success", sc.name)
168 | }
169 | if ee, ok := err.(*exec.ExitError); ok && ee.Success() {
170 | t.Fatalf("scenario %q exited with status 0, expected non‑zero", sc.name)
171 | }
172 | })
173 | }
174 | }
175 |
176 | // TestHelperProcess is executed in the child process; it simply calls Init()
177 | // with the supplied arguments. It never returns on successful exitUsage
178 | // because os.Exit is invoked inside the library.
179 | func TestHelperProcess(t *testing.T) {
180 | if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
181 | return
182 | }
183 |
184 | // Strip everything up to "--".
185 | idx := 0
186 | for i, a := range os.Args {
187 | if a == "--" {
188 | idx = i + 1
189 | break
190 | }
191 | }
192 | userArgs := os.Args[idx:]
193 | os.Args = append([]string{"dnsanity"}, userArgs...)
194 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
195 |
196 | // Call Init(); any expected error will trigger exitUsage (os.Exit).
197 | config.Init()
198 |
199 | // If we reach here, exitUsage did not fire – exit with 0 for completeness.
200 | os.Exit(0)
201 | }
202 |
--------------------------------------------------------------------------------
/internal/config/constants.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const VERSION = "v1.3.0"
4 |
5 | const HEADER = `
6 | ▗▄▄▄ ▗▖ ▗▖ ▗▄▄▖ ▗▄▖ ▗▖ ▗▖▗▄▄▄▖▗▄▄▄▖▗▖ ▗▖
7 | ▐▌ █▐▛▚▖▐▌▐▌ ▐▌ ▐▌▐▛▚▖▐▌ █ █ ▝▚▞▘
8 | ▐▌ █▐▌ ▝▜▌ ▝▀▚▖▐▛▀▜▌▐▌ ▝▜▌ █ █ ▐▌
9 | ▐▙▄▄▀▐▌ ▐▌▗▄▄▞▘▐▌ ▐▌▐▌ ▐▌▗▄█▄▖ █ ▐▌
10 | `
11 |
12 | const DEFAULT_TEMPLATE = `
13 | #
14 |
15 | # Multiple A records
16 | cr.yp.to A=131.193.32.108 A=131.193.32.109
17 |
18 | # These A & CNAME records are expected:
19 | mbc.group.stanford.edu CNAME=web.stanford.edu. A=171.67.215.200
20 | wiki.debian.org CNAME=wilder.debian.org. A=*
21 |
22 | # # valid TLD, no records: SERVFAIL
23 | # dnssec-failed.org SERVFAIL # disabled, because non-dnssec-aware resolvers will fail here
24 |
25 | # be flexible here, some servers return NOERROR (with no records),
26 | # some timeout, some return formerr, anyway, we just want to ensure
27 | # here that server didn't put an IP that shouldn't exist:
28 | invalid.com SERVFAIL || NOERROR || TIMEOUT || FORMERR
29 |
30 | # # invalid TLD - NXDOMAIN is expected:
31 | dn05jq2u.fr NXDOMAIN
32 |
33 | # Single A record expected:
34 | bet365.com A=5.226.17*
35 | lists.isc.org A=149.20.*
36 | #www-78-46-204-247.sslip.io A=78.46.204.247 # unreliable, sometimes 9.9.9.9 fails on it
37 | app-c0a801fb.nip.io A=192.168.1.251
38 | retro.localtest.me A=127.0.0.1
39 |
40 | algolia.net A=103.254.154.6 A=149.202.84.123 A=*
41 |
42 | # PS: Beware of geo-located domains for reliable results !
43 | `
44 |
--------------------------------------------------------------------------------
/internal/config/options.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | // standard
5 | "flag"
6 | "fmt"
7 | "os"
8 |
9 | // external
10 | // local
11 | "github.com/nil0x42/dnsanity/internal/tty"
12 | )
13 |
14 | type Options struct {
15 | UntrustedDNS string
16 | TrustedDNS string
17 | Template string
18 | Threads int
19 | MaxPoolSize int
20 | Timeout int
21 | TrustedTimeout int
22 | GlobRateLimit int
23 | RateLimit float64
24 | TrustedRateLimit float64
25 | Attempts int
26 | MaxMismatches int
27 | TrustedAttempts int
28 | OutputFilePath string
29 | ShowHelp bool
30 | ShowVersion bool
31 | Verbose bool
32 | Debug bool
33 | }
34 |
35 | func ShowHelp() {
36 | var rst = "\033[0m"
37 | var bol = "\033[1m"
38 | var red = "\033[31m"
39 | // var grn = "\033[32m"
40 | var yel = "\033[33m"
41 | // var blu = "\033[34m"
42 | // var mag = "\033[35m"
43 | // var cya = "\033[36m"
44 | var gra = "\033[37m"
45 | // var dimgra = "\033[2;37m"
46 | var whi = "\033[97m"
47 | var s string
48 |
49 | s += fmt.Sprintf("\n")
50 | s += fmt.Sprintf(
51 | "%s%sDNSanity is a high-performance DNS validator using template-based verification%s\n",
52 | rst, bol, rst)
53 | s += fmt.Sprintf("\n")
54 |
55 | s += fmt.Sprintf(
56 | "Usage: %sdnsanity%s %s[OPTION]...%s\n",
57 | whi, rst, yel, rst)
58 | s += fmt.Sprintf(
59 | "Example: %sdnsanity%s %s-list%s /tmp/untrusted-dns.txt %s-o%s /tmp/trusted-dns.txt\n",
60 | whi, rst, yel, rst, yel, rst)
61 | s += fmt.Sprintf("\n")
62 |
63 | s += fmt.Sprintf(
64 | "%sGENERIC OPTIONS:%s\n",
65 | bol, rst)
66 | s += fmt.Sprintf(
67 | " %s-o%s %s[FILE]%s file to write output (defaults to %sSTDOUT%s)\n",
68 | yel, rst, gra, rst, yel, rst)
69 | s += fmt.Sprintf(
70 | " %s-global-ratelimit%s %sint%s global max requests per second (default %s500%s)\n",
71 | yel, rst, gra, rst, yel, rst)
72 | s += fmt.Sprintf(
73 | " %s-threads%s %sint%s max concurrency (default: %sauto%s) %s[experts only]%s\n",
74 | yel, rst, gra, rst, yel, rst, red, rst)
75 | s += fmt.Sprintf(
76 | " %s-max-poolsize%s %sint%s limit servers loaded in memory (default: %sauto%s) %s[experts only]%s\n",
77 | yel, rst, gra, rst, yel, rst, red, rst)
78 | s += fmt.Sprintf("\n")
79 |
80 | s += fmt.Sprintf(
81 | "%sSERVERS SANITIZATION:%s\n",
82 | bol, rst)
83 | s += fmt.Sprintf(
84 | " %s-list%s %s[FILE||str]%s list of DNS servers to sanitize (%sfile%s or %scomma separated%s or %sSTDIN%s)\n",
85 | yel, rst, gra, rst, yel, rst, yel, rst, yel, rst)
86 | s += fmt.Sprintf(
87 | " %s-timeout%s %sint%s timeout in seconds for DNS queries (default %s4%s)\n",
88 | yel, rst, gra, rst, yel, rst)
89 | s += fmt.Sprintf(
90 | " %s-ratelimit%s %sfloat%s max requests per second per DNS server (default %s2%s)\n",
91 | yel, rst, gra, rst, yel, rst)
92 | s += fmt.Sprintf(
93 | " %s-max-attempts%s %sint%s max attempts before marking a mismatching DNS test as failed (default %s2%s)\n",
94 | yel, rst, gra, rst, yel, rst)
95 | s += fmt.Sprintf(
96 | " %s-max-mismatches%s %sint%s max allowed mismatching DNS tests per server (default %s0%s)\n",
97 | yel, rst, gra, rst, yel, rst)
98 | s += fmt.Sprintf("\n")
99 |
100 | s += fmt.Sprintf(
101 | "%sTEMPLATE VALIDATION:%s\n",
102 | bol, rst)
103 | s += fmt.Sprintf(
104 | " %s-template%s %s[FILE]%s use a custom validation template instead of default one\n",
105 | yel, rst, gra, rst)
106 | s += fmt.Sprintf(
107 | " %s-trusted-list%s %s[FILE||str]%s list of TRUSTED servers (defaults to %s\"8.8.8.8, 1.1.1.1, 9.9.9.9\"%s)\n",
108 | yel, rst, gra, rst, yel, rst)
109 | s += fmt.Sprintf(
110 | " %s-trusted-timeout%s %sint%s timeout in seconds for TRUSTED servers (default %s2%s)\n",
111 | yel, rst, gra, rst, yel, rst)
112 | s += fmt.Sprintf(
113 | " %s-trusted-ratelimit%s %sfloat%s max requests per second per TRUSTED server (default %s10%s)\n",
114 | yel, rst, gra, rst, yel, rst)
115 | s += fmt.Sprintf(
116 | " %s-trusted-max-attempts%s %sint%s max attempts before marking a mismatching TRUSTED test as failed (default %s2%s)\n",
117 | yel, rst, gra, rst, yel, rst)
118 | s += fmt.Sprintf("\n")
119 |
120 | s += fmt.Sprintf(
121 | "%sDEBUG:%s\n",
122 | bol, rst)
123 | s += fmt.Sprintf(
124 | " %s-h, --help%s show help\n",
125 | yel, rst)
126 | s += fmt.Sprintf(
127 | " %s-version%s display version of dnsanity\n",
128 | yel, rst)
129 | s += fmt.Sprintf(
130 | " %s-verbose%s show template and servers status details (on STDERR)\n",
131 | yel, rst)
132 | s += fmt.Sprintf(
133 | " %s-debug%s show debugging information (on STDERR)\n",
134 | yel, rst)
135 | s += fmt.Sprintf("\n")
136 | tty.SmartFprintf(os.Stdout, s)
137 | }
138 |
139 | func ShowVersion() {
140 | tty.SmartFprintf(
141 | os.Stdout,
142 | "DNSanity %s \n",
143 | VERSION,
144 | )
145 | os.Exit(0)
146 | }
147 |
148 | func ParseOptions() (*Options, error) {
149 | opts := &Options{}
150 | // GENERIC OPTIONS
151 | flag.StringVar(&opts.OutputFilePath, "o", "/dev/stdout", "file to write output")
152 | flag.IntVar(&opts.GlobRateLimit, "global-ratelimit", 500, "global rate limit")
153 | flag.IntVar(&opts.Threads, "threads", -0xdead, "number of threads")
154 | flag.IntVar(&opts.MaxPoolSize, "max-poolsize", -0xdead, "limit servers loaded in memory")
155 | // SERVER SANITIZATION
156 | flag.StringVar(&opts.UntrustedDNS, "list", "/dev/stdin", "list of DNS servers to sanitize (file or comma separated or stdin)")
157 | flag.IntVar(&opts.Timeout, "timeout", 4, "timeout in seconds for DNS queries")
158 | flag.Float64Var(&opts.RateLimit, "ratelimit", 2.0, "max requests per second per DNS server")
159 | flag.IntVar(&opts.Attempts, "max-attempts", 2, "max attempts before marking a mismatching DNS test as failed")
160 | flag.IntVar(&opts.MaxMismatches, "max-mismatches", 0, "max allowed mismatching tests per DNS server")
161 | // TEMPLATE VALIDATION
162 | flag.StringVar(&opts.Template, "template", "", "path to the DNSanity validation template")
163 | flag.StringVar(&opts.TrustedDNS, "trusted-list", "8.8.8.8, 1.1.1.1, 9.9.9.9", "list of TRUSTED servers")
164 | flag.IntVar(&opts.TrustedTimeout, "trusted-timeout", 2, "timeout in seconds for TRUSTED servers")
165 | flag.Float64Var(&opts.TrustedRateLimit, "trusted-ratelimit", 10.0, "max requests per second per TRUSTED server")
166 | flag.IntVar(&opts.TrustedAttempts, "trusted-max-attempts", 2, "max attempts before marking a mismatching TRUSTED test as failed")
167 | // DEBUG
168 | flag.BoolVar(&opts.ShowHelp, "h", false, "show help")
169 | // flag.BoolVar(&opts.ShowFullHelp, "full-help", false, "show advanced help")
170 | flag.BoolVar(&opts.ShowVersion, "version", false, "display version of dnsanity")
171 | flag.BoolVar(&opts.Verbose, "verbose", false, "show configuration and template")
172 | flag.BoolVar(&opts.Debug, "debug", false, "enable debugging information")
173 |
174 | flag.Usage = ShowHelp
175 | flag.Parse()
176 |
177 | if opts.ShowHelp {
178 | flag.Usage()
179 | os.Exit(0)
180 | }
181 | if opts.ShowVersion {
182 | ShowVersion()
183 | }
184 | return opts, nil
185 | }
186 |
--------------------------------------------------------------------------------
/internal/config/options_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "flag"
5 | "io"
6 | "os"
7 | "regexp"
8 | "strings"
9 | "testing"
10 | )
11 |
12 | // stripANSI removes ANSI escape codes from a string – handy for comparing
13 | // coloured terminal output with plain strings.
14 | func stripANSI(s string) string {
15 | re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
16 | return re.ReplaceAllString(s, "")
17 | }
18 |
19 | // captureStdout runs fn while capturing everything written to Stdout and
20 | // returns it as a string.
21 | func captureStdout(fn func()) string {
22 | orig := os.Stdout
23 | r, w, _ := os.Pipe()
24 | os.Stdout = w
25 |
26 | fn()
27 |
28 | w.Close()
29 | os.Stdout = orig
30 |
31 | out, _ := io.ReadAll(r)
32 | r.Close()
33 | return string(out)
34 | }
35 |
36 | // resetFlags installs a fresh flag set and custom os.Args so each test starts
37 | // from a clean slate. It returns a restore() callback that must be deferred.
38 | func resetFlags(args []string) (restore func()) {
39 | oldCmd := flag.CommandLine
40 | oldArgs := os.Args
41 |
42 | fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
43 | fs.SetOutput(io.Discard) // silence parsing noise
44 | flag.CommandLine = fs
45 | os.Args = args
46 |
47 | return func() {
48 | flag.CommandLine = oldCmd
49 | os.Args = oldArgs
50 | }
51 | }
52 |
53 | // ---------------------------------------------------------------------------
54 | // ShowHelp() dynamic coverage test ------------------------------------------
55 | // ---------------------------------------------------------------------------
56 |
57 | // TestShowHelpContainsEveryFlag enumerates all flags added by ParseOptions at
58 | // runtime and asserts that ShowHelp() prints each one. This way the test stays
59 | // up‑to‑date even when new CLI options are introduced – no manual list to
60 | // maintain.
61 | func TestShowHelpContainsEveryFlag(t *testing.T) {
62 | // Step 1: create a fresh FlagSet *without* DNSanity flags.
63 | restore := resetFlags([]string{"dnsanity"})
64 | defer restore()
65 |
66 | // Snapshot Go test framework flags so we can ignore them later.
67 | baseline := map[string]struct{}{}
68 | flag.CommandLine.VisitAll(func(f *flag.Flag) { baseline[f.Name] = struct{}{} })
69 |
70 | // Step 2: register DNSanity flags by calling ParseOptions.
71 | if _, err := ParseOptions(); err != nil {
72 | t.Fatalf("ParseOptions() unexpected error: %v", err)
73 | }
74 |
75 | // Collect the *new* flag names – i.e. those not present in baseline.
76 | var dnsanityFlags []string
77 | flag.CommandLine.VisitAll(func(f *flag.Flag) {
78 | if _, preExisting := baseline[f.Name]; !preExisting {
79 | dnsanityFlags = append(dnsanityFlags, f.Name)
80 | }
81 | })
82 |
83 | // Sanity check – there *must* be at least one custom flag.
84 | if len(dnsanityFlags) == 0 {
85 | t.Fatalf("no DNSanity‑specific flags detected – test invalid")
86 | }
87 |
88 | // Step 3: capture ShowHelp() output and make sure every flag appears.
89 | output := captureStdout(func() { ShowHelp() })
90 | plain := stripANSI(output)
91 |
92 | for _, name := range dnsanityFlags {
93 | want := "-" + name
94 | if !strings.Contains(plain, want) {
95 | t.Errorf("ShowHelp() is missing %q", want)
96 | }
97 | }
98 | }
99 |
100 | // ---------------------------------------------------------------------------
101 | // ParseOptions() tests -------------------------------------------------------
102 | // ---------------------------------------------------------------------------
103 |
104 | type parseTest struct {
105 | name string
106 | args []string
107 | expect func(t *testing.T, o *Options)
108 | }
109 |
110 | func TestParseOptions(t *testing.T) {
111 | tests := []parseTest{
112 | {
113 | name: "defaults",
114 | args: []string{"dnsanity"},
115 | expect: func(t *testing.T, o *Options) {
116 | if o.GlobRateLimit != 500 {
117 | t.Fatalf("default GlobRateLimit = %d, want 500", o.GlobRateLimit)
118 | }
119 | if o.Threads != -0xdead {
120 | t.Fatalf("default Threads = %d, want 10000", o.Threads)
121 | }
122 | if o.OutputFilePath != "/dev/stdout" {
123 | t.Fatalf("OutputFilePath = %q, want /dev/stdout", o.OutputFilePath)
124 | }
125 | },
126 | },
127 | {
128 | name: "explicit threads kept",
129 | args: []string{"dnsanity", "-threads", "42"},
130 | expect: func(t *testing.T, o *Options) {
131 | if o.Threads != 42 {
132 | t.Fatalf("Threads = %d, want 42", o.Threads)
133 | }
134 | },
135 | },
136 | {
137 | name: "all custom values",
138 | args: []string{
139 | "dnsanity",
140 | "-o", "out.txt",
141 | "-list", "8.8.8.8",
142 | "-template", "tpl.txt",
143 | "-threads", "16",
144 | "-timeout", "7",
145 | "-ratelimit", "0.5",
146 | "-max-attempts", "3",
147 | "-max-mismatches", "2",
148 | },
149 | expect: func(t *testing.T, o *Options) {
150 | if o.OutputFilePath != "out.txt" {
151 | t.Fatalf("OutputFilePath = %q, want out.txt", o.OutputFilePath)
152 | }
153 | if o.UntrustedDNS != "8.8.8.8" {
154 | t.Fatalf("UntrustedDNS = %q, want 8.8.8.8", o.UntrustedDNS)
155 | }
156 | if o.Template != "tpl.txt" {
157 | t.Fatalf("Template = %q, want tpl.txt", o.Template)
158 | }
159 | if o.Threads != 16 {
160 | t.Fatalf("Threads = %d, want 16", o.Threads)
161 | }
162 | if o.Timeout != 7 {
163 | t.Fatalf("Timeout = %d, want 7", o.Timeout)
164 | }
165 | if o.RateLimit != 0.5 {
166 | t.Fatalf("RateLimit = %f, want 0.5", o.RateLimit)
167 | }
168 | if o.Attempts != 3 {
169 | t.Fatalf("Attempts = %d, want 3", o.Attempts)
170 | }
171 | if o.MaxMismatches != 2 {
172 | t.Fatalf("MaxMismatches = %d, want 2", o.MaxMismatches)
173 | }
174 | },
175 | },
176 | }
177 |
178 | for _, tc := range tests {
179 | t.Run(tc.name, func(t *testing.T) {
180 | restore := resetFlags(tc.args)
181 | defer restore()
182 |
183 | opts, err := ParseOptions()
184 | if err != nil {
185 | t.Fatalf("ParseOptions() error: %v", err)
186 | }
187 | tc.expect(t, opts)
188 | })
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/internal/config/serverlist.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "bufio"
6 | "fmt"
7 | "net"
8 | "strings"
9 | "errors"
10 | )
11 |
12 |
13 | // return a slice containing all DNS servers (IPv4) from
14 | // a string or file.
15 | // > parseServerList("8.8.8.8, 1.1.1.1") -> [8.8.8.8 1.1.1.1]
16 | // > parseServerList("/tmp/srv.lst") -> [1.1.1.1 2.2.2.2 3.3.3.3 ...]
17 | func ParseServerList(input string) ([]string, error) {
18 | var servers []string
19 | var scanner *bufio.Scanner
20 |
21 | if st, err := os.Stat(input); err == nil && !st.IsDir() {
22 | file, err := os.Open(input)
23 | if err != nil {
24 | return nil, fmt.Errorf("Can't open %q: %w", input, err)
25 | }
26 | defer file.Close()
27 | scanner = bufio.NewScanner(file)
28 | } else {
29 | scanner = bufio.NewScanner(strings.NewReader(input))
30 | }
31 | // for each line:
32 | for scanner.Scan() {
33 | line := scanner.Text()
34 | if idx := strings.Index(line, "#"); idx != -1 {
35 | line = line[:idx]
36 | }
37 | // for each comma-separated elem:
38 | for _, elem := range strings.Split(line, ",") {
39 | elem = strings.TrimSpace(elem)
40 | if elem == "" {
41 | continue
42 | }
43 | if ip := net.ParseIP(elem); ip == nil {
44 | return nil, fmt.Errorf("Invalid IP: %q", elem)
45 | }
46 | servers = append(servers, elem)
47 | }
48 | }
49 | if len(servers) == 0 {
50 | return nil, errors.New("server list is empty")
51 | }
52 | return servers, nil
53 | }
54 |
--------------------------------------------------------------------------------
/internal/config/serverlist_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | // helper to create temp file with given content and return path
10 | func createTempFile(t *testing.T, content string) string {
11 | t.Helper()
12 | file, err := os.CreateTemp(t.TempDir(), "srvlist-*.txt")
13 | if err != nil {
14 | t.Fatalf("unable to create temp file: %v", err)
15 | }
16 | if _, err := file.WriteString(content); err != nil {
17 | file.Close()
18 | t.Fatalf("unable to write temp file: %v", err)
19 | }
20 | if err := file.Close(); err != nil {
21 | t.Fatalf("unable to close temp file: %v", err)
22 | }
23 | return file.Name()
24 | }
25 |
26 | func TestParseServerList(t *testing.T) {
27 | // Build a real file to exercise the “open file” branch
28 | tempFilePath := createTempFile(t, `
29 | # comment line
30 | 8.8.8.8 , 1.1.1.1 # inline comment
31 | 2001:4860:4860::8888
32 | 9.9.9.9,
33 | ,,,, # empty elems should be ignored
34 | `)
35 |
36 | tests := []struct {
37 | name string
38 | input string
39 | want []string
40 | wantErr bool
41 | }{
42 | {
43 | name: "inline string simple list",
44 | input: "8.8.8.8,1.1.1.1",
45 | want: []string{"8.8.8.8", "1.1.1.1"},
46 | },
47 | {
48 | name: "inline multi-line with comments and spaces",
49 | input: " 8.8.4.4 # comment\n1.0.0.1 , 9.9.9.9 ",
50 | want: []string{"8.8.4.4", "1.0.0.1", "9.9.9.9"},
51 | },
52 | {
53 | name: "inline IPv6 mixed with IPv4",
54 | input: "2001:4860:4860::8888, 8.8.8.8",
55 | want: []string{"2001:4860:4860::8888", "8.8.8.8"},
56 | },
57 | {
58 | name: "invalid IP in inline list",
59 | input: "8.8.8.8,999.999.999.999",
60 | wantErr: true,
61 | },
62 | {
63 | name: "empty list",
64 | input: " # just a comment",
65 | wantErr: true,
66 | },
67 | {
68 | name: "treat directory path as string, expect error",
69 | input: ".",
70 | wantErr: true,
71 | },
72 | {
73 | name: "file path input",
74 | input: tempFilePath,
75 | want: []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "9.9.9.9"},
76 | },
77 | {
78 | name: "non‑existent path treated as inline string",
79 | input: "4.4.4.4",
80 | want: []string{"4.4.4.4"},
81 | },
82 | {
83 | name: "duplicates preserved",
84 | input: "8.8.8.8,8.8.8.8",
85 | want: []string{"8.8.8.8", "8.8.8.8"},
86 | },
87 | }
88 |
89 | for _, tc := range tests {
90 | tc := tc // capture range variable
91 | t.Run(tc.name, func(t *testing.T) {
92 | got, err := ParseServerList(tc.input)
93 | if tc.wantErr {
94 | if err == nil {
95 | t.Fatalf("expected error, got nil with output %v", got)
96 | }
97 | return
98 | }
99 | if err != nil {
100 | t.Fatalf("unexpected error: %v", err)
101 | }
102 | if !reflect.DeepEqual(got, tc.want) {
103 | t.Errorf("result mismatch; got %v, want %v", got, tc.want)
104 | }
105 | })
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/internal/config/settings.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/nil0x42/dnsanity/internal/dns"
5 | )
6 |
7 | type Settings struct {
8 | // global
9 | ServerIPs []string
10 | Template dns.Template
11 | MaxThreads int
12 | MaxPoolSize int
13 | GlobRateLimit int
14 | // per server
15 | PerSrvRateLimit float64
16 | PerSrvMaxFailures int
17 | // per check
18 | PerCheckMaxAttempts int
19 | // per dns query
20 | PerQueryTimeout int
21 | }
22 |
--------------------------------------------------------------------------------
/internal/dns/dnsanswer.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | // --------------------------------------------------------------------
9 | // DNSAnswerData
10 | // --------------------------------------------------------------------
11 |
12 | type DNSAnswerData struct {
13 | Status string // NOERROR | NXDOMAIN | TIMEOUT | SERVFAIL
14 | A []string // sorted A records (IPv4)
15 | CNAME []string // sorted CNAME records
16 | }
17 |
18 | func (dad *DNSAnswerData) ToString() string {
19 | if len(dad.A) == 0 && len(dad.CNAME) == 0 {
20 | return dad.Status
21 | }
22 | // here, it's implicitly a NOERROR, because we got results..
23 | records := []string{}
24 | for _, a := range dad.A {
25 | records = append(records, "A="+a)
26 | }
27 | for _, cname := range dad.CNAME {
28 | records = append(records, "CNAME="+cname)
29 | }
30 | return strings.Join(records, " ")
31 | }
32 |
33 | func NewDNSAnswerData(data string) (*DNSAnswerData, error) {
34 | tokens := strings.Fields(data)
35 | if len(tokens) == 0 {
36 | return nil, fmt.Errorf("empty answer")
37 | }
38 | dad := &DNSAnswerData{}
39 | // single 'STATUS' word:
40 | if len(tokens) == 1 {
41 | switch tokens[0] {
42 | case
43 | "TIMEOUT",
44 | "NOERROR",
45 | "FORMERR",
46 | "NOTIMP",
47 | "NXDOMAIN",
48 | "SERVFAIL":
49 | dad.Status = tokens[0]
50 | return dad, nil
51 | default:
52 | }
53 | }
54 | // 1 or more A / CNAME records (implicitly a NOERROR)
55 | for _, tok := range tokens {
56 | if strings.HasPrefix(tok, "A=") {
57 | dad.A = append(
58 | dad.A,
59 | strings.TrimPrefix(tok, "A="),
60 | )
61 | } else if strings.HasPrefix(tok, "CNAME=") {
62 | dad.CNAME = append(
63 | dad.CNAME,
64 | strings.ToLower(strings.TrimPrefix(tok, "CNAME=")),
65 | )
66 | } else {
67 | return nil, fmt.Errorf("invalid record: %q", tok)
68 | }
69 | }
70 | dad.Status = "NOERROR"
71 | return dad, nil
72 | }
73 |
74 | // --------------------------------------------------------------------
75 | // DNSAnswer
76 | // --------------------------------------------------------------------
77 |
78 | type DNSAnswer struct {
79 | Domain string
80 | DNSAnswerData
81 | Truncated bool
82 | }
83 |
84 | // DNSAnswer.ToString converts a DNSAnswer to string
85 | func (da *DNSAnswer) ToString() string {
86 | out := da.Domain + " " + da.DNSAnswerData.ToString()
87 | if da.Truncated {
88 | out += " [TC=1]"
89 | }
90 | return out
91 | }
92 |
--------------------------------------------------------------------------------
/internal/dns/dnsanswer_test.go:
--------------------------------------------------------------------------------
1 | // dnsanswer_test.go
2 | package dns
3 |
4 | import (
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | // TestDNSAnswerData_ToString_StatusOnly verifies stringification when only a status is set.
10 | func TestDNSAnswerData_ToString_StatusOnly(t *testing.T) {
11 | t.Parallel()
12 | dad := &DNSAnswerData{Status: "TIMEOUT"}
13 | if got, want := dad.ToString(), "TIMEOUT"; got != want {
14 | t.Fatalf("ToString() = %q, want %q", got, want)
15 | }
16 | }
17 |
18 | // TestDNSAnswerData_ToString_WithRecords verifies stringification with multiple A and CNAME records.
19 | func TestDNSAnswerData_ToString_WithRecords(t *testing.T) {
20 | t.Parallel()
21 | dad := &DNSAnswerData{
22 | Status: "NOERROR",
23 | A: []string{"1.2.3.4", "4.3.2.1"},
24 | CNAME: []string{"example.com", "foo.bar"},
25 | }
26 | got := dad.ToString()
27 | want := "A=1.2.3.4 A=4.3.2.1 CNAME=example.com CNAME=foo.bar"
28 | if got != want {
29 | t.Fatalf("ToString() = %q, want %q", got, want)
30 | }
31 | }
32 |
33 | // TestNewDNSAnswerData covers every parsing branch including edge cases and error paths.
34 | func TestNewDNSAnswerData(t *testing.T) {
35 | t.Parallel()
36 |
37 | tests := []struct {
38 | name string
39 | input string
40 | want *DNSAnswerData
41 | wantErr bool
42 | }{
43 | {
44 | name: "empty_input",
45 | input: "",
46 | wantErr: true,
47 | },
48 | {
49 | name: "single_known_status",
50 | input: "SERVFAIL",
51 | want: &DNSAnswerData{Status: "SERVFAIL"},
52 | },
53 | {
54 | name: "single_A_record",
55 | input: "A=8.8.8.8",
56 | want: &DNSAnswerData{Status: "NOERROR", A: []string{"8.8.8.8"}},
57 | },
58 | {
59 | name: "single_CNAME_record",
60 | input: "CNAME=WWW.Example.Org",
61 | want: &DNSAnswerData{Status: "NOERROR", CNAME: []string{"www.example.org"}},
62 | },
63 | {
64 | name: "mixed_records",
65 | input: "A=1.1.1.1 CNAME=Foo.COM A=9.9.9.9",
66 | want: &DNSAnswerData{
67 | Status: "NOERROR",
68 | A: []string{"1.1.1.1", "9.9.9.9"},
69 | CNAME: []string{"foo.com"},
70 | },
71 | },
72 | {
73 | name: "single_invalid_token",
74 | input: "TXT=hello",
75 | wantErr: true,
76 | },
77 | {
78 | name: "mixed_with_invalid_token",
79 | input: "A=1.1.1.1 BADTOKEN CNAME=x",
80 | wantErr: true,
81 | },
82 | }
83 |
84 | for _, tc := range tests {
85 | tc := tc // capture range variable
86 | t.Run(tc.name, func(t *testing.T) {
87 | t.Parallel()
88 | got, err := NewDNSAnswerData(tc.input)
89 |
90 | if tc.wantErr {
91 | if err == nil {
92 | t.Fatalf("expected error, got nil")
93 | }
94 | return
95 | }
96 | if err != nil {
97 | t.Fatalf("unexpected error: %v", err)
98 | }
99 | if !reflect.DeepEqual(got, tc.want) {
100 | t.Fatalf("result mismatch\n got %+v\n want %+v", got, tc.want)
101 | }
102 | })
103 | }
104 | }
105 |
106 | // TestDNSAnswer_ToString verifies the outer DNSAnswer stringification including TC flag.
107 | func TestDNSAnswer_ToString(t *testing.T) {
108 | t.Parallel()
109 |
110 | // NXDOMAIN (no records)
111 | nx := &DNSAnswer{
112 | Domain: "example.com.",
113 | DNSAnswerData: DNSAnswerData{
114 | Status: "NXDOMAIN",
115 | },
116 | }
117 | if got, want := nx.ToString(), "example.com. NXDOMAIN"; got != want {
118 | t.Fatalf("ToString() = %q, want %q", got, want)
119 | }
120 |
121 | // NOERROR with records and truncated flag
122 | okTrunc := &DNSAnswer{
123 | Domain: "example.com.",
124 | DNSAnswerData: DNSAnswerData{
125 | Status: "NOERROR",
126 | A: []string{"4.4.4.4"},
127 | },
128 | Truncated: true,
129 | }
130 | if got, want := okTrunc.ToString(), "example.com. A=4.4.4.4 [TC=1]"; got != want {
131 | t.Fatalf("ToString() (truncated) = %q, want %q", got, want)
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/internal/dns/resolver.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "strings"
5 | "time"
6 | "context"
7 | "net"
8 |
9 | "github.com/miekg/dns"
10 | )
11 |
12 |
13 | func ResolveDNS(
14 | domain string,
15 | dnsServer string,
16 | timeout time.Duration,
17 | ctx context.Context,
18 | ) *DNSAnswer {
19 | client := &dns.Client{
20 | Timeout: timeout,
21 | // UDPSize: 4096,
22 | }
23 |
24 | message := &dns.Msg{}
25 | message.SetEdns0(1232, false)
26 | message.SetQuestion(dns.Fqdn(domain), dns.TypeA) // A record
27 |
28 | // init DNSAnswer
29 | answer := &DNSAnswer{Domain: domain}
30 |
31 | // DNS resolution
32 | // net.JoinHostPort() is needed for ipv6 (bracket expansion):
33 | hostAndPort := net.JoinHostPort(dnsServer, "53")
34 | response, _, err := client.ExchangeContext(ctx, message, hostAndPort)
35 | if err != nil {
36 | if strings.HasSuffix(err.Error(), "i/o timeout") {
37 | answer.Status = "TIMEOUT"
38 | } else if strings.HasSuffix(err.Error(), "read: connection refused") {
39 | answer.Status = "ECONNREFUSED"
40 | } else if strings.HasSuffix(err.Error(), "connect: network is unreachable") {
41 | answer.Status = "ENETUNREACH (no internet)"
42 | } else {
43 | answer.Status = "ERROR - " + err.Error()
44 | }
45 | } else if response.Rcode != dns.RcodeSuccess {
46 | answer.Status = dns.RcodeToString[response.Rcode]
47 | } else {
48 | for _, rr := range response.Answer {
49 | switch record := rr.(type) {
50 | case *dns.A:
51 | answer.A = append(answer.A, record.A.String())
52 | case *dns.CNAME:
53 | answer.CNAME = append(answer.CNAME, record.Target)
54 | }
55 | }
56 | answer.Status = "NOERROR"
57 | }
58 | if err == nil { // check needed to avoid segfault if resp is not built
59 | answer.Truncated = response.Truncated
60 | }
61 | return answer
62 | }
63 |
--------------------------------------------------------------------------------
/internal/dns/resolver_test.go:
--------------------------------------------------------------------------------
1 | // resolver_test.go
2 | package dns
3 |
4 | import (
5 | "context"
6 | "testing"
7 | "time"
8 | "strings"
9 | )
10 |
11 | // testCase models one ResolveDNS scenario
12 | type testCase struct {
13 | name string
14 | domain string
15 | server string
16 | timeout time.Duration
17 | cancelCtx bool
18 | wantStatus string // exact expected Status string
19 | wantA bool // expect at least one A record
20 | wantCNAME bool // expect at least one CNAME record
21 | }
22 |
23 | // TestResolveDNS exhaustively exercises ResolveDNS covering every branch.
24 | func TestResolveDNS(t *testing.T) {
25 | t.Parallel()
26 |
27 | // helper to make a context (possibly already cancelled)
28 | mkCtx := func(cancel bool) context.Context {
29 | ctx, cancelFn := context.WithCancel(context.Background())
30 | if cancel {
31 | cancelFn()
32 | }
33 | return ctx
34 | }
35 |
36 | // NOTE: The public DNS 8.8.8.8 is used for successful lookups because it is
37 | // globally reachable in the vast majority of CI environments. Reserved
38 | // documentation‑prefix addresses (RFC 5737) are leveraged to produce
39 | // deterministic network errors without needing privileged ports or external
40 | // dependencies.
41 | tests := []testCase{
42 | {
43 | name: "SuccessARecord",
44 | domain: "example.com",
45 | server: "8.8.8.8",
46 | timeout: 4 * time.Second,
47 | wantStatus: "NOERROR",
48 | wantA: true,
49 | },
50 | {
51 | name: "SuccessCNAME",
52 | domain: "www.apple.com",
53 | server: "8.8.8.8",
54 | timeout: 4 * time.Second,
55 | wantStatus: "NOERROR",
56 | wantCNAME: true,
57 | wantA: true,
58 | },
59 | {
60 | name: "NXDOMAIN",
61 | domain: "this-domain-should-not-exist.invalid",
62 | server: "8.8.8.8",
63 | timeout: 4 * time.Second,
64 | wantStatus: "NXDOMAIN",
65 | },
66 | {
67 | name: "Timeout",
68 | domain: "example.com",
69 | server: "192.0.2.1", // TEST‑NET‑1 (no host responds)
70 | timeout: 500 * time.Millisecond,
71 | wantStatus: "TIMEOUT",
72 | },
73 | {
74 | name: "ConnectionRefused",
75 | domain: "example.com",
76 | server: "127.0.0.1", // loopback, assuming nothing on port 53
77 | timeout: 500 * time.Millisecond,
78 | wantStatus: "ECONNREFUSED",
79 | },
80 | {
81 | name: "InvalidServer",
82 | domain: "example.com",
83 | server: "1203.0.113.1", // TEST‑NET‑3, usually unroutable
84 | timeout: 500 * time.Millisecond,
85 | wantStatus: ": no such host",
86 | },
87 | {
88 | name: "ContextCanceled",
89 | domain: "example.com",
90 | server: "8.8.8.8",
91 | timeout: 4 * time.Second,
92 | cancelCtx: true,
93 | wantStatus: "ERROR - ",
94 | },
95 | }
96 |
97 | for _, tc := range tests {
98 | tc := tc // capture
99 | t.Run(tc.name, func(t *testing.T) {
100 | t.Parallel()
101 |
102 | ctx := mkCtx(tc.cancelCtx)
103 | ans := ResolveDNS(tc.domain, tc.server, tc.timeout, ctx)
104 |
105 | if !strings.Contains(ans.Status,tc.wantStatus) {
106 | t.Fatalf("Status mismatch for %s: got %q, want %q", tc.name, ans.Status, tc.wantStatus)
107 | }
108 |
109 | if tc.wantA && len(ans.A) == 0 {
110 | t.Fatalf("%s: expected at least one A record, got none", tc.name)
111 | }
112 | if tc.wantCNAME && len(ans.CNAME) == 0 {
113 | t.Fatalf("%s: expected at least one CNAME record, got none", tc.name)
114 | }
115 |
116 | // Generic sanity: Domain field should echo input.
117 | if ans.Domain != tc.domain {
118 | t.Fatalf("%s: Domain mismatch, got %q want %q", tc.name, ans.Domain, tc.domain)
119 | }
120 | })
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/internal/dns/servercontext.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "time"
5 | "context"
6 | "fmt"
7 | )
8 |
9 | type CheckContext struct {
10 | Answer *DNSAnswer // last received answer
11 | Passed bool // last attempt result
12 | AttemptsLeft int // retries remaining
13 | MaxAttempts int // immutable upper bound
14 | }
15 |
16 | type ServerContext struct {
17 | Ctx context.Context
18 | CancelCtx context.CancelFunc
19 | Disabled bool // true if reaches maxFailures
20 |
21 | IPAddress string // resolver IPv4
22 | FailedCount int // failed checks.
23 | CompletedCount int // finished checks (pass+fail)
24 | NextQueryAt time.Time // honour per-server rps
25 | PendingChecks []int // queue of remaining check indexes
26 | Checks []CheckContext // answers log
27 | }
28 |
29 | func NewServerContext(
30 | ipAddress string,
31 | template Template,
32 | maxAttempts int, // max attempts per check
33 | ) *ServerContext {
34 | ctx, cancelCtx := context.WithCancel(context.Background())
35 | sc := &ServerContext{
36 | Ctx: ctx,
37 | CancelCtx: cancelCtx,
38 | IPAddress: ipAddress,
39 | PendingChecks: make([]int, len(template)),
40 | Checks: make([]CheckContext, len(template)),
41 | }
42 | for i := range template {
43 | sc.PendingChecks[i] = i
44 | sc.Checks[i].AttemptsLeft = maxAttempts
45 | sc.Checks[i].MaxAttempts = maxAttempts
46 | sc.Checks[i].Answer = &DNSAnswer{
47 | Domain: template[i].Domain,
48 | DNSAnswerData: DNSAnswerData{Status: "SKIPPED"},
49 | }
50 | }
51 | return sc
52 | }
53 |
54 | // Finished returns true when the server is either disabled or has
55 | // completed all its checks.
56 | // ServerContext.Finished():
57 | func (srv *ServerContext) Finished() bool {
58 | return srv.Disabled || srv.CompletedCount == len(srv.Checks)
59 | }
60 |
61 | func (srv *ServerContext) PrettyDump() string {
62 | var s string
63 | if srv.FailedCount == 0 {
64 | s += fmt.Sprintf(
65 | "\033[1;32m[+] SERVER %v (valid)\033[m\n", srv.IPAddress)
66 | } else {
67 | s += fmt.Sprintf(
68 | "\033[1;31m[-] SERVER %v (invalid)\033[m\n", srv.IPAddress)
69 | }
70 | for _, test := range srv.Checks {
71 | var prefix string
72 | if test.Passed {
73 | prefix = "\033[1;32m+\033[0;32m"
74 | } else if test.Answer.Status == "SKIPPED" {
75 | prefix = "\033[1;90m!\033[0;90m"
76 | } else {
77 | prefix = "\033[1;31m-\033[0;31m"
78 | }
79 | numTries := test.MaxAttempts - test.AttemptsLeft
80 | attemptsRepr := ""
81 | if numTries > 1 {
82 | suffix := "th"
83 | if numTries == 2 {
84 | suffix = "nd"
85 | } else if numTries == 3 {
86 | suffix = "rd"
87 | }
88 | attemptsRepr = fmt.Sprintf(
89 | " \033[33m(on %v%v attempt)\033[m", numTries, suffix)
90 | }
91 | s += fmt.Sprintf(
92 | " %s %s\033[m%v\n",
93 | prefix, test.Answer.ToString(), attemptsRepr,
94 | )
95 | }
96 | return s
97 | }
98 |
--------------------------------------------------------------------------------
/internal/dns/servercontext_test.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | "time"
7 |
8 | "github.com/nil0x42/dnsanity/internal/tty"
9 | )
10 |
11 | // buildTemplate quickly crafts a Template with domains mapped to NOERROR expectations.
12 | func buildTemplate(domains []string) Template {
13 | tpl := make(Template, len(domains))
14 | for i, d := range domains {
15 | tpl[i] = TemplateEntry{Domain: d, ValidAnswers: []DNSAnswerData{{Status: "NOERROR"}}}
16 | }
17 | return tpl
18 | }
19 |
20 | // TestNewServerContextInitialization verifies that NewServerContext populates every
21 | // field as expected for a freshly‑created ServerContext.
22 | func TestNewServerContextInitialization(t *testing.T) {
23 | maxAttempts := 3
24 | tpl := buildTemplate([]string{"a.example", "b.example", "c.example"})
25 |
26 | sc := NewServerContext("1.2.3.4", tpl, maxAttempts)
27 |
28 | // Basic structural assertions.
29 | if got, want := len(sc.PendingChecks), len(tpl); got != want {
30 | t.Fatalf("PendingChecks length = %d, want %d", got, want)
31 | }
32 | for idx, v := range sc.PendingChecks {
33 | if v != idx {
34 | t.Errorf("PendingChecks[%d] = %d, want %d", idx, v, idx)
35 | }
36 | }
37 |
38 | // Per‑check initialization.
39 | for i, chk := range sc.Checks {
40 | if chk.Answer.Domain != tpl[i].Domain {
41 | t.Errorf("Check[%d].Domain = %q, want %q", i, chk.Answer.Domain, tpl[i].Domain)
42 | }
43 | if chk.Answer.Status != "SKIPPED" {
44 | t.Errorf("Check[%d].Status = %q, want SKIPPED", i, chk.Answer.Status)
45 | }
46 | if chk.AttemptsLeft != maxAttempts {
47 | t.Errorf("Check[%d].AttemptsLeft = %d, want %d", i, chk.AttemptsLeft, maxAttempts)
48 | }
49 | if chk.MaxAttempts != maxAttempts {
50 | t.Errorf("Check[%d].MaxAttempts = %d, want %d", i, chk.MaxAttempts, maxAttempts)
51 | }
52 | }
53 |
54 | // Sanity on zero‑values.
55 | if sc.Disabled {
56 | t.Error("Server should not be disabled on creation")
57 | }
58 | if sc.FailedCount != 0 || sc.CompletedCount != 0 {
59 | t.Error("FailedCount or CompletedCount not zero on creation")
60 | }
61 | if !sc.NextQueryAt.IsZero() {
62 | t.Error("NextQueryAt should start at zero value")
63 | }
64 | }
65 |
66 | // TestServerContextFinished ensures all branches of Finished() are exercised.
67 | func TestServerContextFinished(t *testing.T) {
68 | tpl := buildTemplate([]string{"only.example"})
69 | sc := NewServerContext("5.6.7.8", tpl, 2)
70 |
71 | // Case 1: nothing done yet – should not be finished.
72 | if sc.Finished() {
73 | t.Error("Finished() returned true too early")
74 | }
75 |
76 | // Case 2: completed all checks.
77 | sc.CompletedCount = len(sc.Checks)
78 | if !sc.Finished() {
79 | t.Error("Finished() should be true when all checks completed")
80 | }
81 |
82 | // Case 3: disabled flag overrides CompletedCount.
83 | sc = NewServerContext("5.6.7.8", tpl, 2)
84 | sc.Disabled = true
85 | if !sc.Finished() {
86 | t.Error("Finished() should be true when server is disabled")
87 | }
88 | }
89 |
90 | // TestCancelCtx validates that CancelCtx actually cancels the underlying context.
91 | func TestCancelCtx(t *testing.T) {
92 | tpl := buildTemplate([]string{"ctx.example"})
93 | sc := NewServerContext("9.9.9.9", tpl, 1)
94 |
95 | sc.CancelCtx()
96 | if err := sc.Ctx.Err(); err == nil {
97 | t.Error("context not cancelled after CancelCtx() call")
98 | }
99 | }
100 |
101 | // stripANSIFast provides a minimalist ANSI escape stripper suitable for test comparisons.
102 | func stripANSIFast(s string) string {
103 | return tty.StripAnsi(s)
104 | }
105 |
106 | // TestPrettyDumpExhaustive crafts several pathological scenarios to hit every
107 | // branch inside PrettyDump(), including colour prefixes and attempt‑suffix logic.
108 | func TestPrettyDumpExhaustive(t *testing.T) {
109 | tpl := buildTemplate([]string{"p0.example", "p1.example", "p2.example", "p3.example"})
110 | sc := NewServerContext("8.8.4.4", tpl, 4) // MaxAttempts = 4
111 |
112 | // --- manipulate each check to explore prefixes & suffixes ------------
113 | // Check 0: Passed on 1st attempt (✓, numTries = 1 → no suffix)
114 | sc.Checks[0].Passed = true
115 | sc.Checks[0].Answer.Status = "NOERROR"
116 | sc.Checks[0].AttemptsLeft = 3 // Max‑Attempts 4 -> numTries=1
117 |
118 | // Check 1: Failed on 2nd attempt (✗, suffix "nd")
119 | sc.Checks[1].Answer.Status = "SERVFAIL"
120 | sc.Checks[1].AttemptsLeft = 2 // numTries=2 (suffix nd)
121 | sc.FailedCount++
122 |
123 | // Check 2: Failed on 3rd attempt (✗, suffix "rd")
124 | sc.Checks[2].Answer.Status = "TIMEOUT"
125 | sc.Checks[2].AttemptsLeft = 1 // numTries=3 (suffix rd)
126 | sc.FailedCount++
127 |
128 | // Check 3: Failed on 4th attempt (✗, suffix "th")
129 | sc.Checks[3].Answer.Status = "TIMEOUT"
130 | sc.Checks[3].AttemptsLeft = 0 // numTries=4 (suffix th)
131 | sc.FailedCount++
132 |
133 | // Mark completed to exercise header logic.
134 | sc.CompletedCount = len(sc.Checks)
135 |
136 | gotDump := stripANSIFast(sc.PrettyDump())
137 |
138 | // Header assertions.
139 | if !strings.Contains(gotDump, "SERVER 8.8.4.4") {
140 | t.Fatalf("PrettyDump header missing IP: %s", gotDump)
141 | }
142 | if !strings.Contains(gotDump, "(invalid)") {
143 | t.Fatalf("PrettyDump should mark server invalid when FailedCount>0: %s", gotDump)
144 | }
145 |
146 | // Prefix markers.
147 | if !strings.Contains(gotDump, " + ") {
148 | t.Error("PrettyDump missing '+' marker for passed test")
149 | }
150 | if !strings.Contains(gotDump, " - ") {
151 | t.Error("PrettyDump missing '-' marker for failed test")
152 | }
153 | if !strings.Contains(gotDump, "! p2.example") { // SKIPPED still present on p2? ensure at least one
154 | t.Log("PrettyDump has no SKIPPED entries as expected after overrides – OK (not fatal)")
155 | }
156 |
157 | // Attempt suffixes.
158 | for _, suff := range []string{"2nd attempt", "3rd attempt", "4th attempt"} {
159 | if !strings.Contains(gotDump, suff) {
160 | t.Errorf("PrettyDump missing attempt suffix %q", suff)
161 | }
162 | }
163 |
164 | // Timing – ensure function executes quickly (regression guard for accidental sleeps).
165 | deadline := time.AfterFunc(100*time.Millisecond, func() {
166 | t.Errorf("PrettyDump took too long (possible deadlock)")
167 | })
168 | _ = sc.PrettyDump()
169 | deadline.Stop()
170 | }
171 |
--------------------------------------------------------------------------------
/internal/dns/template.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "errors"
7 | "io"
8 | "os"
9 | "bufio"
10 | )
11 |
12 |
13 | // --------------------------------------------------------------------
14 | // TemplateEntry (single template line/entry)
15 | // --------------------------------------------------------------------
16 | type TemplateEntry struct {
17 | Domain string
18 | ValidAnswers []DNSAnswerData
19 | }
20 |
21 |
22 | // NewTemplateEntry() creates a new TemplateEntry from string
23 | func NewTemplateEntry(line string) (*TemplateEntry, error) {
24 | // 1) Extract domain (first field) and remainder.
25 | parts := strings.Fields(line)
26 | if len(parts) < 2 {
27 | return nil, fmt.Errorf("must have a domain and at least one A|CNAME record or NXDOMAIN/NOERROR")
28 | }
29 | domain := parts[0]
30 | remainder := line[len(domain):]
31 |
32 | // 2) Build entry holder.
33 | te := &TemplateEntry{Domain: domain}
34 |
35 | // 3) For each alternative separated by "||", build a DNSAnswerData.
36 | for _, alt := range strings.Split(remainder, "||" ) {
37 | answer, err := NewDNSAnswerData(strings.TrimSpace(alt))
38 | if err != nil {
39 | return nil, err
40 | }
41 | te.ValidAnswers = append(te.ValidAnswers, *answer)
42 | }
43 | return te, nil
44 | }
45 |
46 |
47 | func (te *TemplateEntry) ToString() string {
48 | altList := []string{}
49 | for _, dad := range te.ValidAnswers {
50 | altList = append(altList, dad.ToString())
51 | }
52 | return te.Domain + " " + strings.Join(altList, " || ")
53 | }
54 |
55 |
56 | // TemplateEntry.Matches() compares itself to a DNSAnswer
57 | func (te *TemplateEntry) Matches(da *DNSAnswer) bool {
58 | if te != nil && da != nil && te.Domain == da.Domain {
59 | for _, choice := range te.ValidAnswers {
60 | if
61 | choice.Status == da.Status &&
62 | matchRecords(choice.A, da.A) &&
63 | matchRecords(choice.CNAME, da.CNAME) {
64 | return true
65 | }
66 | }
67 | }
68 | return false
69 | }
70 |
71 |
72 | // matchRecords compares two slices of records using glob matching
73 | // Returns true if each record in patterns matches exactly one
74 | // record in values, no matter the order
75 | func matchRecords(patterns, values []string) bool {
76 | if len(patterns) != len(values) {
77 | return false // two slices with different sizes are not equal
78 | }
79 | if len(patterns) == 0 && len(values) == 0 {
80 | return true // two empty slices are equal
81 | }
82 | // Try all possible permutations of matching patterns to values
83 | perm := make([]int, len(values))
84 | for i := range perm {
85 | perm[i] = i
86 | }
87 | // Try each permutation
88 | for {
89 | // Check if this permutation works
90 | allMatch := true
91 | for i, pattern := range patterns {
92 | if !globMatch(pattern, values[perm[i]]) {
93 | allMatch = false
94 | break
95 | }
96 | }
97 | if allMatch {
98 | return true
99 | }
100 | // Get next permutation
101 | if !nextPermutation(perm) {
102 | break
103 | }
104 | }
105 | return false
106 | }
107 |
108 |
109 | // nextPermutation generates the next lexicographic permutation of the slice
110 | // Returns false if there are no more permutations
111 | func nextPermutation(p []int) bool {
112 | // Find the largest index k such that p[k] < p[k+1]
113 | k := len(p) - 2
114 | for k >= 0 && p[k] >= p[k+1] {
115 | k--
116 | }
117 | if k < 0 {
118 | return false
119 | }
120 | // Find the largest index l greater than k such that p[k] < p[l]
121 | l := len(p) - 1
122 | for p[k] >= p[l] {
123 | l--
124 | }
125 | // Swap p[k] and p[l]
126 | p[k], p[l] = p[l], p[k]
127 | // Reverse the sequence from p[k+1] up to and including the final element
128 | for i, j := k+1, len(p)-1; i < j; i, j = i+1, j-1 {
129 | p[i], p[j] = p[j], p[i]
130 | }
131 | return true
132 | }
133 |
134 |
135 | // globMatch compares a pattern with a value using glob matching
136 | // Returns true if the value matches the pattern
137 | // fmt.Println(globMatch("192.168.*.*", "192.168.1.1")) // true
138 | // fmt.Println(globMatch("1.1.*.1", "1.1.10.1")) // true
139 | // fmt.Println(globMatch("10.*.0.1", "10.200.0.1")) // true
140 | // fmt.Println(globMatch("192.168.1.*", "192.168.2.1")) // false
141 | // fmt.Println(globMatch("*.*.*.*", "255.255.255.255")) // true
142 | // fmt.Println(globMatch("*.example.com", "test.example.com")) // true
143 | // fmt.Println(globMatch("*.example.com", "sub.test.com")) // false
144 | func globMatch(pattern, str string) bool {
145 | pattern = strings.ToLower(pattern) // case insensitive
146 | str = strings.ToLower(str) // case insensitive
147 | if !strings.ContainsRune(pattern, '*') {
148 | return pattern == str // most common case (no glob)
149 | }
150 | // cut pattern on '*'
151 | parts := strings.Split(pattern, "*")
152 |
153 | // first segment
154 | if first := parts[0]; first != "" {
155 | if !strings.HasPrefix(str, first) {
156 | return false
157 | }
158 | str = str[len(first):]
159 | }
160 | // internediate segments
161 | for i := 1; i < len(parts)-1; i++ {
162 | part := parts[i]
163 | if part == "" { // several consecutive '*'
164 | continue
165 | }
166 | idx := strings.Index(str, part)
167 | if idx == -1 {
168 | return false
169 | }
170 | // cut str after found segment to preserve order
171 | str = str[idx+len(part):]
172 | }
173 | // last segment
174 | last := parts[len(parts)-1]
175 | if last != "" && !strings.HasSuffix(str, last) {
176 | return false
177 | }
178 | return true
179 | }
180 |
181 |
182 | // --------------------------------------------------------------------
183 | // Template (list of template entries)
184 | // --------------------------------------------------------------------
185 | type Template []TemplateEntry
186 |
187 | func (t Template) PrettyDump() string {
188 | out := "\033[1;34m[*] DNSANITY TEMPLATE:\033[m\n"
189 | for _, entry := range t {
190 | out += " \033[34m* " + entry.ToString() + "\033[m\n"
191 | }
192 | return out
193 | }
194 |
195 | var errNoEntries = errors.New("no entries found")
196 |
197 | // load a template ([]DNSAnswer) from file.
198 | func NewTemplateFromFile(filePath string) (Template, error) {
199 | file, err := os.Open(filePath)
200 | if err != nil {
201 | return nil, fmt.Errorf("%q: %w", filePath, err)
202 | }
203 | defer file.Close()
204 |
205 | tpl, err := loadTemplate(
206 | file,
207 | func(err error, lineNo int) error {
208 | return fmt.Errorf("%v line %v: %w", filePath, lineNo, err)
209 | },
210 | )
211 | if err != nil {
212 | if err == errNoEntries {
213 | return nil, fmt.Errorf("Can't find any entry")
214 | }
215 | return nil, fmt.Errorf("Can't read %q: %w", filePath, err)
216 | }
217 | return tpl, nil
218 | }
219 |
220 | // load a template ([]DNSAnswer) from a multiline string.
221 | func NewTemplate(content string) (Template, error) {
222 | tpl, err := loadTemplate(
223 | strings.NewReader(content),
224 | func(err error, lineNo int) error {
225 | return fmt.Errorf("line %v: %w", lineNo, err)
226 | },
227 | )
228 | if err != nil {
229 | if err == errNoEntries {
230 | return nil, fmt.Errorf("Can't find any entry")
231 | }
232 | return nil, fmt.Errorf("Error reading input: %w", err)
233 | }
234 | return tpl, nil
235 | }
236 |
237 | // loadTemplate reads template entries from a reader,
238 | // using wrapErr to format line-specific errors
239 | func loadTemplate(
240 | r io.Reader,
241 | wrapErr func(error, int) error,
242 | ) (Template, error) {
243 | var tpl Template
244 | scanner := bufio.NewScanner(r)
245 | lineNo := 1
246 | for scanner.Scan() {
247 | line := scanner.Text()
248 | line = strings.Split(line, "#")[0]
249 | line = strings.TrimSpace(line)
250 | if line == "" {
251 | continue
252 | }
253 | // Convert to DNSAnswer
254 | entry, err := NewTemplateEntry(line)
255 | if err != nil {
256 | return nil, wrapErr(err, lineNo)
257 | }
258 | tpl = append(tpl, *entry)
259 | lineNo++
260 | }
261 | if err := scanner.Err(); err != nil {
262 | return nil, err
263 | } else if len(tpl) == 0 {
264 | return nil, errNoEntries
265 | }
266 | return tpl, nil
267 | }
268 |
--------------------------------------------------------------------------------
/internal/dns/template_test.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "io/ioutil"
5 | "os"
6 | "reflect"
7 | "strings"
8 | "testing"
9 | "errors"
10 | "path/filepath"
11 | )
12 |
13 | // TestNewTemplateEntry_Valid ensures that a well‑formed line is parsed correctly.
14 | func TestNewTemplateEntry_Valid(t *testing.T) {
15 | line := "example.com A=1.1.1.1 || NXDOMAIN || CNAME=alias.example.com. A=2.2.2.2"
16 | te, err := NewTemplateEntry(line)
17 | if err != nil {
18 | t.Fatalf("unexpected error: %v", err)
19 | }
20 | if te.Domain != "example.com" {
21 | t.Errorf("wrong domain: got %q", te.Domain)
22 | }
23 | if len(te.ValidAnswers) != 3 {
24 | t.Fatalf("expected 3 alternatives, got %d", len(te.ValidAnswers))
25 | }
26 | // Round‑trip via ToString() must preserve semantic content.
27 | rebuilt, err := NewTemplateEntry(te.ToString())
28 | if err != nil {
29 | t.Fatalf("round‑trip failed: %v", err)
30 | }
31 | if !reflect.DeepEqual(te, rebuilt) {
32 | t.Errorf("round‑trip mismatch: %+v vs %+v", te, rebuilt)
33 | }
34 | }
35 |
36 | // TestNewTemplateEntry_Invalid covers all error branches inside NewTemplateEntry().
37 | func TestNewTemplateEntry_Invalid(t *testing.T) {
38 | cases := []string{
39 | "", // empty line ➜ <2 tokens
40 | "onlydomain", // single token ➜ <2 tokens
41 | "bad.com AAAA=::1", // unsupported record ➜ invalid record
42 | "bad.com A=1.1.1.1||BLAH", // alternative with unsupported token
43 | }
44 | for _, c := range cases {
45 | if _, err := NewTemplateEntry(c); err == nil {
46 | t.Errorf("expected error for input %q", c)
47 | }
48 | }
49 | }
50 |
51 | // TestGlobMatch exercises the globMatch helper with tricky patterns.
52 | func TestGlobMatch(t *testing.T) {
53 | positive := map[string]string{
54 | "192.168.*.*": "192.168.10.20",
55 | "1.1.*.1": "1.1.99.1",
56 | "*.example.com": "sub.domain.EXAMPLE.com", // case‑insensitive
57 | "*.*.*.*": "255.255.255.255",
58 | "foo**bar*baz": "foobarbaz", // multiple consecutive '*'
59 | "foo**bar*baz2": "fooxbarxxbaz2", // complex skip
60 | }
61 | for pattern, value := range positive {
62 | if !globMatch(pattern, value) {
63 | t.Errorf("globMatch(%q,%q) expected true", pattern, value)
64 | }
65 | }
66 | // Negative checks.
67 | negatives := [][2]string{
68 | {"192.168.*.*", "10.0.0.1"},
69 | {"*.example.com", "sub.test.org"},
70 | {"foo*bar", "foobaz"},
71 | }
72 | for _, nv := range negatives {
73 | if globMatch(nv[0], nv[1]) {
74 | t.Errorf("globMatch(%q,%q) expected false", nv[0], nv[1])
75 | }
76 | }
77 | }
78 |
79 | // TestMatchRecords verifies exhaustive permutations and size mismatch logic.
80 | func TestMatchRecords(t *testing.T) {
81 | patterns := []string{"A", "B"}
82 | valuesAB := []string{"B", "A"}
83 | if !matchRecords(patterns, valuesAB) {
84 | t.Error("permutation should match")
85 | }
86 | // Size mismatch ➜ false.
87 | if matchRecords(patterns[:1], valuesAB) {
88 | t.Error("size mismatch must fail")
89 | }
90 | // Glob inside patterns.
91 | globPatterns := []string{"192.168.*.1", "10.*.0.1"}
92 | globValues := []string{"10.123.0.1", "192.168.99.1"}
93 | if !matchRecords(globPatterns, globValues) {
94 | t.Error("glob patterns should match values irrespective of order")
95 | }
96 | }
97 |
98 | // TestNextPermutation enumerates all permutations of a length‑3 slice.
99 | func TestNextPermutation(t *testing.T) {
100 | p := []int{0, 1, 2}
101 | seen := map[[3]int]bool{}
102 | for {
103 | var key [3]int
104 | copy(key[:], p)
105 | if seen[key] {
106 | t.Fatalf("duplicate permutation %v", p)
107 | }
108 | seen[key] = true
109 | if !nextPermutation(p) {
110 | break
111 | }
112 | }
113 | if len(seen) != 6 {
114 | t.Fatalf("expected 6 permutations, got %d", len(seen))
115 | }
116 | // last permutation must be descending order.
117 | if got := [3]int{p[0], p[1], p[2]}; got != [3]int{2, 1, 0} {
118 | t.Errorf("unexpected last permutation %v", got)
119 | }
120 | }
121 |
122 | // TestTemplateEntry_Matches covers success and failure scenarios.
123 | func TestTemplateEntry_Matches(t *testing.T) {
124 | entryLine := "service.local A=10.0.*.1 || NXDOMAIN"
125 | entry, _ := NewTemplateEntry(entryLine)
126 |
127 | // Successful A record match (glob).
128 | ans := &DNSAnswer{
129 | Domain: "service.local",
130 | DNSAnswerData: DNSAnswerData{
131 | Status: "NOERROR",
132 | A: []string{"10.0.50.1"},
133 | },
134 | }
135 | if !entry.Matches(ans) {
136 | t.Error("expected A record to match")
137 | }
138 | // Successful NXDOMAIN alternative.
139 | ans2 := &DNSAnswer{Domain: "service.local", DNSAnswerData: DNSAnswerData{Status: "NXDOMAIN"}}
140 | if !entry.Matches(ans2) {
141 | t.Error("expected NXDOMAIN to match")
142 | }
143 | // Domain mismatch ➜ false.
144 | ans3 := &DNSAnswer{Domain: "other.local", DNSAnswerData: DNSAnswerData{Status: "NXDOMAIN"}}
145 | if entry.Matches(ans3) {
146 | t.Error("domain mismatch must fail")
147 | }
148 | }
149 |
150 | // TestLoadTemplate_StringInput hits the happy path and PrettyDump().
151 | func TestLoadTemplate_StringInput(t *testing.T) {
152 | tmpl := `
153 | # comment line
154 | example.com A=1.1.1.1
155 | invalid.com NXDOMAIN || SERVFAIL
156 | `
157 | tpls, err := NewTemplate(tmpl)
158 | if err != nil {
159 | t.Fatalf("unexpected error: %v", err)
160 | }
161 | if len(tpls) != 2 {
162 | t.Fatalf("expected 2 entries, got %d", len(tpls))
163 | }
164 | // PrettyDump should include exactly two bullet lines.
165 | dump := tpls.PrettyDump()
166 | if cnt := strings.Count(dump, "* "); cnt != 2 {
167 | t.Errorf("PrettyDump should list 2 entries, got %d", cnt)
168 | }
169 | }
170 |
171 | // TestLoadTemplate_ErrorPaths exercises errNoEntries and invalid line handling.
172 | func TestLoadTemplate_ErrorPaths(t *testing.T) {
173 | // 1) Only comments ➜ errNoEntries.
174 | if _, err := NewTemplate("# just a comment\n\n"); err == nil {
175 | t.Error("expected error for empty template")
176 | }
177 | // 2) Line with invalid record token.
178 | bad := "bad.com AAAA=::1"
179 | if _, err := NewTemplate(bad); err == nil {
180 | t.Error("expected error for invalid record token")
181 | }
182 | }
183 |
184 | // TestNewTemplateFromFile creates a temporary file to ensure the file path branch executes.
185 | func TestNewTemplateFromFile(t *testing.T) {
186 | content := `
187 | # comment
188 | example.org NXDOMAIN
189 | cr.yp.to A=131.193.32.108 A=131.193.32.109 || TIMEOUT||A=*
190 | `
191 | tmp, err := ioutil.TempFile("", "tpl*.txt")
192 | if err != nil {
193 | t.Fatalf("tempfile: %v", err)
194 | }
195 | defer os.Remove(tmp.Name())
196 | if _, err := tmp.WriteString(content); err != nil {
197 | t.Fatalf("write tmp: %v", err)
198 | }
199 | tmp.Close()
200 |
201 | tpl, err := NewTemplateFromFile(tmp.Name())
202 | if err != nil {
203 | t.Fatalf("unexpected error: %v", err)
204 | }
205 | if len(tpl) != 2 || tpl[0].Domain != "example.org" || len(tpl[1].ValidAnswers) != 3 {
206 | t.Errorf("template not loaded correctly: %+v", tpl)
207 | }
208 | }
209 |
210 |
211 | // createTempFile is a helper that writes content to a fresh temporary file and returns the file path and a cleanup func.
212 | func createTempFile(t *testing.T, content string) (string, func()) {
213 | t.Helper()
214 | tmp, err := ioutil.TempFile("", "tpl*.txt")
215 | if err != nil {
216 | t.Fatalf("tempfile: %v", err)
217 | }
218 | if _, err := tmp.WriteString(content); err != nil {
219 | t.Fatalf("write tmp: %v", err)
220 | }
221 | if err := tmp.Close(); err != nil {
222 | t.Fatalf("close tmp: %v", err)
223 | }
224 | return tmp.Name(), func() { os.Remove(tmp.Name()) }
225 | }
226 |
227 | // TestNewTemplateFromFile_FileNotFound ensures that the function correctly wraps os.Open errors.
228 | func TestNewTemplateFromFile_FileNotFound(t *testing.T) {
229 | t.Parallel()
230 | fakePath := filepath.Join(os.TempDir(), "definitely‑not‑exist‑12345.txt")
231 | _, err := NewTemplateFromFile(fakePath)
232 | if err == nil {
233 | t.Fatal("expected error for missing file")
234 | }
235 | // The wrapped error must preserve os.ErrNotExist.
236 | if !errors.Is(err, os.ErrNotExist) {
237 | t.Fatalf("expected not‑exist error, got: %v", err)
238 | }
239 | if !strings.Contains(err.Error(), fakePath) {
240 | t.Errorf("error message should contain the file path; got %v", err)
241 | }
242 | }
243 |
244 | // TestNewTemplateFromFile_EmptyFile checks the branch that reports "Can't find any entry".
245 | func TestNewTemplateFromFile_EmptyFile(t *testing.T) {
246 | t.Parallel()
247 | path, cleanup := createTempFile(t, "# just a comment\n\n \n")
248 | defer cleanup()
249 |
250 | _, err := NewTemplateFromFile(path)
251 | if err == nil {
252 | t.Fatal("expected error for file with no entries")
253 | }
254 | if !strings.Contains(err.Error(), "Can't find any entry") {
255 | t.Errorf("unexpected error message: %v", err)
256 | }
257 | }
258 |
259 | // TestNewTemplateFromFile_InvalidLineNumber validates error wrapping and accurate line numbers.
260 | func TestNewTemplateFromFile_InvalidLineNumber(t *testing.T) {
261 | t.Parallel()
262 | content := "valid.com NXDOMAIN\ninvalid.com AAAA=::1\n"
263 | path, cleanup := createTempFile(t, content)
264 | defer cleanup()
265 |
266 | _, err := NewTemplateFromFile(path)
267 | if err == nil {
268 | t.Fatal("expected parsing error")
269 | }
270 | if !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), "line 2") {
271 | t.Errorf("error should include path and line 2; got %v", err)
272 | }
273 | }
274 |
275 | // TestNewTemplateFromFile_ValidComplex explores a realistic file with comments, blank lines, globs and multiple alternatives.
276 | func TestNewTemplateFromFile_ValidComplex(t *testing.T) {
277 | t.Parallel()
278 | complex := `
279 | # initial blank lines and comments should be ignored
280 |
281 |
282 |
283 | example.net A=1.1.1.1 || A=2.2.2.2
284 | test.invalid NXDOMAIN
285 | wildcard.com A=192.168.*.* || SERVFAIL
286 | `
287 |
288 | path, cleanup := createTempFile(t, complex)
289 | defer cleanup()
290 |
291 | tpl, err := NewTemplateFromFile(path)
292 | if err != nil {
293 | t.Fatalf("unexpected error: %v", err)
294 | }
295 | if len(tpl) != 3 {
296 | t.Fatalf("expected 3 parsed entries, got %d", len(tpl))
297 | }
298 |
299 | domains := []string{tpl[0].Domain, tpl[1].Domain, tpl[2].Domain}
300 | expected := []string{"example.net", "test.invalid", "wildcard.com"}
301 | for i, exp := range expected {
302 | if domains[i] != exp {
303 | t.Errorf("entry %d domain mismatch: want %q got %q", i, exp, domains[i])
304 | }
305 | }
306 | if l := len(tpl[0].ValidAnswers); l != 2 {
307 | t.Errorf("first entry expected 2 alternatives, got %d", l)
308 | }
309 | if l := len(tpl[2].ValidAnswers); l != 2 {
310 | t.Errorf("third entry expected 2 alternatives, got %d", l)
311 | }
312 | }
313 |
--------------------------------------------------------------------------------
/internal/dnsanitize/dnsanitize.go:
--------------------------------------------------------------------------------
1 | package dnsanitize
2 |
3 | import (
4 | "sync"
5 | "time"
6 |
7 | "github.com/nil0x42/dnsanity/internal/config"
8 | "github.com/nil0x42/dnsanity/internal/report"
9 | "github.com/nil0x42/dnsanity/internal/dns"
10 | )
11 |
12 |
13 | // ---------------------------------------------------------------------------
14 | // Worker & scheduler plumbing -----------------------------------------------
15 | // ---------------------------------------------------------------------------
16 |
17 | // WorkerResult is used by goroutines to send back the final DNSAnswer.
18 | type WorkerResult struct {
19 | SrvID int // srv id in pool
20 | CheckID int // check index
21 | Answer *dns.DNSAnswer // received answer
22 | Passed bool // equals? result
23 | }
24 |
25 | type QueryScheduler struct {
26 | waitGroup sync.WaitGroup
27 | JobLimiter chan struct{}
28 | RateLimiter *RateLimiter
29 | Results chan WorkerResult // worker results are sent here
30 | }
31 |
32 | func runDNSWorker(
33 | srv *dns.ServerContext, // server context
34 | check *dns.TemplateEntry, // template check
35 | srvID int, // server ID (in pool)
36 | checkID int, // check ID (template index)
37 | timeout time.Duration, // DNS query timeout
38 | sched *QueryScheduler, // scheduler
39 | ) {
40 | defer sched.waitGroup.Done()
41 | answer := dns.ResolveDNS(check.Domain, srv.IPAddress, timeout, srv.Ctx)
42 | sched.Results <- WorkerResult{
43 | SrvID: srvID,
44 | CheckID: checkID,
45 | Answer: answer,
46 | Passed: check.Matches(answer),
47 | }
48 | <- sched.JobLimiter
49 | }
50 |
51 | // ---------------------------------------------------------------------------
52 | // PUBLIC ENTRYPOINT --------------------------------------------------------
53 | // ---------------------------------------------------------------------------
54 | func DNSanitize(
55 | s *config.Settings,
56 | status *report.StatusReporter,
57 | ) {
58 | qryTimeout := time.Duration(s.PerQueryTimeout) * time.Second
59 | srvReqInterval := time.Duration(0)
60 | if s.PerSrvRateLimit > 0 {
61 | srvReqInterval = time.Duration(float64(time.Second) / s.PerSrvRateLimit)
62 | }
63 |
64 | // init server pool
65 | pool := NewServerPool(
66 | s.MaxPoolSize, s.ServerIPs, s.Template, s.PerCheckMaxAttempts)
67 |
68 | // init scheduler
69 | maxThreads := min(s.MaxThreads, len(s.ServerIPs) * len(s.Template))
70 | sched := &QueryScheduler{
71 | JobLimiter: make(chan struct{}, maxThreads),
72 | Results: make(chan WorkerResult, maxThreads),
73 | RateLimiter: NewRateLimiter(s.GlobRateLimit, time.Second),
74 | }
75 | // Run the scheduling loop to fill out servers
76 | scheduleChecks(
77 | pool, s.Template, sched, status,
78 | qryTimeout, srvReqInterval, s.PerSrvMaxFailures,
79 | )
80 | // stop gobal ratelimiter
81 | sched.RateLimiter.StopRefiller()
82 | }
83 |
84 | // scheduleChecks is the core scheduler that dispatches DNS queries,
85 | // observes concurrency limits, rate limits, and failure thresholds.
86 | func scheduleChecks(
87 | pool *ServerPool,
88 | template dns.Template,
89 | sched *QueryScheduler,
90 | status *report.StatusReporter,
91 | qryTimeout time.Duration,
92 | srvReqInterval time.Duration,
93 | srvMaxFailures int,
94 | ) {
95 | inFlight := make(map[int]int)
96 | for {
97 | // 1) async collection of worker results -----------------------------
98 | collectLoop:
99 | for {
100 | select {
101 | case res := <-sched.Results:
102 | // request done -> decrement inFlight count
103 | if n := inFlight[res.SrvID]; n > 1 {
104 | inFlight[res.SrvID] = n - 1
105 | } else { // <=0
106 | delete(inFlight, res.SrvID)
107 | }
108 | srv, srvExists := pool.Get(res.SrvID)
109 | if !srvExists { // server already dropped
110 | continue
111 | }
112 | applyResults(srv, &res, srvMaxFailures, status)
113 | if srv.Finished() {
114 | status.ReportFinishedServer(srv) // report server
115 | pool.Unload(res.SrvID) // drop server from pool
116 | }
117 | default:
118 | break collectLoop
119 | }
120 | }
121 |
122 | // 2) scheduling new queries -----------------------------------------
123 | now := time.Now()
124 | numScheduled, numScheduledBusy, numScheduledIdle := 0, 0, 0
125 | poolCanGrow := pool.CanGrow()
126 | busyJobs := len(sched.JobLimiter)
127 | freeJobs := cap(sched.JobLimiter) - busyJobs
128 | // Two passes : first IDLE servers, then BUSY (inFlight).
129 | // Second pass is allowed ONLY when the pool cannot grow anymore.
130 | for pass := 0; pass < 2 && freeJobs > 0; pass++ {
131 | if pass == 1 && poolCanGrow {
132 | break // skip BUSY pass if pool can grow
133 | }
134 | for srvID, srv := range pool.pool {
135 | idle := inFlight[srvID] == 0 // not in inFlight: srv is idle
136 | if ///////////////////////////////// SKIP SERVER IF:
137 | (pass == 0 && !idle) || // - BUSY srv on IDLE pass
138 | (pass == 1 && idle) || // - IDLE srv on BUSY pass
139 | len(srv.PendingChecks) == 0 || // - nothing to do now
140 | srv.NextQueryAt.After(now) || // - per‑server rate‑limit
141 | !sched.RateLimiter.ConsumeOne() { // - global RPS exceeded
142 | continue
143 | }
144 | select {
145 | case sched.JobLimiter <- struct{}{}:
146 | // We can schedule a new test for this server
147 | inFlight[srvID]++
148 | busyJobs = max(busyJobs, len(sched.JobLimiter))
149 | checkID := srv.PendingChecks[0]
150 | srv.PendingChecks = srv.PendingChecks[1:]
151 | sched.waitGroup.Add(1)
152 | go runDNSWorker(
153 | srv, &template[checkID],
154 | srvID, checkID, qryTimeout, sched,
155 | )
156 | srv.NextQueryAt = now.Add(srvReqInterval)
157 | freeJobs--
158 | numScheduled++
159 | if idle {
160 | numScheduledIdle++
161 | } else {
162 | numScheduledBusy++
163 | }
164 | default:
165 | // No free worker slot: give back the consumed token
166 | sched.RateLimiter.GiveBackOne()
167 | }
168 | }
169 | }
170 | // notify num of requests just scheduled (for RPS count)
171 | if numScheduled > 0 {
172 | status.LogRequests(now, numScheduledIdle, numScheduledBusy)
173 | status.UpdateBusyJobs(busyJobs)
174 | }
175 | // 3) termination condition ------------------------------------------
176 | if pool.IsDrained() {
177 | break // every server processed and pool emptied
178 | }
179 | // 4) refill pool if we have job/req budget
180 | poolGrowth := 0
181 | if poolCanGrow && freeJobs != 0 {
182 | freeReqs := sched.RateLimiter.Remaining()
183 | toLoad := min(freeReqs, freeJobs)
184 | if toLoad > 0 {
185 | poolGrowth = pool.LoadN(toLoad)
186 | if poolGrowth > 0 {
187 | status.UpdatePoolSize(pool.Len())
188 | status.Debug("expand pool by %d. newsz=%d", poolGrowth, pool.Len())
189 | }
190 | }
191 | }
192 | if numScheduled == 0 && poolGrowth == 0 {
193 | time.Sleep(13 * time.Millisecond) // avoid busy‑wait
194 | }
195 | }
196 | // END) Once all works are done, wait for remaining workers
197 | sched.waitGroup.Wait()
198 | }
199 |
200 | // applyResults updates a ServerContext after one DNS query
201 | // and reflects the change into the shared Status struct.
202 | // This runs in the scheduler goroutine (single-threaded),
203 | func applyResults(
204 | srv *dns.ServerContext, // server
205 | res *WorkerResult, // worker result
206 | srvMaxFailures int, // max allowed non-passing checks per server
207 | status *report.StatusReporter,
208 | ) {
209 | chk := &srv.Checks[res.CheckID]
210 | chk.AttemptsLeft--
211 | chk.Answer = res.Answer
212 | /* ---------- success ------------------------------------------------ */
213 | if res.Passed {
214 | chk.Passed = true
215 | srv.CompletedCount++
216 | status.AddDoneChecks(+1, +0) // +1 done, +0 total
217 | return
218 | }
219 | /* ---------- failure, retry remaining ------------------------------- */
220 | if chk.AttemptsLeft > 0 {
221 | // re-queue the check at the front
222 | srv.PendingChecks = append([]int{res.CheckID}, srv.PendingChecks...)
223 | status.AddDoneChecks(+1, +1) // +1 done, +1 total
224 | return
225 | }
226 | /* ---------- failure, no retry left --------------------------------- */
227 | srv.CompletedCount++
228 | srv.FailedCount++
229 | // reached drop threshold?
230 | if srv.FailedCount >= srvMaxFailures {
231 | // how many planned checks are immediately cancelled
232 | cancelledChecks := len(srv.Checks) - srv.CompletedCount
233 | status.AddDoneChecks(+1, -cancelledChecks)
234 | srv.Disabled = true
235 | srv.CancelCtx()
236 | } else {
237 | status.AddDoneChecks(+1, +0) // +1 done, +0 total
238 | }
239 | }
240 |
--------------------------------------------------------------------------------
/internal/dnsanitize/dnsanitize_test.go:
--------------------------------------------------------------------------------
1 | package dnsanitize
2 |
3 | // All tests below aim for near‑complete coverage of the dnsanitize package.
4 | // Comments are deliberately kept in **English** as required by the project
5 | // guidelines, while normal assistant explanations remain in French.
6 |
7 | import (
8 | "bytes"
9 | "context"
10 | "reflect"
11 | "sync"
12 | "sync/atomic"
13 | "testing"
14 | "time"
15 | "unsafe"
16 |
17 | "github.com/nil0x42/dnsanity/internal/config"
18 | "github.com/nil0x42/dnsanity/internal/dns"
19 | "github.com/nil0x42/dnsanity/internal/report"
20 | )
21 |
22 | // ---------------------------------------------------------------------------
23 | // Helpers -------------------------------------------------------------------
24 | // ---------------------------------------------------------------------------
25 |
26 | // newIOFiles returns an empty IOFiles ready to be embedded inside a
27 | // StatusReporter. Every file writer is nil, which is fine because the
28 | // implementation checks for nil before writing.
29 | func newIOFiles() *report.IOFiles {
30 | return &report.IOFiles{
31 | OutputFile: bytes.NewBuffer(nil),
32 | VerboseFile: nil,
33 | DebugFile: nil,
34 | TTYFile: nil,
35 | }
36 | }
37 |
38 | // newStatus creates a *report.StatusReporter with its private `io` field set
39 | // to a minimal instance so that calls like ReportFinishedServer() never panic.
40 | // We must reach into the unexported field using reflect + unsafe, which is
41 | // perfectly acceptable inside tests.
42 | func newStatus() *report.StatusReporter {
43 | sr := &report.StatusReporter{}
44 |
45 | // Sneak‑set the private `io` field.
46 | rv := reflect.ValueOf(sr).Elem()
47 | ioField := rv.FieldByName("io") // unexported – use unsafe
48 | if ioField.IsValid() {
49 | p := unsafe.Pointer(ioField.UnsafeAddr())
50 | reflect.NewAt(ioField.Type(), p).Elem().Set(reflect.ValueOf(newIOFiles()))
51 | }
52 | return sr
53 | }
54 |
55 | // helperServer creates a minimal ServerContext with the desired AttemptsLeft.
56 | func helperServer(attempts int) *dns.ServerContext {
57 | ctx, cancel := context.WithCancel(context.Background())
58 | return &dns.ServerContext{
59 | Ctx: ctx,
60 | CancelCtx: cancel,
61 | IPAddress: "192.0.2.123", // TEST‑NET‑1: guaranteed unroutable
62 | PendingChecks: []int{},
63 | Checks: []dns.CheckContext{{AttemptsLeft: attempts, MaxAttempts: attempts}},
64 | }
65 | }
66 |
67 | // dummyTemplate returns a one‑line template entry targeting an RFC‑2606 domain.
68 | func dummyTemplate() dns.Template {
69 | entry := dns.TemplateEntry{
70 | Domain: "invalid.test", // will never resolve
71 | ValidAnswers: []dns.DNSAnswerData{{Status: "TIMEOUT"}},
72 | }
73 | return dns.Template{entry}
74 | }
75 |
76 | // ---------------------------------------------------------------------------
77 | // min / max helpers ----------------------------------------------------------
78 | // ---------------------------------------------------------------------------
79 |
80 | func TestMinMaxHelpers(t *testing.T) {
81 | t.Parallel()
82 |
83 | if max(1, 2) != 2 {
84 | t.Fatalf("max(1,2) should be 2")
85 | }
86 | if min(1, 2) != 1 {
87 | t.Fatalf("min(1,2) should be 1")
88 | }
89 | }
90 |
91 | // ---------------------------------------------------------------------------
92 | // applyResults --------------------------------------------------------------
93 | // ---------------------------------------------------------------------------
94 |
95 | func TestApplyResultsPaths(t *testing.T) {
96 | t.Parallel()
97 |
98 | st := newStatus()
99 | res := WorkerResult{SrvID: 0, CheckID: 0, Passed: true}
100 |
101 | // Success path ---------------------------------------------------------
102 | srv := helperServer(1)
103 | applyResults(srv, &res, 1, st)
104 | if srv.CompletedCount != 1 || srv.FailedCount != 0 || !srv.Checks[0].Passed {
105 | t.Fatal("applyResults success path failed")
106 | }
107 |
108 | // Retry path -----------------------------------------------------------
109 | srv = helperServer(2)
110 | res.Passed = false
111 | applyResults(srv, &res, 2, st)
112 | if len(srv.PendingChecks) != 1 || srv.Checks[0].AttemptsLeft != 1 {
113 | t.Fatal("applyResults retry path incorrect")
114 | }
115 |
116 | // Final failure → server disabled when maxFailures reached -------------
117 | srv = helperServer(1)
118 | applyResults(srv, &res, 0, st) // maxFailures==0 → immediate drop
119 | if !srv.Disabled || srv.FailedCount != 1 {
120 | t.Fatal("applyResults final failure logic incorrect")
121 | }
122 | }
123 |
124 | // ---------------------------------------------------------------------------
125 | // runDNSWorker --------------------------------------------------------------
126 | // ---------------------------------------------------------------------------
127 |
128 | func TestRunDNSWorker(t *testing.T) {
129 | t.Parallel()
130 |
131 | tmpl := dummyTemplate()
132 | srv := dns.NewServerContext("192.0.2.45", tmpl, 1)
133 | // Cancel ctx to make ResolveDNS return immediately
134 | srv.CancelCtx()
135 |
136 | sched := &QueryScheduler{
137 | JobLimiter: make(chan struct{}, 1),
138 | RateLimiter: NewRateLimiter(1, time.Second),
139 | Results: make(chan WorkerResult, 1),
140 | }
141 | sched.JobLimiter <- struct{}{} // occupy one slot
142 |
143 | sched.waitGroup.Add(1)
144 | go runDNSWorker(srv, &tmpl[0], 0, 0, time.Millisecond*5, sched)
145 | sched.waitGroup.Wait()
146 |
147 | res := <-sched.Results
148 | if res.SrvID != 0 || res.CheckID != 0 || res.Answer.Domain != "invalid.test" {
149 | t.Fatal("runDNSWorker produced unexpected result data")
150 | }
151 | }
152 |
153 | // ---------------------------------------------------------------------------
154 | // RateLimiter sanity & concurrency -----------------------------------------
155 | // ---------------------------------------------------------------------------
156 |
157 | func TestRateLimiterBasic(t *testing.T) {
158 | t.Parallel()
159 |
160 | rl := NewRateLimiter(10, 50*time.Millisecond)
161 | if rl.Remaining() == 0 {
162 | t.Fatal("expected some initial tokens in RateLimiter")
163 | }
164 | if !rl.ConsumeOne() {
165 | t.Fatal("ConsumeOne should succeed when tokens are available")
166 | }
167 | rl.GiveBackOne()
168 | if rl.Remaining() == 0 {
169 | t.Fatal("GiveBackOne failed to return token")
170 | }
171 | rl.StopRefiller() // idempotent
172 | }
173 |
174 | func TestRateLimiterConcurrency(t *testing.T) {
175 | t.Parallel()
176 |
177 | rl := NewRateLimiter(100, 10*time.Millisecond)
178 | defer rl.StopRefiller()
179 |
180 | var ok int32
181 | var wg sync.WaitGroup
182 | for i := 0; i < 200; i++ {
183 | wg.Add(1)
184 | go func() {
185 | defer wg.Done()
186 | if rl.ConsumeOne() {
187 | atomic.AddInt32(&ok, 1)
188 | }
189 | }()
190 | }
191 | wg.Wait()
192 | if ok == 0 {
193 | t.Fatal("no goroutine managed to consume a token – concurrency broken")
194 | }
195 | }
196 |
197 | // ---------------------------------------------------------------------------
198 | // Mini end‑to‑end run through DNSanitize() ----------------------------------
199 | // ---------------------------------------------------------------------------
200 |
201 | func TestDNSanitizeEndToEnd(t *testing.T) {
202 | t.Parallel()
203 |
204 | settings := &config.Settings{
205 | ServerIPs: []string{"192.0.2.99"}, // unroutable
206 | Template: dummyTemplate(),
207 | MaxThreads: 2,
208 | MaxPoolSize: 2,
209 | GlobRateLimit: 50,
210 | PerSrvRateLimit: 1,
211 | PerSrvMaxFailures: -1, // never drop
212 | PerCheckMaxAttempts: 1,
213 | PerQueryTimeout: 1,
214 | }
215 |
216 | st := newStatus()
217 |
218 | done := make(chan struct{})
219 | go func() {
220 | DNSanitize(settings, st)
221 | close(done)
222 | }()
223 |
224 | select {
225 | case <-done:
226 | // success – function returned
227 | case <-time.After(5 * time.Second):
228 | t.Fatal("DNSanitize did not finish within expected time")
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/internal/dnsanitize/ratelimiter.go:
--------------------------------------------------------------------------------
1 | package dnsanitize
2 |
3 | import (
4 | "context"
5 | "math"
6 | "runtime"
7 | "sync"
8 | "sync/atomic"
9 | "time"
10 | )
11 |
12 | // RateLimiter implements a high-performance token bucket rate limiter
13 | // using Go 1.19 atomic wrappers. It guarantees zero mutex usage,
14 | // idempotent start/stop, and strict parameter validation.
15 | type RateLimiter struct {
16 | tokens atomic.Uint64 // current number of available tokens
17 | maxTokens atomic.Uint64 // maximum number of tokens (burst capacity)
18 | refillAmount atomic.Uint64 // tokens added each interval
19 | refillInterval time.Duration // interval between refills
20 |
21 | startOnce sync.Once // ensures StartRefiller is called only once
22 | ctx context.Context // context for cancellation
23 | cancel context.CancelFunc // cancellation function
24 | }
25 |
26 | // NewRateLimiter creates a new RateLimiter given a desired rate (RPS) and burst duration.
27 | // It panics if parameters are invalid or lead to impossible internal values.
28 | func NewRateLimiter(globalRateLimit int, burstTime time.Duration) *RateLimiter {
29 | // validate inputs
30 | if globalRateLimit < 1 {
31 | panic("dnsanitize: globalRateLimit must be >= 1")
32 | }
33 | if burstTime <= 0 {
34 | panic("dnsanitize: burstTime must be > 0")
35 | }
36 |
37 | // calculate desired tokens as float and round
38 | floatTokens := float64(globalRateLimit) * burstTime.Seconds()
39 | refillAmount := uint64(math.Round(floatTokens))
40 | if refillAmount < 1 {
41 | panic("dnsanitize: computed refillAmount < 1; burstTime too small relative to rate")
42 | }
43 |
44 | // derive interval to ensure exact average rate
45 | realInterval := time.Duration(
46 | float64(refillAmount)/float64(globalRateLimit)*float64(time.Second),
47 | )
48 | if realInterval < time.Nanosecond {
49 | panic("dnsanitize: computed refillInterval < 1ns; parameters too extreme")
50 | }
51 |
52 | // initialize context for cancellation
53 | ctx, cancel := context.WithCancel(context.Background())
54 |
55 | // build bucket
56 | tb := &RateLimiter{
57 | refillInterval: realInterval,
58 | ctx: ctx,
59 | cancel: cancel,
60 | }
61 | tb.tokens.Store(refillAmount)
62 | tb.maxTokens.Store(refillAmount)
63 | tb.refillAmount.Store(refillAmount)
64 | tb.startRefiller()
65 | return tb
66 | }
67 |
68 | // StartRefiller begins a background goroutine that refills tokens at fixed intervals.
69 | // It is safe to call multiple times; the refiller will only start once.
70 | func (tb *RateLimiter) startRefiller() {
71 | tb.startOnce.Do(func() {
72 | ticker := time.NewTicker(tb.refillInterval)
73 | go func() {
74 | defer ticker.Stop()
75 | for {
76 | select {
77 | case <-tb.ctx.Done():
78 | return
79 | case <-ticker.C:
80 | tb.refillOnce()
81 | }
82 | }
83 | }()
84 | })
85 | }
86 |
87 | // StopRefiller stops the background refill goroutine. It is safe to call multiple times.
88 | func (tb *RateLimiter) StopRefiller() {
89 | // cancel is idempotent and thread-safe
90 | tb.cancel()
91 | }
92 |
93 | // refillOnce performs a single refill operation with CAS loop and minimal backoff.
94 | func (tb *RateLimiter) refillOnce() {
95 | const maxSpins = 10
96 | for spins := 0; ; spins++ {
97 | old := tb.tokens.Load()
98 | want := old + tb.refillAmount.Load()
99 | max := tb.maxTokens.Load()
100 | if want > max {
101 | want = max
102 | }
103 | if tb.tokens.CompareAndSwap(old, want) {
104 | return
105 | }
106 | if spins >= maxSpins {
107 | runtime.Gosched()
108 | spins = 0
109 | }
110 | }
111 | }
112 |
113 | // ConsumeOne attempts to remove one token. Returns true if successful.
114 | // This method is lock-free and non-blocking.
115 | func (tb *RateLimiter) ConsumeOne() bool {
116 | const maxSpins = 10
117 | for spins := 0; ; spins++ {
118 | old := tb.tokens.Load()
119 | if old == 0 {
120 | return false
121 | }
122 | if tb.tokens.CompareAndSwap(old, old-1) {
123 | return true
124 | }
125 | if spins >= maxSpins {
126 | runtime.Gosched()
127 | spins = 0
128 | }
129 | }
130 | }
131 |
132 | // GiveBackOne returns one token back into the bucket if not already full.
133 | // This method is lock-free and non-blocking.
134 | func (tb *RateLimiter) GiveBackOne() {
135 | const maxSpins = 10
136 | for spins := 0; ; spins++ {
137 | old := tb.tokens.Load()
138 | max := tb.maxTokens.Load()
139 | if old >= max {
140 | return
141 | }
142 | if tb.tokens.CompareAndSwap(old, old+1) {
143 | return
144 | }
145 | if spins >= maxSpins {
146 | runtime.Gosched()
147 | spins = 0
148 | }
149 | }
150 | }
151 |
152 | // Remaining returns the number of remaining reqs that can be done right now
153 | func (tb *RateLimiter) Remaining() int {
154 | return int(tb.tokens.Load())
155 | }
156 |
--------------------------------------------------------------------------------
/internal/dnsanitize/ratelimiter_test.go:
--------------------------------------------------------------------------------
1 | package dnsanitize
2 |
3 | import (
4 | "sync"
5 | "sync/atomic"
6 | "testing"
7 | "time"
8 | )
9 |
10 | // expectPanic executes fn and ensures it panics – helper for invalid constructor parameters.
11 | func expectPanic(t *testing.T, name string, fn func()) {
12 | t.Helper()
13 | defer func() {
14 | if r := recover(); r == nil {
15 | t.Fatalf("%s: expected panic, but none occurred", name)
16 | }
17 | }()
18 | fn()
19 | }
20 |
21 | func TestRateLimiterInvalidParamsPanics(t *testing.T) {
22 | t.Parallel()
23 | cases := []struct {
24 | name string
25 | rate int
26 | burst time.Duration
27 | }{
28 | {"rate zero", 0, time.Second},
29 | {"burst zero", 10, 0},
30 | {"burst too small", 1000, time.Nanosecond},
31 | }
32 | for _, tc := range cases {
33 | expectPanic(t, tc.name, func() {
34 | _ = NewRateLimiter(tc.rate, tc.burst)
35 | })
36 | }
37 | }
38 |
39 | func TestRateLimiterConsumeAndGiveBack(t *testing.T) {
40 | t.Parallel()
41 | rl := NewRateLimiter(10, time.Second) // 10 tokens, 1s interval
42 | defer rl.StopRefiller()
43 |
44 | if got := rl.Remaining(); got != 10 {
45 | t.Fatalf("initial Remaining() = %d, want 10", got)
46 | }
47 |
48 | // consume all tokens
49 | for i := 0; i < 10; i++ {
50 | if !rl.ConsumeOne() {
51 | t.Fatalf("ConsumeOne() returned false at iteration %d", i)
52 | }
53 | }
54 | if rl.ConsumeOne() {
55 | t.Fatalf("ConsumeOne() should fail when bucket is empty")
56 | }
57 | if got := rl.Remaining(); got != 0 {
58 | t.Fatalf("Remaining() after drain = %d, want 0", got)
59 | }
60 |
61 | // give back a single token
62 | rl.GiveBackOne()
63 | if got := rl.Remaining(); got != 1 {
64 | t.Fatalf("Remaining() after single GiveBackOne() = %d, want 1", got)
65 | }
66 |
67 | // spam GiveBackOne – should never exceed max (10)
68 | for i := 0; i < 20; i++ {
69 | rl.GiveBackOne()
70 | }
71 | if got := rl.Remaining(); got != 10 {
72 | t.Fatalf("Remaining() after excessive GiveBackOne() = %d, want 10", got)
73 | }
74 | }
75 |
76 | func TestRateLimiterRefill(t *testing.T) {
77 | t.Parallel()
78 | // rate = 200 rps, burst = 5ms ➜ 1 token, interval ≈5ms
79 | rl := NewRateLimiter(200, 5*time.Millisecond)
80 | defer rl.StopRefiller()
81 |
82 | // consume the single token
83 | if !rl.ConsumeOne() {
84 | t.Fatalf("failed to consume initial token")
85 | }
86 | if rl.Remaining() != 0 {
87 | t.Fatalf("bucket not empty after consume")
88 | }
89 |
90 | // wait long enough for at least one refill tick
91 | time.Sleep(15 * time.Millisecond)
92 | if got := rl.Remaining(); got == 0 {
93 | t.Fatalf("bucket did not refill after interval")
94 | }
95 | }
96 |
97 | func TestRateLimiterStopRefiller(t *testing.T) {
98 | t.Parallel()
99 | rl := NewRateLimiter(100, 10*time.Millisecond)
100 |
101 | // drain bucket and stop refiller quickly
102 | rl.ConsumeOne()
103 | rl.StopRefiller()
104 | tokensBefore := rl.Remaining()
105 |
106 | // wait several intervals to verify no further refills
107 | time.Sleep(40 * time.Millisecond)
108 | if got := rl.Remaining(); got != tokensBefore {
109 | t.Fatalf("tokens changed after StopRefiller(): before=%d after=%d", tokensBefore, got)
110 | }
111 | }
112 |
113 | func TestRateLimiterConcurrencySafety(t *testing.T) {
114 | t.Parallel()
115 | const tokens = 1000
116 | rl := NewRateLimiter(tokens, time.Second) // 1s burst gives tokens tokens
117 | rl.StopRefiller() // lock bucket size – no new tokens
118 |
119 | var consumed int64
120 | var wg sync.WaitGroup
121 |
122 | // concurrent consumption
123 | for i := 0; i < 20; i++ {
124 | wg.Add(1)
125 | go func() {
126 | defer wg.Done()
127 | for {
128 | if rl.ConsumeOne() {
129 | atomic.AddInt64(&consumed, 1)
130 | } else {
131 | return
132 | }
133 | }
134 | }()
135 | }
136 | wg.Wait()
137 | if consumed != tokens {
138 | t.Fatalf("concurrent consumed=%d, want %d", consumed, tokens)
139 | }
140 | if rl.Remaining() != 0 {
141 | t.Fatalf("Remaining() after concurrent drain = %d, want 0", rl.Remaining())
142 | }
143 |
144 | // concurrent GiveBackOne up to capacity (and beyond)
145 | var retWG sync.WaitGroup
146 | for i := 0; i < tokens*2; i++ { // try to overflow bucket
147 | retWG.Add(1)
148 | go func() {
149 | rl.GiveBackOne()
150 | retWG.Done()
151 | }()
152 | }
153 | retWG.Wait()
154 | if rl.Remaining() != tokens {
155 | t.Fatalf("Remaining() after concurrent GiveBackOne = %d, want %d", rl.Remaining(), tokens)
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/internal/dnsanitize/serverpool.go:
--------------------------------------------------------------------------------
1 | package dnsanitize
2 |
3 | import (
4 | "github.com/nil0x42/dnsanity/internal/dns"
5 | )
6 |
7 | // ServerPool streams huge resolver lists in small batches to save memory.
8 | // All methods are single-goroutine – no mutex needed.
9 | type ServerPool struct {
10 | template dns.Template // checks
11 |
12 | queue []string // IPs still to load
13 | dequeueIdx int // next server idx to dequeue
14 |
15 | pool map[int]*dns.ServerContext // srvID ➜ *ServerContext
16 | nextSlot int // srvID generator
17 | maxPoolSz int // maximum pool size
18 |
19 | maxAttempts int // needed to build ServerContext
20 | }
21 |
22 | /* construction ----------------------------------------------------------- */
23 |
24 | // NewServerPool
25 | func NewServerPool(
26 | maxPoolSz int,
27 | serverIPs []string,
28 | template dns.Template,
29 | maxAttempts int,
30 | ) *ServerPool {
31 | sp := &ServerPool{
32 | template: template,
33 | queue: serverIPs,
34 | pool: make(map[int]*dns.ServerContext),
35 | maxPoolSz: maxPoolSz,
36 | maxAttempts: maxAttempts,
37 | }
38 | return sp
39 | }
40 |
41 | /* public API ------------------------------------------------------------- */
42 |
43 | // LoadN loads up to n ServerContexts into the pool, expanding it as needed.
44 | // Returns the number of ServerContexts inserted.
45 | func (sp *ServerPool) LoadN(n int) int {
46 | inserted := 0
47 | for inserted < n && !sp.IsFull() && sp.NumPending() > 0 {
48 | ip := sp.queue[sp.dequeueIdx]
49 | sp.dequeueIdx++
50 | sp.pool[sp.nextSlot] = dns.NewServerContext(
51 | ip, sp.template, sp.maxAttempts,
52 | )
53 | sp.nextSlot++
54 | inserted++
55 | }
56 | return inserted
57 | }
58 |
59 | // Get returns the *ServerContext associated to the slot
60 | func (sp *ServerPool) Get(slot int) (*dns.ServerContext, bool) {
61 | srv, ok := sp.pool[slot]
62 | return srv, ok
63 | }
64 |
65 | // Unload removes a finished ServerContext; if not Disabled, records its IP.
66 | func (sp *ServerPool) Unload(slot int) {
67 | delete(sp.pool, slot)
68 | }
69 |
70 | // Len returns current servers loaded in pool
71 | func (sp *ServerPool) Len() int {
72 | return len(sp.pool)
73 | }
74 |
75 | // NumPending tells how many servers must still be sent to queue
76 | func (sp *ServerPool) NumPending() int {
77 | return len(sp.queue) - sp.dequeueIdx
78 | }
79 |
80 | // IsDrained is true when no server remains and queue is empty.
81 | func (sp *ServerPool) IsDrained() bool {
82 | return sp.Len() == 0 && sp.NumPending() == 0
83 | }
84 |
85 | func (sp *ServerPool) MaxSize() int {
86 | return sp.maxPoolSz
87 | }
88 |
89 | // IsFull is true when pool size == maxPoolSz
90 | func (sp *ServerPool) IsFull() bool {
91 | return sp.Len() == sp.maxPoolSz
92 | }
93 |
94 | func (sp *ServerPool) CanGrow() bool {
95 | return !sp.IsFull() && sp.NumPending() > 0
96 | }
97 |
--------------------------------------------------------------------------------
/internal/report/logging.go:
--------------------------------------------------------------------------------
1 | package report
2 |
3 | import (
4 | "os"
5 | "io"
6 | "time"
7 | )
8 |
9 |
10 | /* ------------------------------------------------------------------ */
11 | /* IOFiles ---------------------------------------------------------- */
12 | /* ------------------------------------------------------------------ */
13 |
14 | type IOFiles struct {
15 | TTYFile *os.File
16 | OutputFile io.Writer
17 | VerboseFile io.Writer
18 | DebugFile io.Writer
19 | }
20 |
21 |
22 | /* ------------------------------------------------------------------ */
23 | /* MetricGauge ------------------------------------------------------ */
24 | /* ------------------------------------------------------------------ */
25 |
26 | // MetricGauge tracks an integer metric (current, peak, average).
27 | // Not concurrency-safe.
28 | type MetricGauge struct {
29 | Max int // optional: theoratical max
30 | Current int // last recorded value
31 | Peak int // highest ever recorded value
32 | totalSum int64 // sum of all values logged
33 | nSamples int64 // number of calls to Log()
34 | }
35 |
36 | // Log records one sample and updates Peak / average.
37 | func (g *MetricGauge) Log(v int) {
38 | g.Current = v
39 | if v > g.Peak {
40 | g.Peak = v
41 | }
42 | g.totalSum += int64(v)
43 | g.nSamples++
44 | }
45 |
46 | // Avg returns the rounded arithmetic mean of all samples.
47 | func (g *MetricGauge) Avg() int {
48 | if g.nSamples == 0 {
49 | return 0
50 | }
51 | return int(float64(g.totalSum)/float64(g.nSamples) + 0.5)
52 | }
53 |
54 | /* ------------------------------------------------------------------ */
55 | /* RequestsLogger --------------------------------------------------- */
56 | /* ------------------------------------------------------------------ */
57 |
58 | // RequestsBatch represents the number of requests observed at one instant.
59 | type RequestsBatch struct {
60 | timestamp time.Time
61 | count int
62 | }
63 |
64 | // RequestsLogger tracks total idle / busy requests and
65 | // keeps a sliding-window log (1 s) to compute RPS.
66 | type RequestsLogger struct {
67 | StartTime time.Time // start time
68 | Idle int // cumulative idle requests
69 | Busy int // cumulative busy requests
70 | OneSecPeak int // highest observers 1s RPS
71 | batches []RequestsBatch // sliding window of ≤ 1 s
72 | }
73 |
74 | // Log records a new batch of requests and prunes outdated entries.
75 | //
76 | // idleDelta / busyDelta: number of idle / busy requests since the last call.
77 | // ts: timestamp of the observation (usually time.Now()).
78 | func (r *RequestsLogger) Log(ts time.Time, idleDelta, busyDelta int) {
79 | // Update cumulative counters.
80 | r.Idle += idleDelta
81 | r.Busy += busyDelta
82 | // Store the batch for 1-second sliding window (RPS).
83 | total := idleDelta + busyDelta
84 | if total > 0 {
85 | r.batches = append(
86 | r.batches, RequestsBatch{timestamp: ts, count: total})
87 | }
88 | // Prune batches older than 1 s to avoid unbounded growth even
89 | // when LastSecCount() is never called.
90 | cutoff := ts.Add(-time.Second)
91 | keep := 0
92 | for _, b := range r.batches {
93 | if b.timestamp.After(cutoff) {
94 | r.batches[keep] = b
95 | keep++
96 | }
97 | }
98 | r.batches = r.batches[:keep]
99 | }
100 |
101 | // Total returns the overall number of requests logged since program start.
102 | func (r *RequestsLogger) Total() int {
103 | return r.Idle + r.Busy
104 | }
105 |
106 | // LastSecCount returns the number of requests in the last second and
107 | // purges everything older than that window.
108 | //
109 | // This is O(k) where k = len(batches) ≤ number of events within 1 s.
110 | func (r *RequestsLogger) LastSecCount() int {
111 | now := time.Now()
112 | cutoff := now.Add(-time.Second)
113 |
114 | sum := 0
115 | keep := 0
116 | for _, b := range r.batches {
117 | if b.timestamp.After(cutoff) {
118 | r.batches[keep] = b
119 | keep++
120 | sum += b.count
121 | }
122 | }
123 | r.batches = r.batches[:keep]
124 | if sum > r.OneSecPeak {
125 | r.OneSecPeak = sum
126 | }
127 | return sum
128 | }
129 |
130 | // OneSecAvg returns the average requests-per-second (RPS) since StartTime.
131 | func (r *RequestsLogger) OneSecAvg() int {
132 | elapsedUs := time.Since(r.StartTime).Microseconds() + 500_000 // +500ms
133 | if elapsedUs <= 0 {
134 | return 0
135 | }
136 | return int(0.5 + (float64(r.Total() * 1_000_000) / float64(elapsedUs)))
137 | }
138 |
--------------------------------------------------------------------------------
/internal/report/status.go:
--------------------------------------------------------------------------------
1 | package report
2 |
3 | import (
4 | "sync"
5 | "fmt"
6 | "strings"
7 | "math"
8 | "time"
9 | "io"
10 | "bytes"
11 |
12 | "github.com/nil0x42/dnsanity/internal/tty"
13 | "github.com/nil0x42/dnsanity/internal/dns"
14 | "github.com/nil0x42/dnsanity/internal/config"
15 | )
16 |
17 | var SPINNER = [][]rune{
18 | {'█', '▏', '█', '▏', '▋'},
19 | {'▎', '▌', '█', '▊', '▉'},
20 | {'█', '▋', '▌', '█', '▋'},
21 | {'█', '▍', '▍', '▋', '▊'},
22 | {'█', '▊', '▎', '▋', '▊'},
23 | {'▊', '▍', '▍', '▉', '▎'},
24 | {'▏', '▎', '▊', '▎', '▍'},
25 | {'▊', '█', '▋', '▋', '█'},
26 | {'█', '▉', '▌', '▍', '▍'},
27 | {'█', '▌', '▍', '▌', '▌'},
28 | {'▊', '▌', '▍', '▊', '▋'},
29 | {'▍', '▊', '█', '▉', '▌'},
30 | {'▉', '▌', '▊', '▉', '▉'},
31 | {'▍', '▍', '▏', '▊', '▎'},
32 | {'▎', '█', '█', '▌', '▏'},
33 | {'▌', '▎', '▉', '▎', '▊'},
34 | {'▌', '▋', '▌', '▍', '▏'},
35 | {'▎', '▋', '▋', '▎', '▊'},
36 | {'█', '▏', '▉', '▌', '▎'},
37 | {'▋', '▋', '▏', '▋', '▏'},
38 | {'█', '▌', '▋', '▍', '▏'},
39 | {'▊', '▏', '▍', '▊', '▊'},
40 | {'▎', '▏', '▋', '▏', '▎'},
41 | {'▌', '▊', '▉', '▏', '▊'},
42 | }
43 |
44 | type StatusReporter struct {
45 | // Plumbing:
46 | mu sync.Mutex
47 | io *IOFiles
48 | quit chan struct{}
49 | redrawTicker *time.Ticker
50 | // Display:
51 | pBarTemplate string // progress bar fmt string template
52 | pBarEraser string // ANSI sequence to 'erase' current pbar
53 | cacheStr string // cached data to display @ next redraw
54 | spinnerFrame int // current spinner frame
55 | verboseFileHdr string // printed once before 1st debugFile write
56 | // Servers Status:
57 | TotalServers int
58 | ValidServers int
59 | InvalidServers int
60 | ServersWithFailures int
61 | // Checks Status:
62 | TotalChecks int
63 | DoneChecks int
64 | // MISC:
65 | StartTime time.Time
66 | Requests RequestsLogger // requests tracking
67 | PoolSize MetricGauge // pool tracking
68 | BusyJobs MetricGauge // jobs tracking
69 | }
70 |
71 |
72 | /* ------------------------------------------------------------------ */
73 | /* CONSTRUCTOR ------------------------------------------------------ */
74 | /* ------------------------------------------------------------------ */
75 |
76 | // NewStatusReporter sets up StatusReporter, spinner and initial progress bar.
77 | func NewStatusReporter(
78 | title string, ioFiles *IOFiles, set *config.Settings,
79 | ) *StatusReporter {
80 | dropMsg := func(srvMaxFail int) string {
81 | if srvMaxFail == 0 {
82 | return "dropped if any test fails"
83 | } else if srvMaxFail >= len(set.Template) {
84 | return "never dropped"
85 | }
86 | return fmt.Sprintf("dropped if >%d tests fail", srvMaxFail)
87 | }
88 | srvRatelimitStr := func() string {
89 | s := fmt.Sprintf("max %.10f", set.PerSrvRateLimit)
90 | return strings.TrimRight(strings.TrimRight(s, "0"), ".")
91 | }
92 | pBarTemplate := fmt.Sprintf(
93 | "\n" +
94 | "\033[1;97m* %-30s\033[2;37m%%10s - %%s\n" +
95 | "%%c Run: %d servers * %d tests, max %d req/s, %d jobs (%%d busy)\n" +
96 | "%%c Per server: %s req/s, %s (%%d in pool)\n" +
97 | "%%c Per test: %ds timeout, up to %d attempts -> %%d%%%% done (%%d/%%d)\n" +
98 | "%%c │\033[32m%%-22s\033[2;37m%%6d req/s\033[31m%%26s\033[2;37m│\n" +
99 | "%%c │%%s\033[2;37m│\033[0m",
100 | // line 0: title
101 | title,
102 | // line 1: Run: ? servers * ? tests ...
103 | len(set.ServerIPs), len(set.Template),
104 | set.GlobRateLimit, set.MaxThreads,
105 | // line 2: Per server: ...
106 | srvRatelimitStr(), dropMsg(set.PerSrvMaxFailures),
107 | // line 3: Per test: ...
108 | set.PerQueryTimeout, set.PerCheckMaxAttempts,
109 | )
110 | s := &StatusReporter{
111 | io: ioFiles,
112 | quit: make(chan struct{}),
113 | redrawTicker: time.NewTicker(time.Millisecond * 250),
114 |
115 | pBarTemplate: pBarTemplate,
116 | verboseFileHdr: set.Template.PrettyDump(),
117 |
118 | TotalServers: len(set.ServerIPs),
119 | TotalChecks: len(set.ServerIPs) * len(set.Template),
120 | StartTime: time.Now(),
121 | Requests: RequestsLogger{StartTime: time.Now()},
122 | PoolSize: MetricGauge{Max: set.MaxPoolSize},
123 | BusyJobs: MetricGauge{Max: set.MaxThreads},
124 | }
125 | pBarNLines := strings.Count(s.renderDebugBar() + pBarTemplate, "\n")
126 | s.pBarEraser = "\r\033[2K" + strings.Repeat("\033[1A\033[2K", pBarNLines)
127 | if (s.hasPBar()) {
128 | s.io.TTYFile.WriteString(s.renderPBar())
129 | go s.loop()
130 | }
131 | return s
132 | }
133 |
134 |
135 | /* ------------------------------------------------------------------ */
136 | /* PUBLIC API ------------------------------------------------------- */
137 | /* ------------------------------------------------------------------ */
138 |
139 | // UpdatePoolSize logs the current worker-pool size.
140 | func (s *StatusReporter) UpdatePoolSize(n int) {
141 | s.mu.Lock()
142 | defer s.mu.Unlock()
143 | s.PoolSize.Log(n)
144 | }
145 |
146 | // UpdateBusyJobs logs the number of goroutines currently busy.
147 | func (s *StatusReporter) UpdateBusyJobs(n int) {
148 | s.mu.Lock()
149 | defer s.mu.Unlock()
150 | s.BusyJobs.Log(n)
151 | }
152 |
153 | // AddDoneChecks adds to done/total check counters.
154 | func (s *StatusReporter) AddDoneChecks(addDoneChecks, addTotalChecks int) {
155 | s.mu.Lock()
156 | defer s.mu.Unlock()
157 |
158 | s.DoneChecks += addDoneChecks
159 | if addTotalChecks != 0 {
160 | s.TotalChecks += addTotalChecks
161 | }
162 | }
163 |
164 | // LogRequests records one idle/busy requests batch.
165 | func (s *StatusReporter) LogRequests(t time.Time, nIdle, nBusy int) {
166 | s.mu.Lock()
167 | defer s.mu.Unlock()
168 | s.Requests.Log(t, nIdle, nBusy)
169 | }
170 |
171 | // ReportFinishedServer updates stats and writes results for one server.
172 | func (s *StatusReporter) ReportFinishedServer(srv *dns.ServerContext) {
173 | s.mu.Lock()
174 | defer s.mu.Unlock()
175 |
176 | if srv.FailedCount > 0 {
177 | s.ServersWithFailures++
178 | }
179 | if srv.Disabled {
180 | s.InvalidServers++
181 | } else {
182 | s.ValidServers++
183 | s.fWrite(s.io.OutputFile, srv.IPAddress)
184 | }
185 | if s.io.VerboseFile != nil {
186 | if s.verboseFileHdr == "" {
187 | s.fWrite(s.io.VerboseFile, srv.PrettyDump())
188 | } else { // hdr is only printed once, before 1st write to verboseFile
189 | s.fWrite(s.io.VerboseFile, s.verboseFileHdr + srv.PrettyDump())
190 | s.verboseFileHdr = ""
191 | }
192 | }
193 | }
194 |
195 | // Debug prints a formatted debug line when -debug is active.
196 | func (s *StatusReporter) Debug(format string, args ...interface{}) {
197 | if s.hasDebug() {
198 | s.mu.Lock()
199 | defer s.mu.Unlock()
200 |
201 | str := fmt.Sprintf(
202 | "\x1b[1;33m[DEBUG]\x1b[0;33m %s\x1b[0m\n",
203 | fmt.Sprintf(format, args...),
204 | )
205 | s.fWrite(s.io.DebugFile, str)
206 | }
207 | }
208 |
209 | // Stop stops ticker, renders final bar and cleans up.
210 | func (s *StatusReporter) Stop() {
211 | close(s.quit)
212 | s.redrawTicker.Stop()
213 | if (s.hasPBar()) {
214 | s.io.TTYFile.WriteString(
215 | s.pBarEraser + s.cacheStr + s.renderPBar() + "\n\n")
216 | s.cacheStr = ""
217 | }
218 | }
219 |
220 |
221 | /* ------------------------------------------------------------------ */
222 | /* INTERNAL UTILS --------------------------------------------------- */
223 | /* ------------------------------------------------------------------ */
224 |
225 | // loop drives spinner redraws until quit.
226 | func (s *StatusReporter) loop() {
227 | for {
228 | select {
229 | case <-s.redrawTicker.C:
230 | s.mu.Lock()
231 | s.spinnerFrame = (s.spinnerFrame + 1) % len(SPINNER)
232 | s.io.TTYFile.WriteString(
233 | s.pBarEraser + s.cacheStr + s.renderPBar())
234 | s.cacheStr = ""
235 | s.mu.Unlock()
236 | case <-s.quit:
237 | return
238 | }
239 | }
240 | }
241 |
242 | // hasPBar returns true when a TTY progress-bar is active.
243 | func (s *StatusReporter) hasPBar() bool {
244 | return s.io.TTYFile != nil
245 | }
246 |
247 | // hasDebug returns true when debug logging is enabled.
248 | func (s *StatusReporter) hasDebug() bool {
249 | return s.io.DebugFile != nil
250 | }
251 |
252 | // isFinished reports whether all servers have been processed.
253 | func (s *StatusReporter) isFinished() bool {
254 | return s.doneServers() == s.TotalServers
255 | }
256 |
257 | // doneServers returns the number of servers already processed.
258 | func (s *StatusReporter) doneServers() int {
259 | return s.ValidServers + s.InvalidServers
260 | }
261 |
262 | // fWrite outputs str to file (or caches) while handling TTY/ANSI.
263 | func (s *StatusReporter) fWrite(file io.Writer, str string){
264 | if file == nil {
265 | return
266 | }
267 | if !strings.HasSuffix(str, "\n") {
268 | str += "\n"
269 | }
270 | if s.hasPBar() && tty.IsTTY(file) {
271 | s.cacheStr += str
272 | } else {
273 | // Only strip ANSI if file is NOT a bytes.Buffer:
274 | if _, ok := file.(*bytes.Buffer); !ok {
275 | str = tty.StripAnsi(str)
276 | }
277 | io.WriteString(file, str)
278 | }
279 | }
280 |
281 | // scaleValue returns value/total scaled to `scale`.
282 | func scaleValue(value, total, scale int) float64 {
283 | if total == 0 {
284 | return 0
285 | }
286 | return float64(scale) * (float64(value) / float64(total))
287 | }
288 |
289 |
290 | /* ------------------------------------------------------------------ */
291 | /* INTERNAL RENDERING ----------------------------------------------- */
292 | /* ------------------------------------------------------------------ */
293 |
294 | // renderElapsedTime returns elapsed-time string in d h / h m / m s / s.
295 | func (s *StatusReporter) renderElapsedTime() string {
296 | sec := int(time.Since(s.StartTime).Seconds())
297 | const D, H, M = 86400, 3600, 60
298 | switch {
299 | case sec >= D:
300 | return fmt.Sprintf("⏳%dd %dh", sec/D, (sec%D)/H)
301 | case sec >= H:
302 | return fmt.Sprintf("⏳%dh %dm", sec/H, (sec%H)/M)
303 | case sec >= M:
304 | return fmt.Sprintf("⏳%dm %ds", sec/M, sec%M)
305 | default:
306 | return fmt.Sprintf("⏳%ds", sec)
307 | }
308 | }
309 |
310 | // renderRemainingTime returns "DONE" or a brief human-readable ETA.
311 | func (s *StatusReporter) renderRemainingTime() string {
312 | if s.isFinished() {
313 | return "DONE"
314 | }
315 | // Weighted progress : 80 % servers, 20 % checks.
316 | progress := scaleValue(s.DoneChecks, s.TotalChecks, 1)
317 | if srvPct := scaleValue(s.doneServers(), s.TotalServers, 1); srvPct > 0 {
318 | progress = (srvPct*4 + progress) / 5
319 | }
320 | if progress < 0.001 {
321 | return "ETA: --"
322 | }
323 | const D, H, M = 86400, 3600, 60
324 | elapsed := time.Since(s.StartTime)
325 | remain := time.Duration(float64(elapsed)*(1/progress - 1))
326 | switch sec := int(remain.Seconds()); {
327 | case sec < M:
328 | return "ETA: <1m"
329 | case sec < H:
330 | return fmt.Sprintf("ETA: %dm", sec/M)
331 | case sec < D:
332 | return fmt.Sprintf("ETA: %dh %dm", sec/H, (sec%H)/M)
333 | default:
334 | return fmt.Sprintf("ETA: %dd %dh", sec/D, (sec%D)/H)
335 | }
336 | }
337 |
338 | // renderBrailleBar outputs a 60-rune Braille bar: green valid blocks left,
339 | // red invalid blocks right, rune-aligned and auto-trimmed to fit.
340 | func (s *StatusReporter) renderBrailleBar() string {
341 | const (
342 | ptsPerChr = 8 // full Braille block (⣿) = 8 pts
343 | totalChrs = 60 // fixed bar width in runes
344 | )
345 | totalPts := totalChrs * ptsPerChr
346 | validPts := int(math.Round(scaleValue(
347 | s.ValidServers, s.TotalServers, totalPts)))
348 | invalidPts := int(math.Round(scaleValue(
349 | s.InvalidServers, s.TotalServers, totalPts)))
350 |
351 | // --- helpers to build runes for each side -----------------------------
352 | buildLeft := func(pts int) []rune {
353 | full, extra := pts / ptsPerChr, pts % ptsPerChr
354 | bar := make([]rune, 0, full+1)
355 | for i := 0; i < full; i++ {
356 | bar = append(bar, '⣿')
357 | }
358 | if extra > 0 {
359 | bar = append(bar, []rune("⡀⡄⡆⡇⣇⣧⣷")[extra-1])
360 | }
361 | return bar
362 | }
363 | buildRight := func(pts int) []rune {
364 | full, extra := pts / ptsPerChr, pts % ptsPerChr
365 | bar := make([]rune, 0, full+1)
366 | if extra > 0 {
367 | bar = append(bar, []rune("⠈⠘⠸⢸⢹⢻⢿")[extra-1])
368 | }
369 | for i := 0; i < full; i++ {
370 | bar = append(bar, '⣿')
371 | }
372 | return bar
373 | }
374 | validRunes := buildLeft(validPts)
375 | invalidRunes := buildRight(invalidPts)
376 | extraValid := validPts % ptsPerChr
377 | extraInvalid := invalidPts % ptsPerChr
378 |
379 | // --- overflow correction (rune‑level) ---------------------------------
380 | if len(validRunes) + len(invalidRunes) > totalChrs {
381 | switch {
382 | case len(validRunes) == 1: // if valid is single rune, trim invalid
383 | invalidRunes = invalidRunes[1:]
384 | case len(invalidRunes) == 1: // if invalid is single rune, trim valid
385 | validRunes = validRunes[:len(validRunes)-1]
386 | case len(validRunes) >= 2 && len(invalidRunes) >= 2:
387 | if extraValid >= extraInvalid { // priorize valid, trim invalid
388 | invalidRunes = invalidRunes[1:]
389 | } else { // priorize invalid, trim valid
390 | validRunes = validRunes[:len(validRunes)-1]
391 | }
392 | }
393 | }
394 | if s.isFinished() {
395 | for i := range validRunes {
396 | validRunes[i] = '⣿'
397 | }
398 | for i := range invalidRunes {
399 | invalidRunes[i] = '⣿'
400 | }
401 | }
402 | return "\033[32m" + string(validRunes) +
403 | strings.Repeat(" ", totalChrs-(len(validRunes)+len(invalidRunes))) +
404 | "\033[31m" + string(invalidRunes)
405 | }
406 |
407 | // renderPBar builds the multi-line spinner/progress bar
408 | // (prepends debug bar if enabled).
409 | func (s *StatusReporter) renderPBar() string {
410 | renderSrvStr := func(r string, n int) string { // OK/KO server str
411 | percent := int(scaleValue(n, s.TotalServers, 100))
412 | return fmt.Sprintf("%s: %d (%d%%)", r, n, percent)
413 | }
414 | pBar := fmt.Sprintf(
415 | s.pBarTemplate,
416 | // line 0 (title):
417 | s.renderElapsedTime(),
418 | s.renderRemainingTime(),
419 | // line 1: Run: N servers ...
420 | SPINNER[s.spinnerFrame][0],
421 | s.BusyJobs.Current,
422 | // line 2: Each server: ...
423 | SPINNER[s.spinnerFrame][1],
424 | s.PoolSize.Current,
425 | // line 3: Each test: ...
426 | SPINNER[s.spinnerFrame][2],
427 | int(scaleValue(s.DoneChecks, s.TotalChecks, 100)),
428 | s.DoneChecks, s.TotalChecks,
429 | // line 4: |OK: N% KO: N%| ...
430 | SPINNER[s.spinnerFrame][3],
431 | renderSrvStr("OK", s.ValidServers),
432 | s.Requests.LastSecCount(),
433 | renderSrvStr("KO", s.InvalidServers),
434 | // line 5: |⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿| ...
435 | SPINNER[s.spinnerFrame][4],
436 | s.renderBrailleBar(),
437 | )
438 | if (s.hasDebug()) {
439 | return s.renderDebugBar() + pBar
440 | }
441 | return pBar
442 | }
443 |
444 | // renderDebugBar formats the yellow metrics block shown only in -debug mode.
445 | func (s *StatusReporter) renderDebugBar() string {
446 | if !s.hasDebug() {
447 | return ""
448 | }
449 | return fmt.Sprintf(
450 | "\n\033[33m" +
451 | "* [jobs] cur:%-7d peak:%-7d avg:%-7d max:%-7d\n" +
452 | "* [pool] cur:%-7d peak:%-7d avg:%-7d max:%-7d\n" +
453 | "* [reqs] cur:%-7d peak:%-7d avg:%-7d all:%-7d idle:%-7d busy:%-7d",
454 | // line 1: [jobs]
455 | s.BusyJobs.Current, s.BusyJobs.Peak,
456 | s.BusyJobs.Avg(), s.BusyJobs.Max,
457 | // line 2: [pool]
458 | s.PoolSize.Current, s.PoolSize.Peak,
459 | s.PoolSize.Avg(), s.PoolSize.Max,
460 | // line 2: [reqs]
461 | s.Requests.LastSecCount(), s.Requests.OneSecPeak,
462 | s.Requests.OneSecAvg(), s.Requests.Total(),
463 | s.Requests.Idle, s.Requests.Busy,
464 | )
465 | }
466 |
--------------------------------------------------------------------------------
/internal/report/status_test.go:
--------------------------------------------------------------------------------
1 | package report
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 | "strings"
8 | "sync"
9 | "testing"
10 | "time"
11 | "math"
12 | "unicode/utf8"
13 |
14 | "github.com/nil0x42/dnsanity/internal/config"
15 | "github.com/nil0x42/dnsanity/internal/dns"
16 | "github.com/nil0x42/dnsanity/internal/tty"
17 | )
18 |
19 | /* --------------------------------------------------------------------- */
20 | /* Helpers */
21 | /* --------------------------------------------------------------------- */
22 |
23 | // newReporterNoTTY builds a StatusReporter without the progress‑bar.
24 | // Every writer points to io.Discard so that tests stay silent.
25 | func newReporterNoTTY() *StatusReporter {
26 | tpl, _ := dns.NewTemplate("example.com A=1.1.1.1")
27 | set := &config.Settings{
28 | ServerIPs: []string{"8.8.8.8"},
29 | Template: tpl,
30 | MaxThreads: 1,
31 | GlobRateLimit: 10,
32 | PerSrvRateLimit: 1,
33 | PerSrvMaxFailures: 0,
34 | PerCheckMaxAttempts: 1,
35 | PerQueryTimeout: 1,
36 | MaxPoolSize: 10,
37 | }
38 | ioFiles := &IOFiles{OutputFile: io.Discard}
39 | return NewStatusReporter("unit‑test‑no‑tty", ioFiles, set)
40 | }
41 |
42 | // newReporterWithPipe enables the progress‑bar using a fake TTY.
43 | // The read side of the pipe lets us inspect what was printed.
44 | func newReporterWithPipe() (st *StatusReporter, r *os.File, w *os.File) {
45 | r, w, _ = os.Pipe()
46 | tpl, _ := dns.NewTemplate("example.com A=1.1.1.1\nexample.org NXDOMAIN")
47 | set := &config.Settings{
48 | ServerIPs: []string{"8.8.8.8", "9.9.9.9", "1.1.1.1"},
49 | Template: tpl,
50 | MaxThreads: 2,
51 | GlobRateLimit: 50,
52 | PerSrvRateLimit: 1,
53 | PerSrvMaxFailures: 0,
54 | PerCheckMaxAttempts: 1,
55 | PerQueryTimeout: 1,
56 | MaxPoolSize: 20,
57 | }
58 | ioFiles := &IOFiles{
59 | TTYFile: w, // fake TTY
60 | OutputFile: io.Discard,
61 | }
62 | st = NewStatusReporter("unit‑test‑tty", ioFiles, set)
63 | return
64 | }
65 |
66 | /* --------------------------------------------------------------------- */
67 | /* scaleValue */
68 | /* --------------------------------------------------------------------- */
69 |
70 | func TestScaleValue(t *testing.T) {
71 | t.Parallel()
72 | cases := []struct {
73 | name string
74 | value, tot int
75 | want float64
76 | }{{"total‑zero", 42, 0, 0}, {"fifty‑pct", 50, 100, 50}, {"ratio", 3, 4, 75}}
77 | for _, c := range cases {
78 | if got := scaleValue(c.value, c.tot, 100); got != c.want {
79 | t.Fatalf("%s: scaleValue=%v, want %v", c.name, got, c.want)
80 | }
81 | }
82 | }
83 |
84 | /* --------------------------------------------------------------------- */
85 | /* MetricGauge */
86 | /* --------------------------------------------------------------------- */
87 |
88 | func TestMetricGaugeLogAndAvg(t *testing.T) {
89 | t.Parallel()
90 | var g MetricGauge
91 | // No sample yet → Avg() must return 0.
92 | if g.Avg() != 0 {
93 | t.Fatalf("empty MetricGauge Avg() != 0")
94 | }
95 | // First sample.
96 | g.Log(3)
97 | if g.Current != 3 || g.Peak != 3 || g.Avg() != 3 {
98 | t.Fatalf("after first Log, got cur=%d peak=%d avg=%d", g.Current, g.Peak, g.Avg())
99 | }
100 | // Higher value raises Peak and shifts average.
101 | g.Log(7)
102 | if g.Current != 7 || g.Peak != 7 || g.Avg() != 5 { // (3+7)/2 == 5
103 | t.Fatalf("after second Log unexpected values cur=%d peak=%d avg=%d", g.Current, g.Peak, g.Avg())
104 | }
105 | // Lower value updates Current but not Peak.
106 | g.Log(1)
107 | if g.Current != 1 || g.Peak != 7 || g.Avg() != int(math.Round(11.0/3.0)) {
108 | t.Fatalf("after third Log incorrect state cur=%d peak=%d avg=%d", g.Current, g.Peak, g.Avg())
109 | }
110 | }
111 |
112 | /* --------------------------------------------------------------------- */
113 | /* RequestsLogger */
114 | /* --------------------------------------------------------------------- */
115 |
116 | func TestRequestsLoggerWindowAndStats(t *testing.T) {
117 | t.Parallel()
118 | base := time.Now()
119 | r := RequestsLogger{StartTime: base.Add(-2 * time.Second)}
120 |
121 | // Old batch (>1 s)
122 | r.Log(base.Add(-1500*time.Millisecond), 3, 2) // total 5
123 | // Recent batch (<1 s)
124 | r.Log(base.Add(-300*time.Millisecond), 2, 1) // total 3
125 | // Empty batch should not be recorded.
126 | r.Log(base, 0, 0)
127 |
128 | if tot := r.Total(); tot != 8 {
129 | t.Fatalf("Total()=%d, want 8", tot)
130 | }
131 |
132 | if cnt := r.LastSecCount(); cnt != 3 {
133 | t.Fatalf("LastSecCount()=%d, want 3 (only recent batch)", cnt)
134 | }
135 | if r.OneSecPeak != 3 {
136 | t.Fatalf("OneSecPeak=%d, want 3", r.OneSecPeak)
137 | }
138 | if r.OneSecAvg() <= 0 {
139 | t.Fatalf("OneSecAvg()=%d, must be >0", r.OneSecAvg())
140 | }
141 | }
142 |
143 | /* --------------------------------------------------------------------- */
144 | /* renderBrailleBar */
145 | /* --------------------------------------------------------------------- */
146 |
147 | func TestRenderBrailleBarWidthAndCompletion(t *testing.T) {
148 | t.Parallel()
149 | s := &StatusReporter{TotalServers: 200, ValidServers: 60, InvalidServers: 40}
150 | // 1) Width must remain exactly 60 runes.
151 | if runes := utf8.RuneCountInString(tty.StripAnsi(s.renderBrailleBar())); runes != 60 {
152 | t.Fatalf("unexpected bar width %d runes (want 60)", runes)
153 | }
154 | // 2) Completed run uses only full blocks.
155 | s.ValidServers = 160
156 | full := tty.StripAnsi(s.renderBrailleBar())
157 | if !strings.ContainsRune(full, '⣿') {
158 | t.Fatalf("completed bar should contain full blocks (⣿)")
159 | }
160 | if strings.ContainsRune(full, '⡀') || strings.ContainsRune(full, '⢹') {
161 | t.Fatalf("completed bar must not contain partial blocks")
162 | }
163 | // 3) Impossible scaling must panic (overflow correction safeguard).
164 | func() {
165 | defer func() { if r := recover(); r == nil { t.Fatalf("renderBrailleBar should panic on impossible scaling but did not") } }()
166 | s.TotalServers, s.ValidServers, s.InvalidServers = 10, 9, 9 // 18/10 impossible
167 | _ = s.renderBrailleBar()
168 | }()
169 | }
170 |
171 | /* --------------------------------------------------------------------- */
172 | /* renderRemainingTime */
173 | /* --------------------------------------------------------------------- */
174 |
175 | func TestRenderRemainingTimeFormats(t *testing.T) {
176 | t.Parallel()
177 | // 1) Unknown progress (<0.1 %).
178 | s := &StatusReporter{TotalChecks: 1000, TotalServers: 100, StartTime: time.Now()}
179 | if got := s.renderRemainingTime(); got != "ETA: --" {
180 | t.Fatalf("early remainingTime=%q, want \"ETA: --\"", got)
181 | }
182 | // 2) DONE (<1 m remaining).
183 | s.DoneChecks, s.TotalChecks = 1, 1
184 | s.ValidServers, s.InvalidServers, s.TotalServers = 100, 0, 100
185 | s.StartTime = time.Now().Add(-30 * time.Second)
186 | if got := s.renderRemainingTime(); got != "DONE" {
187 | t.Fatalf("remainingTime 'DONE' unexpected: %q", got)
188 | }
189 | // 3) Hours format.
190 | s = &StatusReporter{TotalChecks: 100, TotalServers: 10, StartTime: time.Now().Add(-time.Hour)}
191 | s.DoneChecks = 10
192 | s.ValidServers = 1
193 | if !strings.Contains(s.renderRemainingTime(), "h") {
194 | t.Fatalf("remainingTime should include hours: %q", s.renderRemainingTime())
195 | }
196 | // 4) Days format.
197 | s = &StatusReporter{TotalChecks: 100, TotalServers: 10, StartTime: time.Now().Add(-24 * time.Hour)}
198 | s.DoneChecks = 1
199 | if !strings.Contains(s.renderRemainingTime(), "d") {
200 | t.Fatalf("remainingTime should include days: %q", s.renderRemainingTime())
201 | }
202 | }
203 |
204 | /* --------------------------------------------------------------------- */
205 | /* fWrite behaviour */
206 | /* --------------------------------------------------------------------- */
207 |
208 | func TestFWriteBehaviour(t *testing.T) {
209 | t.Parallel()
210 | const ansiMsg = "\x1b[31mred\x1b[0m"
211 | buf := &bytes.Buffer{}
212 | st := &StatusReporter{io: &IOFiles{}} // io non‑nil, no TTY.
213 | st.fWrite(buf, ansiMsg)
214 | if got := buf.String(); got != ansiMsg+"\n" {
215 | t.Fatalf("bytes.Buffer: unexpected output %q", got)
216 | }
217 | r, w, _ := os.Pipe()
218 | defer func() { r.Close(); w.Close() }()
219 | st.fWrite(w, ansiMsg)
220 | w.Close()
221 | out, _ := io.ReadAll(r)
222 | if string(out) != "red\n" {
223 | t.Fatalf("os.File: ANSI not stripped: %q", string(out))
224 | }
225 | }
226 |
227 | /* --------------------------------------------------------------------- */
228 | /* Debug */
229 | /* --------------------------------------------------------------------- */
230 |
231 | func TestDebugWritesToFile(t *testing.T) {
232 | t.Parallel()
233 | dbg := &bytes.Buffer{}
234 | tpl, _ := dns.NewTemplate("example.com A=1.1.1.1")
235 | set := &config.Settings{
236 | ServerIPs: []string{"8.8.8.8"},
237 | Template: tpl,
238 | MaxThreads: 1,
239 | GlobRateLimit: 10,
240 | PerSrvRateLimit: 1,
241 | PerSrvMaxFailures: 0,
242 | PerCheckMaxAttempts: 1,
243 | PerQueryTimeout: 1,
244 | MaxPoolSize: 5,
245 | }
246 | ioFiles := &IOFiles{DebugFile: dbg}
247 | st := NewStatusReporter("debug‑test", ioFiles, set)
248 | st.Debug("hello %s", "world")
249 | if !strings.Contains(dbg.String(), "hello world") {
250 | t.Fatalf("Debug did not write expected content, got %q", dbg.String())
251 | }
252 | st.Stop()
253 | }
254 |
255 | /* --------------------------------------------------------------------- */
256 | /* UpdatePoolSize & UpdateBusyJobs */
257 | /* --------------------------------------------------------------------- */
258 |
259 | func TestUpdatePoolAndBusyJobs(t *testing.T) {
260 | t.Parallel()
261 | rep := newReporterNoTTY()
262 | rep.UpdatePoolSize(3)
263 | rep.UpdateBusyJobs(2)
264 | if rep.PoolSize.Current != 3 || rep.PoolSize.Peak != 3 {
265 | t.Fatalf("PoolSize gauge incorrect cur=%d peak=%d", rep.PoolSize.Current, rep.PoolSize.Peak)
266 | }
267 | if rep.BusyJobs.Current != 2 || rep.BusyJobs.Peak != 2 {
268 | t.Fatalf("BusyJobs gauge incorrect cur=%d peak=%d", rep.BusyJobs.Current, rep.BusyJobs.Peak)
269 | }
270 | // Lower values update Current but not Peak.
271 | rep.UpdatePoolSize(1)
272 | rep.UpdateBusyJobs(1)
273 | if rep.PoolSize.Peak != 3 || rep.BusyJobs.Peak != 2 {
274 | t.Fatalf("Peak values should stay unchanged, got poolPeak=%d jobsPeak=%d", rep.PoolSize.Peak, rep.BusyJobs.Peak)
275 | }
276 | rep.Stop()
277 | }
278 |
279 | /* --------------------------------------------------------------------- */
280 | /* LogRequests integration (StatusReporter → RequestsLogger) */
281 | /* --------------------------------------------------------------------- */
282 |
283 | func TestStatusReporterLogRequests(t *testing.T) {
284 | t.Parallel()
285 | st := newReporterNoTTY() // progress‑bar disabled but RequestsLogger still active.
286 | now := time.Now()
287 | st.LogRequests(now, 4, 3) // 7 total
288 | if st.Requests.Idle != 4 || st.Requests.Busy != 3 {
289 | t.Fatalf("Idle/Busy counters not updated: idle=%d busy=%d", st.Requests.Idle, st.Requests.Busy)
290 | }
291 | if st.Requests.Total() != 7 {
292 | t.Fatalf("Total()=%d, want 7", st.Requests.Total())
293 | }
294 | if st.Requests.LastSecCount() != 7 {
295 | t.Fatalf("LastSecCount() should see 7 recent reqs")
296 | }
297 | st.Stop()
298 | }
299 |
300 | /* --------------------------------------------------------------------- */
301 | /* AddDoneChecks concurrency */
302 | /* --------------------------------------------------------------------- */
303 |
304 | func TestConcurrentAddDoneChecks(t *testing.T) {
305 | t.Parallel()
306 | st := newReporterNoTTY()
307 | const n = 128
308 | wg := sync.WaitGroup{}
309 | wg.Add(n)
310 | for i := 0; i < n; i++ {
311 | go func() { st.AddDoneChecks(1, 0); wg.Done() }()
312 | }
313 | wg.Wait()
314 | if st.DoneChecks != n {
315 | t.Fatalf("lost updates: have %d, want %d", st.DoneChecks, n)
316 | }
317 | st.Stop()
318 | }
319 |
320 | /* --------------------------------------------------------------------- */
321 | /* ReportFinishedServer variants */
322 | /* --------------------------------------------------------------------- */
323 |
324 | func TestReportFinishedServerVariants(t *testing.T) {
325 | t.Parallel()
326 | outBuf, verboseBuf := &bytes.Buffer{}, &bytes.Buffer{}
327 | rep := newReporterNoTTY()
328 | rep.io.OutputFile = outBuf
329 | rep.io.VerboseFile = verboseBuf // KO servers will be dumped here.
330 |
331 | // Valid server.
332 | ok := &dns.ServerContext{IPAddress: "10.0.0.1"}
333 | rep.ReportFinishedServer(ok)
334 | if rep.ValidServers != 1 || !strings.Contains(outBuf.String(), "10.0.0.1") {
335 | t.Fatalf("valid server path incorrect")
336 | }
337 |
338 | // Invalid server.
339 | ko := &dns.ServerContext{IPAddress: "10.0.0.2", Disabled: true, FailedCount: 3}
340 | rep.ReportFinishedServer(ko)
341 | if rep.InvalidServers != 1 || rep.ServersWithFailures != 1 {
342 | t.Fatalf("invalid server counters incorrect")
343 | }
344 | if !strings.Contains(verboseBuf.String(), "10.0.0.2") {
345 | t.Fatalf("VerboseFile missing KO server dump")
346 | }
347 | }
348 |
349 | /* --------------------------------------------------------------------- */
350 | /* Stop final render & Eraser */
351 | /* --------------------------------------------------------------------- */
352 |
353 | func TestStopWritesFinalRender(t *testing.T) {
354 | t.Parallel()
355 | st, r, w := newReporterWithPipe()
356 | defer r.Close()
357 | defer w.Close()
358 |
359 | st.AddDoneChecks(2, 0) // Simulate progress.
360 | st.UpdatePoolSize(0)
361 |
362 | st.Stop()
363 | buf := make([]byte, 4096)
364 | n, _ := r.Read(buf)
365 | if n == 0 {
366 | t.Fatal("Stop did not write anything to fake TTY")
367 | }
368 | if !bytes.Contains(buf[:n], []byte("\n\n")) {
369 | t.Fatalf("final output missing expected newline padding: %q", string(buf[:n]))
370 | }
371 | }
372 |
--------------------------------------------------------------------------------
/internal/tty/tty.go:
--------------------------------------------------------------------------------
1 | package tty
2 |
3 | import (
4 | // standard
5 | "fmt"
6 | "os"
7 | "regexp"
8 | "sync"
9 | "io"
10 |
11 | "golang.org/x/sys/unix"
12 | "golang.org/x/term"
13 | )
14 |
15 | // stripAnsiRegex removes ANSI escape sequences (colors, cursor movements, etc.).
16 | var stripAnsiRegex = regexp.MustCompile(
17 | "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))",
18 | )
19 |
20 | // cacheIsTTY stores the result of IsTTY for each file descriptor,
21 | // avoiding repeated system calls.
22 | var cacheIsTTY sync.Map // Key = file descriptor (uintptr), Value = bool
23 |
24 |
25 | // IsTTY returns whether the given writer is a terminal.
26 | // The result is cached to prevent repeated checks.
27 | func IsTTY(w io.Writer) bool {
28 | f, ok := w.(*os.File)
29 | if !ok {
30 | return false
31 | }
32 | fd := f.Fd()
33 | if v, loaded := cacheIsTTY.Load(fd); loaded {
34 | return v.(bool)
35 | }
36 | isTerm := term.IsTerminal(int(fd))
37 | cacheIsTTY.Store(fd, isTerm)
38 | return isTerm
39 | }
40 |
41 | func OpenTTY() *os.File {
42 | fd, err := unix.Open("/dev/tty", unix.O_WRONLY|unix.O_NONBLOCK, 0)
43 | if err != nil {
44 | return nil
45 | }
46 | tty := os.NewFile(uintptr(fd), "/dev/tty")
47 | if !IsTTY(tty) {
48 | tty.Close()
49 | return nil
50 | }
51 | return tty
52 | }
53 |
54 | // StripAnsi removes all ANSI escape sequences from a string.
55 | func StripAnsi(str string) string {
56 | return stripAnsiRegex.ReplaceAllString(str, "")
57 | }
58 |
59 | // SmartFprintf behaves like fmt.Fprintf, but automatically strips
60 | // ANSI sequences if the output file is not a TTY.
61 | func SmartFprintf(f *os.File, format string, args ...interface{}) (int, error) {
62 | output := fmt.Sprintf(format, args...)
63 | if !IsTTY(f) {
64 | output = StripAnsi(output)
65 | }
66 | return f.WriteString(output)
67 | }
68 |
--------------------------------------------------------------------------------
/internal/tty/tty_test.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 | // +build !windows
3 |
4 | package tty
5 |
6 | /*
7 | Comprehensive white‑box tests for the tty package.
8 | All code paths – including ANSI stripping, TTY detection cache,
9 | OpenTTY fallback, and SmartFprintf stripping logic – are exercised.
10 | The auxiliary openPTY() helper relies on github.com/google/goterm/term
11 | so the tests run unchanged on Linux and macOS where /dev/ptmx is
12 | available. On CI systems lacking a PTY device the relevant tests are
13 | skipped gracefully.
14 | */
15 |
16 | import (
17 | "bytes"
18 | "io"
19 | "os"
20 | "strings"
21 | "sync"
22 | "testing"
23 | "time"
24 |
25 | "github.com/google/goterm/term"
26 | )
27 |
28 | // openPTY is a tiny wrapper around term.OpenPTY so we can re‑use the
29 | // function name already referenced in the existing test suite. It
30 | // returns the master and slave *os.File handles exactly like the
31 | // original helper did.
32 | func openPTY() (*os.File, *os.File, error) {
33 | p, err := term.OpenPTY()
34 | if err != nil {
35 | return nil, nil, err
36 | }
37 | return p.Master, p.Slave, nil
38 | }
39 |
40 | // TestStripAnsi verifies both removal of ANSI sequences and idempotency
41 | // when no sequences are present.
42 | func TestStripAnsi(t *testing.T) {
43 | colored := "\033[31mRED\033[0m and \033[32mGREEN\033[0m"
44 | want := "RED and GREEN"
45 | if got := StripAnsi(colored); got != want {
46 | t.Fatalf("StripAnsi failed: want %q, got %q", want, got)
47 | }
48 |
49 | plain := "no-color text"
50 | if got := StripAnsi(plain); got != plain {
51 | t.Fatalf("StripAnsi modified clean string: got %q", got)
52 | }
53 | }
54 |
55 | // TestIsTTYExhaustive exercises every branch of IsTTY, including the
56 | // caching fast‑path and concurrent access (both negative and positive
57 | // cases).
58 | func TestIsTTYExhaustive(t *testing.T) {
59 | // 1. Non-*os.File writer should never be a TTY.
60 | if IsTTY(&bytes.Buffer{}) {
61 | t.Fatal("IsTTY returned true for non-*os.File writer")
62 | }
63 |
64 | // 2. *os.File that is NOT a TTY: pipe writer.
65 | r, w, err := os.Pipe()
66 | if err != nil {
67 | t.Fatalf("os.Pipe: %v", err)
68 | }
69 | t.Cleanup(func() {
70 | r.Close(); w.Close()
71 | })
72 |
73 | if IsTTY(w) {
74 | t.Fatal("IsTTY returned true for pipe writer")
75 | }
76 | // Cached path should return the same value.
77 | if IsTTY(w) {
78 | t.Fatal("IsTTY cached value changed unexpectedly (pipe)")
79 | }
80 |
81 | // 3. Positive path using a pseudo‑terminal when available.
82 | ptmx, pts, err := openPTY()
83 | if err != nil {
84 | t.Skipf("no pseudo‑terminal available: %v", err)
85 | }
86 | t.Cleanup(func() {
87 | ptmx.Close(); pts.Close()
88 | })
89 |
90 | if !IsTTY(pts) {
91 | t.Fatal("IsTTY returned false for pty slave")
92 | }
93 | // Cached path (second call) – still true.
94 | if !IsTTY(pts) {
95 | t.Fatal("IsTTY cached value changed unexpectedly (pty)")
96 | }
97 |
98 | // 4. Concurrency check on both negative (pipe) and positive (pty) paths.
99 | var wg sync.WaitGroup
100 | for i := 0; i < 32; i++ {
101 | wg.Add(1)
102 | go func(idx int) {
103 | defer wg.Done()
104 | if idx%2 == 0 {
105 | if IsTTY(w) {
106 | t.Error("IsTTY concurrently returned true for pipe writer")
107 | }
108 | } else {
109 | if !IsTTY(pts) {
110 | t.Error("IsTTY concurrently returned false for pty slave")
111 | }
112 | }
113 | }(i)
114 | }
115 | wg.Wait()
116 | }
117 |
118 | // TestOpenTTY ensures OpenTTY behaves correctly. In CI environments
119 | // /dev/tty is usually absent; the test is skipped in that case.
120 | func TestOpenTTY(t *testing.T) {
121 | f := OpenTTY()
122 | if f == nil {
123 | t.Skip("/dev/tty not available on this runner")
124 | }
125 | t.Cleanup(func() { f.Close() })
126 |
127 | if !IsTTY(f) {
128 | t.Fatal("OpenTTY returned file considered non‑TTY by IsTTY")
129 | }
130 | }
131 |
132 | // TestSmartFprintf verifies ANSI stripping on non‑TTY and preservation on
133 | // TTY when a pseudo‑terminal is available.
134 | func TestSmartFprintf(t *testing.T) {
135 | const ansi = "\033[35mMAGENTA\033[0m"
136 |
137 | // ---------- Non‑TTY path ----------
138 | r, w, _ := os.Pipe()
139 | t.Cleanup(func() { r.Close(); w.Close() })
140 |
141 | n, err := SmartFprintf(w, "%s", ansi)
142 | if err != nil {
143 | t.Fatalf("SmartFprintf(pipe) error: %v", err)
144 | }
145 | w.Close() // EOF for reader
146 |
147 | data, _ := io.ReadAll(r)
148 | out := string(data)
149 | if strings.Contains(out, "\033[") {
150 | t.Fatalf("ANSI codes not stripped on non‑TTY: %q", out)
151 | }
152 | if out != "MAGENTA" || n != len(out) {
153 | t.Fatalf("Unexpected output/len mismatch: out=%q n=%d", out, n)
154 | }
155 |
156 | // ---------- TTY path ----------
157 | ptmx, pts, err := openPTY()
158 | if err != nil {
159 | t.Skipf("no pseudo‑terminal available: %v", err)
160 | }
161 | t.Cleanup(func() { ptmx.Close(); pts.Close() })
162 |
163 | if !IsTTY(pts) {
164 | t.Fatal("IsTTY returned false for pty slave")
165 | }
166 | if n, err := SmartFprintf(pts, "%s", ansi); err != nil || n != len(ansi) {
167 | t.Fatalf("SmartFprintf(pty) error=%v n=%d", err, n)
168 | }
169 |
170 | // Allow the kernel to flush the write.
171 | time.Sleep(10 * time.Millisecond)
172 |
173 | buf := make([]byte, 64)
174 | m, _ := ptmx.Read(buf)
175 | got := string(buf[:m])
176 | if !strings.Contains(got, ansi) {
177 | t.Fatalf("ANSI codes unexpectedly stripped on TTY: %q", got)
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | // standard
5 | "os"
6 | "strings"
7 | "bytes"
8 | "fmt"
9 | // external
10 | // local
11 | "github.com/nil0x42/dnsanity/internal/config"
12 | "github.com/nil0x42/dnsanity/internal/tty"
13 | "github.com/nil0x42/dnsanity/internal/report"
14 | "github.com/nil0x42/dnsanity/internal/dnsanitize"
15 | )
16 |
17 |
18 | func validateTemplate(
19 | conf *config.Config,
20 | ttyFile *os.File,
21 | ) bool {
22 | settings := &config.Settings{
23 | // global
24 | ServerIPs: conf.TrustedDNSList,
25 | Template: conf.Template,
26 | MaxThreads: conf.Opts.Threads,
27 | MaxPoolSize: conf.Opts.MaxPoolSize,
28 | GlobRateLimit: conf.Opts.GlobRateLimit,
29 | // per server
30 | PerSrvRateLimit: conf.Opts.TrustedRateLimit,
31 | PerSrvMaxFailures: len(conf.Template), // never drop Trusted Srvs
32 | // per check
33 | PerCheckMaxAttempts: conf.Opts.TrustedAttempts,
34 | // per dns query
35 | PerQueryTimeout: conf.Opts.TrustedTimeout,
36 | }
37 | buffer := &bytes.Buffer{}
38 | ioFiles := &report.IOFiles{
39 | TTYFile: ttyFile,
40 | VerboseFile: buffer, // write to buffer for later
41 | }
42 | status := report.NewStatusReporter(
43 | "[step 1/2] Template validation",
44 | ioFiles, settings,
45 | )
46 | dnsanitize.DNSanitize(settings, status)
47 | status.Stop()
48 |
49 | // Fails if at least 1 trusted server has a mismatch:
50 | if status.ServersWithFailures > 0 {
51 | errMsg := "Template validation error"
52 | tty.SmartFprintf(
53 | os.Stderr,
54 | "%s\n" +
55 | "\033[1;31m[-] %s: (%d/%d trusted servers failed)\n" +
56 | "[-] Possible reasons:\n" +
57 | " - Unreliable internet connection\n" +
58 | " - Outdated template entries\n" +
59 | " - Trusted servers not so trustworthy\n" +
60 | "\033[0m",
61 | buffer.String(), errMsg,
62 | status.ServersWithFailures, len(settings.ServerIPs),
63 | )
64 | return false
65 | }
66 | return true
67 | }
68 |
69 | func sanitizeServers(
70 | conf *config.Config,
71 | ttyFile *os.File,
72 | ) {
73 | settings := &config.Settings{
74 | // global
75 | ServerIPs: conf.UntrustedDNSList,
76 | Template: conf.Template,
77 | MaxThreads: conf.Opts.Threads,
78 | MaxPoolSize: conf.Opts.MaxPoolSize,
79 | GlobRateLimit: conf.Opts.GlobRateLimit,
80 | // per server
81 | PerSrvRateLimit: conf.Opts.RateLimit,
82 | PerSrvMaxFailures: conf.Opts.MaxMismatches,
83 | // per check
84 | PerCheckMaxAttempts: conf.Opts.Attempts,
85 | // per dns query
86 | PerQueryTimeout: conf.Opts.Timeout,
87 | }
88 |
89 | ioFiles := &report.IOFiles{
90 | TTYFile: ttyFile,
91 | OutputFile: conf.OutputFile,
92 | }
93 | if conf.Opts.Verbose {
94 | ioFiles.VerboseFile = os.Stderr
95 | }
96 | if conf.Opts.Debug {
97 | ioFiles.DebugFile = os.Stderr
98 | }
99 |
100 | status := report.NewStatusReporter(
101 | "[step 2/2] Servers sanitization",
102 | ioFiles, settings,
103 | )
104 | dnsanitize.DNSanitize(settings, status)
105 | status.Stop()
106 |
107 | // display final report line:
108 | successRate := float64(0.0)
109 | if status.TotalServers > 0 {
110 | successRate =
111 | float64(status.ValidServers) / float64(status.TotalServers)
112 | }
113 | reportStr := fmt.Sprintf(
114 | "[*] Valid servers: %d/%d (%.1f%%)",
115 | status.ValidServers, status.TotalServers, successRate * 100,
116 | )
117 | if ttyFile != nil {
118 | fmt.Fprintf(ttyFile, "\033[1;34m%s\033[0m\n", reportStr)
119 | }
120 | if !tty.IsTTY(os.Stderr) {
121 | fmt.Fprintf(os.Stderr, "\n%s\n", reportStr)
122 | }
123 | }
124 |
125 | func main() {
126 | conf := config.Init()
127 | ttyFile := tty.OpenTTY()
128 |
129 | // display header
130 | if ttyFile != nil {
131 | fmt.Fprintf(
132 | ttyFile,
133 | "\033[0;90m%s\033[0m\n\n",
134 | strings.Trim(config.HEADER, "\n"),
135 | )
136 | }
137 | // validate Template
138 | if !validateTemplate(conf, ttyFile) {
139 | os.Exit(3)
140 | }
141 |
142 | // sanitize servers
143 | sanitizeServers(conf, ttyFile)
144 | os.Exit(0)
145 | }
146 |
--------------------------------------------------------------------------------