├── .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 | tweet 7 | 8 |

9 |
10 | 11 |

12 | 13 | Go v1.21 compatible 14 | 15 | 16 | Tests workflow 17 | 18 | 19 | Dependabot status 20 | 21 | 22 | Codacy code quality 23 | 24 | 25 | CodeQL workflow 26 | 27 |

28 | 29 |

30 | 31 | CodeCov coverage 32 | 33 | 34 | Qlty maintainability 35 | 36 | 37 | 38 | 39 |

40 | 41 |
42 | 43 | Created by 44 | nil0x42 and 45 | contributors 46 | 47 |
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 | --------------------------------------------------------------------------------