package ipresolve import ( "context" "crypto/tls" "errors" "fmt" "io" "net" "net/http" "net/netip" "net/url" "strings" "time" ) type Result struct { IPv4 string IPv6 string IPv4Err error IPv6Err error } type Resolver struct { resolver *net.Resolver } func New() *Resolver { return &Resolver{resolver: net.DefaultResolver} } func (r *Resolver) Resolve(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string) Result { return r.ResolveWithIPv6(ctx, ipv4Endpoints, ipv6Endpoints, true) } func (r *Resolver) ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) Result { result := Result{} result.IPv4, result.IPv4Err = r.resolveFamily(ctx, ipv4Endpoints, true) if ipv6Enabled { result.IPv6, result.IPv6Err = r.resolveFamily(ctx, ipv6Endpoints, false) } return result } func (r *Resolver) ValidateEndpoint(ctx context.Context, raw string) error { _, _, err := r.parseAndResolve(ctx, raw, true) if err == nil { return nil } _, _, err6 := r.parseAndResolve(ctx, raw, false) if err6 == nil { return nil } return fmt.Errorf("endpoint is not a reachable public HTTPS host: %w", err) } func (r *Resolver) resolveFamily(ctx context.Context, endpoints []string, ipv4 bool) (string, error) { if len(endpoints) == 0 { return "", errors.New("no endpoints configured") } var failures []string for _, endpoint := range endpoints { value, err := r.resolveOne(ctx, endpoint, ipv4) if err == nil { return value, nil } failures = append(failures, safeEndpointName(endpoint)+": "+err.Error()) } family := "IPv6" if ipv4 { family = "IPv4" } return "", fmt.Errorf("all %s endpoints failed: %s", family, strings.Join(failures, "; ")) } func (r *Resolver) resolveOne(ctx context.Context, raw string, ipv4 bool) (string, error) { u, ips, err := r.parseAndResolve(ctx, raw, ipv4) if err != nil { return "", err } port := u.Port() if port == "" { port = "443" } dialer := &net.Dialer{Timeout: 5 * time.Second} transport := &http.Transport{ Proxy: nil, ForceAttemptHTTP2: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second, IdleConnTimeout: 10 * time.Second, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: u.Hostname()}, DialContext: func(dialCtx context.Context, _, _ string) (net.Conn, error) { var lastErr error for _, ip := range ips { conn, dialErr := dialer.DialContext(dialCtx, "tcp", net.JoinHostPort(ip.String(), port)) if dialErr == nil { return conn, nil } lastErr = dialErr } return nil, lastErr }, } client := &http.Client{ Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return "", err } req.Header.Set("Accept", "text/plain") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1025)) if err != nil { return "", err } if len(body) > 1024 { return "", errors.New("response is larger than 1 KiB") } address, err := netip.ParseAddr(strings.TrimSpace(string(body))) if err != nil { return "", errors.New("response is not a plain IP address") } if ipv4 != address.Is4() { return "", errors.New("response has the wrong IP family") } if !publicAddress(address) { return "", errors.New("response is not a globally routable address") } return address.String(), nil } func (r *Resolver) parseAndResolve(ctx context.Context, raw string, ipv4 bool) (*url.URL, []netip.Addr, error) { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u == nil { return nil, nil, errors.New("invalid URL") } if u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" { return nil, nil, errors.New("only credential-free HTTPS URLs are allowed") } if port := u.Port(); port != "" && port != "443" { return nil, nil, errors.New("only HTTPS port 443 is allowed") } if u.RawQuery != "" { return nil, nil, errors.New("query parameters are not allowed") } host := u.Hostname() if literal, err := netip.ParseAddr(host); err == nil { if literal.Is4() != ipv4 || !publicAddress(literal) { return nil, nil, errors.New("host must resolve to a public address of the requested family") } return u, []netip.Addr{literal}, nil } addresses, err := r.resolver.LookupNetIP(ctx, "ip", host) if err != nil { return nil, nil, fmt.Errorf("resolve host: %w", err) } filtered := make([]netip.Addr, 0, len(addresses)) for _, address := range addresses { if address.Is4() == ipv4 && publicAddress(address) { filtered = append(filtered, address) } } if len(filtered) == 0 { return nil, nil, errors.New("host has no public address of the requested family") } return u, filtered, nil } func publicAddress(address netip.Addr) bool { return address.IsValid() && address.IsGlobalUnicast() && !address.IsPrivate() && !address.IsLoopback() && !address.IsLinkLocalUnicast() && !address.IsLinkLocalMulticast() && !address.IsUnspecified() && !address.IsMulticast() } func safeEndpointName(raw string) string { u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "configured endpoint" } return u.Hostname() }