├── go.mod ├── Dockerfile ├── sni-finder-run.sh ├── README.md ├── .github └── workflows │ └── release.yml └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module sni-finder 2 | 3 | go 1.24.1 4 | 5 | require github.com/sirupsen/logrus v1.9.3 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest AS builder 2 | WORKDIR /app 3 | COPY go.mod main.go ./ 4 | RUN go mod tidy 5 | RUN CGO_ENABLED=0 GOOS=linux go build -o sni-finder main.go 6 | 7 | FROM alpine:latest 8 | WORKDIR /app 9 | COPY --from=builder /app/sni-finder . 10 | ENTRYPOINT ["./sni-finder"] 11 | -------------------------------------------------------------------------------- /sni-finder-run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # For Linux/macOS. Downloads & runs the latest release binary. 3 | 4 | # --- Configuration --- 5 | OWNER="hawshemi" 6 | REPO="sni-finder" 7 | 8 | # --- Detect OS (Linux or macOS) --- 9 | os_type=$(uname -s) 10 | if [ "$os_type" = "Darwin" ]; then 11 | platform="darwin" 12 | elif [ "$os_type" = "Linux" ]; then 13 | platform="linux" 14 | else 15 | echo "Unsupported OS: $os_type. This script only supports Linux and macOS." 16 | exit 1 17 | fi 18 | 19 | # --- Detect CPU Architecture --- 20 | cpu_arch=$(uname -m) 21 | if [ "$cpu_arch" = "x86_64" ]; then 22 | arch="amd64" 23 | elif [ "$cpu_arch" = "arm64" ] || [ "$cpu_arch" = "aarch64" ]; then 24 | arch="arm64" 25 | else 26 | echo "Unsupported architecture: $cpu_arch" 27 | exit 1 28 | fi 29 | 30 | # --- Construct Expected Asset Name --- 31 | asset_name="${REPO}-${platform}-${arch}" 32 | echo "Detected OS: $platform, Architecture: $arch" 33 | echo "Looking for asset: $asset_name" 34 | 35 | # --- Fetch Latest Release Info from GitHub --- 36 | API_URL="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" 37 | release_json=$(curl -s "$API_URL") 38 | 39 | # --- Extract the Download URL without jq --- 40 | # This sed/grep pipeline works as follows: 41 | # 1. sed prints lines starting from the one with our asset "name" up to the first occurrence of "browser_download_url" 42 | # 2. grep filters the line containing "browser_download_url" 43 | # 3. sed extracts the URL value. 44 | download_url=$(echo "$release_json" \ 45 | | sed -n '/"name": *"'"$asset_name"'"/,/"browser_download_url"/p' \ 46 | | grep "browser_download_url" \ 47 | | head -n 1 \ 48 | | sed -E 's/.*"browser_download_url": *"([^"]+)".*/\1/') 49 | 50 | if [ -z "$download_url" ]; then 51 | echo "Error: Could not find asset \"$asset_name\" in the latest release." 52 | exit 1 53 | fi 54 | 55 | echo "Downloading $asset_name from:" 56 | echo "$download_url" 57 | curl -L -o "$asset_name" "$download_url" 58 | 59 | # --- Set Execute Permissions & Run the Binary --- 60 | chmod +x "$asset_name" 61 | echo 62 | echo "$asset_name Downloaded." 63 | echo "Run with: ./"$asset_name --addr ip"" 64 | echo "The "ip" would be your VPS IP Address." 65 | echo 66 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SNI Finder 2 | 3 | This script will scan all domains with `TLS 1.3` and `h2` enabled on your VPS IP address range. These domains are useful for SNI domain names in various configurations and tests. 4 | 5 | When you begin the scan, two files are created: `results.txt` contains the output log, while `domains.txt` only contains the domain names. 6 | 7 | It is recommended to run this scanner locally _(with your residential internet)_. It may cause VPS to be flagged if you run a scanner in the cloud. 8 | 9 | 10 | ## Run 11 | 12 | ### Linux/Mac OS: 13 | 14 | 1. 15 | ``` 16 | curl -L "https://raw.githubusercontent.com/hawshemi/sni-finder/main/sni-finder-run.sh" -o sni-finder-run.sh && chmod +x sni-finder-run.sh && bash sni-finder-run.sh 17 | ``` 18 | 2. 19 | ``` 20 | ./sni-finder -addr ip 21 | ``` 22 | 23 | ### Docker: 24 | 25 | 1. 26 | ``` 27 | git clone https://github.com/hawshemi/SNI-Finder/ && cd SNI-Finder 28 | ``` 29 | 2. 30 | ``` 31 | docker build -t "sni-finder:latest" . 32 | ``` 33 | 3. 34 | ``` 35 | docker run -d sni-finder -addr ip 36 | ``` 37 | 38 | ### Windows: 39 | 40 | 1. Download from [Releases](https://github.com/hawshemi/SNI-Finder/releases/latest). 41 | 2. Open `CMD` or `Powershell` in the directory. 42 | 3. 43 | ``` 44 | .\sni-finder-windows-amd64.exe -addr ip 45 | ``` 46 | 47 | #### Replace `ip` with your VPS IP Address. 48 | 49 | 50 | ## Build 51 | 52 | ### Prerequisites 53 | 54 | #### Install `wget`: 55 | ``` 56 | sudo apt install -y wget 57 | ``` 58 | 59 | #### Run this script to install `Go` & other dependencies _(Debian & Ubuntu)_: 60 | 61 | wget "https://raw.githubusercontent.com/hawshemi/tools/main/go-installer/go-installer.sh" -O go-installer.sh && chmod +x go-installer.sh && bash go-installer.sh 62 | 63 | - Reboot is recommended. 64 | 65 | 66 | #### Then: 67 | 68 | #### 1. Clone the repository 69 | ``` 70 | git clone https://github.com/hawshemi/sni-finder.git 71 | ``` 72 | 73 | #### 2. Navigate into the repository directory 74 | ``` 75 | cd sni-finder 76 | ``` 77 | 78 | #### 3. Initiate and download deps 79 | ``` 80 | go mod init sni-finder && go mod tidy 81 | ``` 82 | 83 | #### 4. Build 84 | ``` 85 | CGO_ENABLED=0 go build 86 | ``` 87 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | BIN_PATH: /tmp/bin 10 | 11 | jobs: 12 | build: 13 | name: Build binaries 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, macos-latest, windows-latest] 19 | arch: [amd64, arm64] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: '1.24' 29 | cache: false 30 | 31 | - name: Run go mod tidy 32 | run: go mod tidy 33 | 34 | - name: Set environment variables 35 | run: | 36 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 37 | echo "GOOS=linux" >> $GITHUB_ENV 38 | elif [ "${{ matrix.os }}" == "macos-latest" ]; then 39 | echo "GOOS=darwin" >> $GITHUB_ENV 40 | else 41 | echo "GOOS=windows" >> $GITHUB_ENV 42 | echo "EXT=.exe" >> $GITHUB_ENV 43 | fi 44 | 45 | - name: Build binary 46 | env: 47 | GOARCH: ${{ matrix.arch }} 48 | GOOS: ${{ env.GOOS }} 49 | EXT: ${{ env.EXT }} 50 | run: | 51 | go build -o sni-finder-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} . 52 | 53 | - name: Upload binaries 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: sni-finder-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} 57 | path: sni-finder-${{ env.GOOS }}-${{ matrix.arch }}${{ env.EXT }} 58 | 59 | release: 60 | name: Create GitHub Release 61 | needs: build 62 | runs-on: ubuntu-latest 63 | 64 | steps: 65 | - name: Create bin directory 66 | run: | 67 | mkdir -p ${{ env.BIN_PATH }} 68 | 69 | - name: Download binaries 70 | uses: actions/download-artifact@v4 71 | with: 72 | path: ${{ env.BIN_PATH }} 73 | pattern: sni-finder-* 74 | merge-multiple: true 75 | 76 | - name: Display structure of downloaded files 77 | run: ls -R ${{ env.BIN_PATH }} 78 | 79 | - name: Release with assets 80 | uses: softprops/action-gh-release@v2 81 | with: 82 | files: ${{ env.BIN_PATH }}/* 83 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "math/big" 9 | "net" 10 | "os" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const ( 19 | defaultAddress = "0.0.0.0" 20 | defaultPort = "443" 21 | defaultThreadCount = 128 22 | defaultTimeout = 4 23 | outPutDef = true 24 | outPutFileName = "results.txt" 25 | domainsFileName = "domains.txt" 26 | showFailDef = false 27 | numIPsToCheck = 10000 28 | workerPoolSize = 100 29 | ) 30 | 31 | var ( 32 | log = logrus.New() 33 | zeroIP = net.ParseIP("0.0.0.0") 34 | maxIP = net.ParseIP("255.255.255.255") 35 | TlsVersions = map[uint16]string{ 36 | 0x0301: "1.0", 37 | 0x0302: "1.1", 38 | 0x0303: "1.2", 39 | 0x0304: "1.3", 40 | } 41 | ) 42 | 43 | type CustomTextFormatter struct { 44 | logrus.TextFormatter 45 | } 46 | 47 | type Scanner struct { 48 | addr string 49 | port string 50 | showFail bool 51 | output bool 52 | timeout time.Duration 53 | wg *sync.WaitGroup 54 | numberOfThread int 55 | mu sync.Mutex 56 | ip net.IP 57 | logFile *os.File 58 | domainFile *os.File 59 | dialer *net.Dialer 60 | logChan chan string 61 | } 62 | 63 | func (f *CustomTextFormatter) Format(entry *logrus.Entry) ([]byte, error) { 64 | timestamp := entry.Time.Format("2006-01-02 15:04:05") 65 | msg := entry.Message 66 | 67 | // Create the log entry without the "level=info" and with a new line 68 | return []byte(timestamp + msg + "\n\n"), nil 69 | } 70 | 71 | func (s *Scanner) Print(outStr string) { 72 | // Split the output string into IP address and the rest 73 | parts := strings.Split(outStr, " ") 74 | if len(parts) < 2 { 75 | return 76 | } 77 | 78 | ipAddress := parts[0] // Extract the IP address part 79 | rest := strings.Join(parts[1:], " ") // Extract the rest of the message 80 | 81 | // Calculate the maximum IP address length 82 | maxIPLength := len("255.255.255.255") 83 | 84 | // Format the IP address with a fixed width 85 | formattedIP := fmt.Sprintf("%-*s", maxIPLength-8, ipAddress) 86 | 87 | // Create the final log entry with IP alignment 88 | logEntry := formattedIP + rest 89 | 90 | // Extract the domain from the log entry 91 | domain := extractDomain(logEntry) 92 | 93 | // Save the domain to domains.txt if it's not empty 94 | if domain != "" { 95 | saveDomain(domain, s.domainFile) 96 | } 97 | 98 | s.logChan <- logEntry 99 | } 100 | 101 | func extractDomain(logEntry string) string { 102 | // Split the log entry into words 103 | parts := strings.Fields(logEntry) 104 | 105 | // Search for a word that looks like a domain (contains a dot) 106 | for i, part := range parts { 107 | if strings.Contains(part, ".") && !strings.HasPrefix(part, "v") && i > 0 { 108 | // Split the part using ":" and take the first part (domain) 109 | domainParts := strings.Split(part, ":") 110 | return domainParts[0] 111 | } 112 | } 113 | 114 | return "" 115 | } 116 | 117 | func saveDomain(domain string, file *os.File) { 118 | if domain == "" { 119 | return 120 | } 121 | 122 | _, err := file.WriteString(domain + "\n") 123 | if err != nil { 124 | log.WithError(err).Error("Error writing domain into file") 125 | } 126 | } 127 | 128 | func main() { 129 | addrPtr := flag.String("addr", defaultAddress, "Destination to start scan") 130 | portPtr := flag.String("port", defaultPort, "Port to scan") 131 | threadPtr := flag.Int("thread", defaultThreadCount, "Number of threads to scan in parallel") 132 | outPutFile := flag.Bool("o", outPutDef, "Is output to results.txt") 133 | timeOutPtr := flag.Int("timeOut", defaultTimeout, "Time out of a scan") 134 | showFailPtr := flag.Bool("showFail", showFailDef, "Is Show fail logs") 135 | 136 | flag.Parse() 137 | 138 | // Initialize Logrus settings 139 | log.SetFormatter(&CustomTextFormatter{}) 140 | log.SetLevel(logrus.InfoLevel) // Set the desired log level 141 | 142 | // Setup scanner with configuration 143 | s := setupScanner(*addrPtr, *portPtr, *showFailPtr, *outPutFile, *timeOutPtr, *threadPtr) 144 | if s == nil { 145 | return 146 | } 147 | defer s.cleanup() 148 | 149 | go s.logWriter() 150 | 151 | // Create a buffered channel for IPs to scan 152 | ipChan := make(chan net.IP, numIPsToCheck) 153 | 154 | // Start the worker pool 155 | for i := 0; i < s.numberOfThread; i++ { 156 | go s.worker(ipChan) 157 | } 158 | 159 | // Generate the IPs to scan and send them to the channel 160 | for i := 0; i < numIPsToCheck; i++ { 161 | nextIP := s.nextIP(true) 162 | if nextIP != nil { 163 | s.wg.Add(1) 164 | ipChan <- nextIP 165 | } 166 | } 167 | 168 | close(ipChan) 169 | 170 | // Wait for all scans to complete 171 | s.wg.Wait() 172 | close(s.logChan) 173 | log.Info("Scan completed.") 174 | } 175 | 176 | func setupScanner(addr, port string, showFail, output bool, timeoutSec int, threads int) *Scanner { 177 | s := &Scanner{ 178 | addr: addr, 179 | port: port, 180 | showFail: showFail, 181 | output: output, 182 | timeout: time.Duration(timeoutSec) * time.Second, 183 | wg: &sync.WaitGroup{}, 184 | numberOfThread: threads, 185 | ip: net.ParseIP(addr), 186 | dialer: &net.Dialer{}, 187 | logChan: make(chan string, numIPsToCheck), 188 | } 189 | 190 | // Open results.txt file for writing 191 | var err error 192 | s.logFile, err = os.OpenFile(outPutFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 193 | if err != nil { 194 | log.WithError(err).Error("Failed to open log file") 195 | return nil 196 | } 197 | 198 | // Open domains.txt file for writing 199 | s.domainFile, err = os.OpenFile(domainsFileName, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 200 | if err != nil { 201 | log.WithError(err).Error("Failed to open domains.txt file") 202 | s.logFile.Close() 203 | return nil 204 | } 205 | 206 | return s 207 | } 208 | 209 | func (s *Scanner) cleanup() { 210 | if s.logFile != nil { 211 | s.logFile.Close() 212 | } 213 | if s.domainFile != nil { 214 | s.domainFile.Close() 215 | } 216 | } 217 | 218 | func (s *Scanner) logWriter() { 219 | for str := range s.logChan { 220 | log.Info(str) // Log with Info level 221 | if s.output { 222 | _, err := s.logFile.WriteString(str + "\n") 223 | if err != nil { 224 | log.WithError(err).Error("Error writing into file") 225 | } 226 | } 227 | } 228 | } 229 | 230 | func (s *Scanner) worker(ipChan <-chan net.IP) { 231 | for ip := range ipChan { 232 | s.Scan(ip) 233 | s.wg.Done() 234 | } 235 | } 236 | 237 | func (s *Scanner) nextIP(increment bool) net.IP { 238 | s.mu.Lock() 239 | defer s.mu.Unlock() 240 | 241 | ipb := big.NewInt(0).SetBytes(s.ip.To4()) 242 | if increment { 243 | ipb.Add(ipb, big.NewInt(1)) 244 | } else { 245 | ipb.Sub(ipb, big.NewInt(1)) 246 | } 247 | 248 | b := ipb.Bytes() 249 | b = append(make([]byte, 4-len(b)), b...) 250 | nextIP := net.IP(b) 251 | 252 | if nextIP.Equal(zeroIP) || nextIP.Equal(maxIP) { 253 | return nil 254 | } 255 | 256 | s.ip = nextIP 257 | return s.ip 258 | } 259 | 260 | func (s *Scanner) Scan(ip net.IP) { 261 | str := ip.String() 262 | 263 | if ip.To4() == nil { 264 | str = "[" + str + "]" 265 | } 266 | 267 | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) 268 | defer cancel() 269 | 270 | conn, err := s.dialer.DialContext(ctx, "tcp", str+":"+s.port) 271 | if err != nil { 272 | if s.showFail { 273 | s.Print(fmt.Sprintf("Dial failed: %v", err)) 274 | } 275 | return 276 | } 277 | defer conn.Close() // Ensure the connection is closed 278 | 279 | remoteAddr := conn.RemoteAddr().(*net.TCPAddr) 280 | remoteIP := remoteAddr.IP.String() 281 | port := remoteAddr.Port 282 | line := fmt.Sprintf("%s:%d", remoteIP, port) + "\t" 283 | 284 | // Set deadline for TLS handshake 285 | conn.SetDeadline(time.Now().Add(s.timeout)) 286 | 287 | c := tls.Client(conn, &tls.Config{ 288 | InsecureSkipVerify: true, 289 | NextProtos: []string{"h2", "http/1.1"}, 290 | }) 291 | err = c.Handshake() 292 | 293 | if err != nil { 294 | if s.showFail { 295 | s.Print(fmt.Sprintf("%s - TLS handshake failed: %v", line, err)) 296 | } 297 | return 298 | } 299 | defer c.Close() // Ensure the TLS client is also properly closed 300 | 301 | state := c.ConnectionState() 302 | alpn := state.NegotiatedProtocol 303 | 304 | if alpn == "" { 305 | alpn = " " 306 | } 307 | 308 | if s.showFail || (state.Version == 0x0304 && alpn == "h2") { 309 | certSubject := "" 310 | if len(state.PeerCertificates) > 0 { 311 | certSubject = state.PeerCertificates[0].Subject.CommonName 312 | } 313 | 314 | // Filter out invalid certificates 315 | if isInvalidCertificate(certSubject) { 316 | return 317 | } 318 | 319 | s.Print(fmt.Sprint(" ", line, "---- TLS v", TlsVersions[state.Version], " ALPN: ", alpn, " ---- ", certSubject, ":", s.port)) 320 | } 321 | } 322 | 323 | func isInvalidCertificate(certSubject string) bool { 324 | numPeriods := strings.Count(certSubject, ".") 325 | return strings.HasPrefix(certSubject, "*") || 326 | certSubject == "localhost" || 327 | numPeriods != 1 || 328 | certSubject == "invalid2.invalid" || 329 | certSubject == "OPNsense.localdomain" 330 | } 331 | --------------------------------------------------------------------------------