|
package public_ip |
|
|
|
import ( |
|
"context" |
|
"errors" |
|
"fmt" |
|
"io" |
|
"net" |
|
"net/http" |
|
"net/netip" |
|
"strings" |
|
"sync" |
|
"time" |
|
) |
|
|
|
const ( |
|
// network timeouts (kept short to avoid blocking overall resolution). |
|
defaultDialTimeout = 2 * time.Second |
|
defaultResolveTimeout = 5 * time.Second // must be > defaultDialTimeout |
|
) |
|
|
|
// dnsResolver returns a *net.Resolver that talks DIRECTLY to dnsAddr via UDP, bypassing /etc/resolv.conf and the |
|
// OS stub resolver. PreferGo=true forces Go's pure resolver which respects the custom Dial function. |
|
func dnsResolver(dnsAddr string) *net.Resolver { |
|
return &net.Resolver{ |
|
PreferGo: true, |
|
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { |
|
d := net.Dialer{Timeout: defaultDialTimeout} |
|
|
|
return d.DialContext(ctx, "udp4", dnsAddr) |
|
}, |
|
} |
|
} |
|
|
|
// OpenDNSResolver queries OpenDNS's special hostname "myip.opendns.com." to learn the caller's public IPv4. |
|
func OpenDNSResolver(ctx context.Context) (ipv4 *netip.Addr, outErr error) { |
|
defer func() { |
|
if outErr != nil { |
|
outErr = fmt.Errorf("opendns dns: %w", outErr) |
|
} |
|
}() |
|
|
|
var ( |
|
wg sync.WaitGroup |
|
mu sync.Mutex |
|
) |
|
|
|
ctx, cancel := context.WithCancel(ctx) |
|
|
|
defer cancel() |
|
|
|
// two OpenDNS recursive resolvers (anycast; see: https://en.wikipedia.org/wiki/OpenDNS) |
|
for _, resolverAddr := range []string{ |
|
"208.67.222.222:53", // resolver1.opendns.com |
|
"208.67.220.220:53", // resolver2.opendns.com |
|
} { |
|
wg.Add(1) |
|
|
|
go func(addr string) { |
|
defer wg.Done() |
|
|
|
// OpenDNS returns the caller IP as an A record for this name |
|
if hosts, lErr := dnsResolver(resolverAddr).LookupHost(ctx, "myip.opendns.com."); lErr == nil { |
|
mu.Lock() |
|
defer mu.Unlock() |
|
|
|
if err := ctx.Err(); err != nil { |
|
return |
|
} |
|
|
|
for _, h := range hosts { |
|
if ip, pErr := netip.ParseAddr(h); pErr == nil && ip.Is4() { |
|
if ipv4 == nil { |
|
ipv4 = &ip |
|
|
|
cancel() |
|
} |
|
|
|
break |
|
} |
|
} |
|
} |
|
}(resolverAddr) |
|
} |
|
|
|
wg.Wait() |
|
|
|
if ipv4 == nil { |
|
return nil, errors.New("no IP address found") |
|
} |
|
|
|
return |
|
} |
|
|
|
// GoogleDNSResolver asks Google Public DNS for TXT records at "o-o.myaddr.l.google.com.", which include the |
|
// caller's public IPs in plain text. |
|
func GoogleDNSResolver(ctx context.Context) (ipv4 *netip.Addr, outErr error) { //nolint:gocyclo |
|
defer func() { |
|
if outErr != nil { |
|
outErr = fmt.Errorf("google dns: %w", outErr) |
|
} |
|
}() |
|
|
|
var googleResolver string // an authoritative ns{1..4}.google.com IP we can query directly |
|
|
|
for _, ns := range []string{ |
|
"ns1.google.com.", |
|
"ns2.google.com.", |
|
"ns3.google.com.", |
|
"ns4.google.com.", |
|
} { |
|
if googleResolver != "" { |
|
break |
|
} |
|
|
|
for _, resolverAddr := range []string{ |
|
"8.8.8.8:53", // Google Public DNS (primary) |
|
"8.8.4.4:53", // Google Public DNS (secondary) |
|
"1.1.1.1:53", // Cloudflare DNS (primary) |
|
"1.0.0.1:53", // Cloudflare DNS (secondary) |
|
} { |
|
if ctxErr := ctx.Err(); ctxErr != nil { |
|
return nil, ctxErr |
|
} |
|
|
|
ips, err := dnsResolver(resolverAddr).LookupNetIP(ctx, "ip4", ns) |
|
if err != nil || len(ips) == 0 { |
|
continue |
|
} |
|
|
|
googleResolver = ips[0].String() |
|
|
|
break |
|
} |
|
} |
|
|
|
if googleResolver == "" { |
|
return nil, errors.New("could not find a reachable Google nameserver") |
|
} |
|
|
|
// google publishes the caller address in TXT fields for this name |
|
txt, lErr := dnsResolver(net.JoinHostPort(googleResolver, "53")).LookupTXT(ctx, "o-o.myaddr.l.google.com.") |
|
if lErr != nil { |
|
return nil, fmt.Errorf("could not lookup: %w", lErr) |
|
} |
|
|
|
// parse out any IPv4 tokens found in the TXT payload |
|
for _, t := range txt { |
|
for _, field := range strings.Fields(t) { |
|
if ip, err := netip.ParseAddr(strings.Trim(field, "\"")); err == nil { |
|
if ip.Is4() && ipv4 == nil { |
|
ipv4 = &ip |
|
} |
|
} |
|
} |
|
} |
|
|
|
if ipv4 == nil { |
|
return nil, errors.New("no IP address found") |
|
} |
|
|
|
return |
|
} |
|
|
|
// HTTPResolver queries several public HTTP(S) services that return the caller's public IP in plain text. |
|
// The first successful response wins. |
|
func HTTPResolver(ctx context.Context) (ipv4 *netip.Addr, outErr error) { //nolint:gocognit,funlen |
|
defer func() { |
|
if outErr != nil { |
|
outErr = fmt.Errorf("http: %w", outErr) |
|
} |
|
}() |
|
|
|
var ( |
|
mu sync.Mutex |
|
wg sync.WaitGroup |
|
) |
|
|
|
ctx, cancel := context.WithCancel(ctx) |
|
defer cancel() |
|
|
|
// all of these return the caller's public IP in plain text |
|
for _, uri := range []string{ |
|
"https://api.ipify.org?format=text", // ipify |
|
"https://ifconfig.me/ip", // ifconfig.me |
|
"https://checkip.amazonaws.com", // Amazon AWS |
|
"https://4.ident.me", // ident.me |
|
"https://ipv4.icanhazip.com", // icanhazip.com |
|
"https://wtfismyip.com/text", // wtfismyip.com |
|
"https://ipecho.net/plain", // ipecho.net |
|
} { |
|
wg.Add(1) |
|
|
|
go func(uri string) { |
|
defer wg.Done() |
|
|
|
ip, err := func(uri string) (_ *netip.Addr, outErr error) { |
|
defer func() { |
|
if outErr != nil { |
|
outErr = fmt.Errorf("%s: %w", uri, outErr) |
|
} |
|
}() |
|
|
|
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, uri, http.NoBody) |
|
if reqErr != nil { |
|
return nil, reqErr |
|
} |
|
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0") |
|
req.Header.Set("Accept", "text/plain") |
|
|
|
resp, respErr := (&http.Client{Timeout: defaultDialTimeout}).Do(req) |
|
if respErr != nil { |
|
return nil, respErr |
|
} |
|
|
|
defer func() { _ = resp.Body.Close() }() |
|
|
|
if resp.StatusCode != http.StatusOK { |
|
return nil, fmt.Errorf("unexpected status: %s", resp.Status) |
|
} |
|
|
|
const maxBody = 32 // should be plenty for an IPv4 address |
|
|
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBody)) |
|
if readErr != nil { |
|
return nil, readErr |
|
} |
|
|
|
ip, pErr := netip.ParseAddr(strings.TrimSpace(string(body))) |
|
if pErr != nil { |
|
return nil, pErr |
|
} |
|
|
|
if !ip.Is4() { |
|
return nil, fmt.Errorf("not an IPv4 address: %s", ip.String()) |
|
} |
|
|
|
return &ip, nil |
|
}(uri) |
|
if err != nil { |
|
return |
|
} |
|
|
|
mu.Lock() |
|
defer mu.Unlock() |
|
|
|
if ipv4 == nil { |
|
ipv4 = ip |
|
|
|
cancel() |
|
} |
|
}(uri) |
|
} |
|
|
|
wg.Wait() |
|
|
|
if ipv4 == nil { |
|
return nil, errors.New("no IP address found") |
|
} |
|
|
|
return |
|
} |
|
|
|
type ( |
|
// resolver abstracts a strategy that returns the caller's public IPv4. |
|
resolver func(context.Context) (ipv4 *netip.Addr, _ error) |
|
|
|
options struct { |
|
resolvers []resolver |
|
timeout time.Duration // overall time budget for Resolve |
|
} |
|
|
|
// Option is a functional option for configuring Resolve behavior. |
|
Option func(*options) |
|
) |
|
|
|
// Apply a list of [Option]'s and return the updated state. |
|
func (o options) Apply(opts ...Option) options { |
|
for _, opt := range opts { |
|
opt(&o) |
|
} |
|
|
|
return o |
|
} |
|
|
|
// WithResolvers sets the list of resolvers to use (order does not matter; they race). |
|
func WithResolvers(r ...resolver) Option { return func(o *options) { o.resolvers = r } } |
|
|
|
// WithTimeout sets the overall timeout for the resolution process. |
|
func WithTimeout(d time.Duration) Option { return func(o *options) { o.timeout = d } } |
|
|
|
// Resolve runs the provided resolvers in parallel under an overall timeout, returning the first observed IPv4 |
|
// address. If none succeed, it returns a joined error of all failures. The input context may be used to cancel |
|
// early. |
|
// |
|
// If no resolvers are passed via [WithResolvers], the following are used: |
|
// - OpenDNSResolver |
|
// - GoogleDNSResolver |
|
// - HTTPResolver |
|
// |
|
// The context may be used to cancel the operation early. |
|
func Resolve(ctx context.Context, opts ...Option) (ipv4 *netip.Addr, _ error) { |
|
var o = options{ |
|
resolvers: []resolver{OpenDNSResolver, GoogleDNSResolver, HTTPResolver}, |
|
timeout: defaultResolveTimeout, |
|
}.Apply(opts...) |
|
|
|
ctx, cancel := context.WithTimeout(ctx, o.timeout) |
|
defer cancel() |
|
|
|
var ( |
|
wg sync.WaitGroup |
|
mu sync.Mutex |
|
errs []error |
|
) |
|
|
|
for _, r := range o.resolvers { |
|
wg.Add(1) |
|
|
|
go func(r resolver) { |
|
defer wg.Done() |
|
|
|
got, err := r(ctx) |
|
|
|
mu.Lock() |
|
|
|
if err != nil { |
|
errs = append(errs, err) |
|
} else if got != nil && ipv4 == nil { |
|
ipv4 = got |
|
|
|
cancel() |
|
} |
|
|
|
mu.Unlock() |
|
}(r) |
|
} |
|
|
|
wg.Wait() |
|
|
|
if ipv4 == nil { |
|
return nil, errors.Join(errs...) |
|
} |
|
|
|
return |
|
} |