Skip to content

Instantly share code, notes, and snippets.

@tarampampam
Last active September 22, 2025 16:08
Show Gist options
  • Save tarampampam/f3418705903f9dd8f905cb1e5f62c1e9 to your computer and use it in GitHub Desktop.
Save tarampampam/f3418705903f9dd8f905cb1e5f62c1e9 to your computer and use it in GitHub Desktop.
Resolve public IP in Golang

Small, dependency-free Go helper to discover your machine’s public IPv4 quickly and reliably. It races three strategies under a short overall timeout and returns the first winner:

  • OpenDNS (myip.opendns.com A lookup via OpenDNS resolvers)
  • Google DNS (TXT on o-o.myaddr.l.google.com via Google authoritative NS)
  • HTTP (plain-text IP from several well-known endpoints)

Each strategy is isolated with short dial/read timeouts and early cancellation. You can supply your own resolvers and tweak the overall budget via functional options.

Usage example

package main

import (
	"context"
	"fmt"
	"log"

	public_ip "github.com/you/yourrepo/public_ip" // update import path to your gist/module
)

func main() {
	var ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	ip, err := public_ip.Resolve(ctx)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(ip.String())
}

Notes

  • Returns a *netip.Addr (IPv4 only by design)
  • Cancels remaining lookups as soon as one source succeeds
  • Errors are joined if all strategies fail, so you can inspect the causes
  • No external deps; works in containers and minimal environments (uses pure Go DNS with custom dialers)
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
}
package public_ip_test
import (
"net/netip"
"reflect"
"sync"
"testing"
"app/internal/public_ip"
)
func TestOpenDNSResolver(t *testing.T) {
t.Parallel()
ipv4, err := public_ip.OpenDNSResolver(t.Context())
assertNoError(t, err)
// t.Log(ipv4, err)
assertTrue(t, ipv4.IsValid() && ipv4.Is4())
}
func TestGoogleDNSResolver(t *testing.T) {
t.Parallel()
ipv4, err := public_ip.GoogleDNSResolver(t.Context())
assertNoError(t, err)
// t.Log(ipv4, err)
assertTrue(t, ipv4.IsValid() && ipv4.Is4())
}
func TestHTTPResolver(t *testing.T) {
t.Parallel()
ipv4, err := public_ip.HTTPResolver(t.Context())
assertNoError(t, err)
// t.Log(ipv4, err)
assertTrue(t, ipv4.IsValid() && ipv4.Is4())
}
func TestResolve(t *testing.T) {
t.Parallel()
var (
wg sync.WaitGroup
openDnsIPv4 *netip.Addr
googleIPv4 *netip.Addr
httpIPv4 *netip.Addr
ipv4 *netip.Addr
openDnsErr, googleErr, httpErr, err error
)
{ // OpenDNS
wg.Add(1)
go func() {
defer wg.Done()
openDnsIPv4, openDnsErr = public_ip.OpenDNSResolver(t.Context())
}()
}
{ // GoogleDNS
wg.Add(1)
go func() {
defer wg.Done()
googleIPv4, googleErr = public_ip.GoogleDNSResolver(t.Context())
}()
}
{ // HTTP
wg.Add(1)
go func() {
defer wg.Done()
httpIPv4, httpErr = public_ip.HTTPResolver(t.Context())
}()
}
{
wg.Add(1)
go func() {
defer wg.Done()
ipv4, err = public_ip.Resolve(t.Context())
}()
}
wg.Wait()
assertNoError(t, openDnsErr)
assertNoError(t, googleErr)
assertNoError(t, httpErr)
assertNoError(t, err)
assertTrue(t, ipv4.IsValid() && ipv4.Is4())
assertDeepEqual(t, openDnsIPv4, ipv4)
assertDeepEqual(t, googleIPv4, ipv4)
assertDeepEqual(t, httpIPv4, ipv4)
}
// assertNoError fails the test if err is not nil.
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
// assertTrue fails the test if the condition is false.
func assertTrue(t *testing.T, condition bool) {
t.Helper()
if !condition {
t.Fatal("expected condition to be true")
}
}
// assertDeepEqual checks if two values of any type are deeply equal.
func assertDeepEqual[T any](t *testing.T, got, want T) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment