package reality
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"regexp"
"strings"
"time"
)
var domainRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9\-.]+\.[A-Za-z]{2,}$`)
// urlDomainRe extracts domains from HTML/text content.
var urlDomainRe = regexp.MustCompile(`https?://([A-Za-z0-9][A-Za-z0-9\-.]+\.[A-Za-z]{2,})`)
// ParseTarget parses a single string as IP, CIDR, or domain and returns hosts.
func ParseTarget(s string, ipv6 bool) ([]Host, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
// Try as CIDR.
if strings.Contains(s, "/") {
prefix, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", s, err)
}
return expandCIDR(prefix, ipv6), nil
}
// Try as IP.
if ip := net.ParseIP(s); ip != nil {
if !ipv6 && ip.To4() == nil {
return nil, nil
}
return []Host{{IP: ip, Origin: s}}, nil
}
// Try as domain.
if domainRe.MatchString(s) {
return resolveDomain(s, ipv6)
}
return nil, fmt.Errorf("unrecognized target %q", s)
}
// ParseTargetsFromReader reads line-separated targets and sends hosts to the returned channel.
func ParseTargetsFromReader(r io.Reader, ipv6 bool) <-chan Host {
ch := make(chan Host, 64)
go func() {
defer close(ch)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
hosts, err := ParseTarget(line, ipv6)
if err != nil {
continue // skip invalid lines
}
for _, h := range hosts {
ch <- h
}
}
}()
return ch
}
// ParseTargetsFromURL fetches a URL, extracts domains from the response body, and returns hosts.
func ParseTargetsFromURL(url string, ipv6 bool) ([]Host, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB limit
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
matches := urlDomainRe.FindAllSubmatch(body, -1)
seen := make(map[string]bool)
var hosts []Host
for _, m := range matches {
domain := string(m[1])
if seen[domain] {
continue
}
seen[domain] = true
resolved, err := resolveDomain(domain, ipv6)
if err != nil {
continue
}
hosts = append(hosts, resolved...)
}
return hosts, nil
}
func expandCIDR(prefix netip.Prefix, ipv6 bool) []Host {
var hosts []Host
origin := prefix.String()
for addr := prefix.Addr(); prefix.Contains(addr); addr = addr.Next() {
if !ipv6 && !addr.Is4() {
continue
}
hosts = append(hosts, Host{
IP: addr.AsSlice(),
Origin: origin,
})
}
return hosts
}
func resolveDomain(domain string, ipv6 bool) ([]Host, error) {
ips, err := net.LookupIP(domain)
if err != nil {
return nil, fmt.Errorf("resolving %s: %w", domain, err)
}
var hosts []Host
for _, ip := range ips {
if !ipv6 && ip.To4() == nil {
continue
}
hosts = append(hosts, Host{
IP: ip,
Origin: domain,
})
}
if len(hosts) == 0 {
return nil, fmt.Errorf("no suitable addresses for %s", domain)
}
return hosts, nil
}