claude update fails with "getaddrinfo EREFUSED" on split-DNS VPN — updater resolves via c-ares, not the system resolver

Status Open
Reported on v2.1.193
Maintainer reply None cached
Activity 3 comments · opened Jun 26, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

On Linux with systemd-resolved and a split-tunnel VPN, claude update consistently fails to fetch the version manifest, even though downloads.claude.ai is fully reachable.

In the same Node runtime, fetch() and dns.lookup() resolve the host fine — only the updater fails. The reason: the updater resolves DNS via c-ares (dns.resolve*), which reads /etc/resolv.conf directly and bypasses NSS / /etc/hosts / systemd-resolved. EREFUSED is a c-ares-specific code (glibc getaddrinfo returns EAI_* codes like ENOTFOUND), which is the tell that this is the c-ares path, not the OS resolver.

My VPN pushes internal authoritative resolvers into /etc/resolv.conf that refuse recursion for public names, and lists them before the systemd-resolved stub (127.0.0.53). c-ares queries the first server, gets REFUSED, and stops — it never reaches the stub, which resolves correctly. So the updater resolves differently, and more fragilely, than the rest of the app.

What Should Happen?

The updater's manifest fetch should resolve downloads.claude.ai the same way the rest of Claude Code does — via the OS resolver (getaddrinfo / dns.lookup), honoring NSS, /etc/hosts, and systemd-resolved split-DNS. It already works for fetch() in the same process, so the updater should succeed in any environment where fetch/curl succeed.

If c-ares is intentional (e.g. to avoid the libuv threadpool), it should at least fall back to dns.lookup on EREFUSED/ESERVFAIL rather than treating the first server's REFUSED as terminal.

Error Messages/Logs

Error: Failed to install native update
Error: Failed to fetch version from https://downloads.claude.ai/claude-code-releases/latest after 3 attempt(s): getaddrinfo EREFUSED downloads.claude.ai

strace -f -e trace=connect on `claude update`, filtered to DNS endpoints:
  11  connect(.., {AF_INET, sin_port=htons(53), sin_addr="10.x.x.53"}) = 0   <- internal resolver, refuses public names
   1  connect(.., {AF_UNIX, sun_path="/run/systemd/resolve/io.systemd.Resolve"}) = 0

The 11 connects are c-ares retrying across the 3 update attempts. The internal server answers REFUSED; c-ares does not fall through to the 127.0.0.53 stub listed later in resolv.conf.

Steps to Reproduce

On a host where the first nameserver in /etc/resolv.conf returns REFUSED for public names (e.g. an internal authoritative DNS server pushed by a VPN), while systemd-resolved/the stub resolves correctly:

# c-ares path (what the updater uses) — FAILS
node -e "require('dns').resolve4('downloads.claude.ai',(e,r)=>console.log(e?e.code:r))"   # -> EREFUSED

# getaddrinfo path (NSS -> systemd-resolved) — WORKS
node -e "require('dns').lookup('downloads.claude.ai',(e,r)=>console.log(e?e.code:r))"      # -> 35.x.x.x

# global fetch to the exact updater URL — WORKS
node -e "fetch('https://downloads.claude.ai/claude-code-releases/latest').then(r=>console.log(r.status))"  # -> 200

# c-ares forced at the systemd-resolved stub — WORKS (stub does split-DNS correctly)
node -e "const d=require('dns');d.setServers(['127.0.0.53']);d.resolve4('downloads.claude.ai',(e,r)=>console.log(e?e.code:r))"  # -> 35.x.x.x

# the updater itself — FAILS
claude update   # -> getaddrinfo EREFUSED downloads.claude.ai

### Claude Model

None

### Is this a regression?

I don't know

### Last Working Version

N/A

### Claude Code Version

2.1.193

### Platform

Anthropic API

### Operating System

Ubuntu/Debian Linux

### Terminal/Shell

Xterm

### Additional Information

[claude-update-cares-bugreport.md](https://github.com/user-attachments/files/29397285/claude-update-cares-bugreport.md)

**Root cause:** the native updater's version-manifest fetch uses c-ares (`dns.resolve*`, or an HTTP client wired with a c-ares `lookup`) instead of the system resolver. c-ares reads `/etc/resolv.conf` directly, ignores NSS/`systemd-resolved`, queries servers in file order, and treats the first `REFUSED` as terminal. Because the VPN writes a public-refusing authoritative server as the first nameserver, c-ares fails where `getaddrinfo` (and `fetch`) succeed.

**Environment:** Ubuntu-family Linux, `systemd-resolved` active (NSS `nss-resolve` enabled), split-tunnel VPN (Cisco Secure Client, `cscotun0`), Node v24, shell `bash`. `/etc/resolv.conf` lists internal resolvers before `127.0.0.53`.

**Workarounds that work for me:**
- Update via the signed Linux package repo (`apt`/`dnf`/`apk` — these resolve through `getaddrinfo`).
- Or the curl installer: `curl -fsSL https://claude.ai/install.sh | bash` (curl uses `getaddrinfo`).
- `"DISABLE_AUTOUPDATER": "1"` in `settings.json` silences the failing background checks (doesn't fix resolution).

_Note: the "suggested fix" is inferred from observed behavior, not from the source._

View original on GitHub ↗

3 Comments

yurukusa · 2 months ago

Your root-cause analysis is exactly right, and the EREFUSED-is-a-c-ares-code tell is the cleanest way to prove it — c-ares (dns.resolve*) reads /etc/resolv.conf directly, queries the first listed server, gets REFUSED from your VPN's internal authoritative resolver, and stops without ever falling through to the systemd-resolved stub that getaddrinfo/dns.lookup (and therefore fetch) use. Nothing to add to the diagnosis. Two practical notes for anyone hitting this who needs to update now:

Don't reach for /etc/hosts — it won't help here. Because the failing path is c-ares, and c-ares bypasses NSS, pinning downloads.claude.ai in /etc/hosts is silently ignored by the very code that's failing (it only works for the getaddrinfo path, which already succeeds). Easy hour to lose.

Workarounds that actually hit the working resolver:

  • Update out-of-band via the install script, which shells out through curl (OS resolver, honors systemd-resolved), not the in-app c-ares updater:

``sh
curl -fsSL https://claude.ai/install.sh | bash
`
(or
claude install — it goes through fetch`, the same path you confirmed already resolves the host correctly).

  • Temporarily front-load the stub resolver so c-ares queries it first:

``sh
# back up, then put the systemd-resolved stub first
sudo sed -i '1i nameserver 127.0.0.53' /etc/resolv.conf
claude update
`
Caveat: the VPN/
systemd will likely rewrite resolv.conf` on the next link change, so this is a one-shot, not a fix — but it lets the manifest fetch through immediately.

---

For the maintainers: this is a clean, correct bug. The updater's manifest fetch should use dns.lookup/getaddrinfo (the OS resolver) like the rest of the process, not dns.resolve*/c-ares — it already works via fetch() in the same runtime, so the invariant should simply be "if fetch/curl can reach downloads.claude.ai, the updater can too." If c-ares is deliberate (e.g. to dodge the libuv threadpool), it should at minimum (a) fall back to dns.lookup on REFUSED/SERVFAIL rather than stopping at the first server, and (b) surface which host failed to resolve in the error — getaddrinfo EREFUSED with no hostname is what makes this a multi-hour diagnosis instead of a one-liner. Split-DNS / split-tunnel VPN is common enough in corp environments that this will keep recurring.

kb2001 · 1 month ago

Is there any update on this bug report?

kb2001 · 20 days ago

Update: this is not updater-specific, and the root cause is upstream in Bun

Follow-up with measured results. Two things changed since I filed this: the scope is wider than
claude update, and the fix I suggested in the original report was wrong — see below.

WebFetch fails the same way

Claude Code 2.1.227, on the same split-DNS VPN host, no updater involved:

WebFetch https://docs.aws.amazon.com/directoryservice/latest/admin-guide/ms_ad_getting_started_admin_account.html
-> Error: getaddrinfo EREFUSED docs.aws.amazon.com

Same error signature, same host, while WebSearch (server-side) works and curl on the same
machine returns HTTP 200.

Root cause: Bun's Linux DNS default, not anything specific to the updater

Claude Code ships as a Bun binary, and on Linux Bun's default DNS backend is c-ares, which reads
/etc/resolv.conf directly and has no NSS hook — so systemd-resolved and nsswitch.conf are
bypassed. That is why the error reads getaddrinfo EREFUSED: the getaddrinfo label with a c-ares
rcode. glibc getaddrinfo() only ever returns EAI_* codes.

Filed upstream, with reproductions:

  • oven-sh/bun#37378 — Linux default backend (c-ares) bypasses NSS/systemd-resolved
  • oven-sh/bun#37377 — c-ares gives up after REFUSED from the first nameserver instead of trying

the remaining resolv.conf nameservers (glibc falls through)

Verified against stock Bun, both the released 1.3.14+0d9b296af and 1.4.0-canary.1+23d233b20
(the line this build embeds) — identical results (internal resolver addresses masked):

servers : 10.x.x.53, 10.x.x.54, 127.0.0.53

node:dns.lookup (default)         -> FAIL EREFUSED (syscall=getaddrinfo)
Bun.dns.lookup backend=c-ares     -> FAIL DNS_EREFUSED (syscall=getaddrinfo)
Bun.dns.lookup backend=libc       -> 13.32.104.7, 13.32.104.39, ...
Bun.dns.lookup backend=system     -> 13.32.104.7, 13.32.104.39, ...

Node.js v24.14.1 on the same host, same /etc/resolv.conf, seconds apart, resolves it fine via
dns.lookup — so this is a Bun-vs-Node divergence, not a broken host.

Correction to my original "Suggested fix"

The original report said to have the updater "use the default getaddrinfo/dns.lookup path."
That would not fix it. In Bun, dns.lookup is the c-ares path — on Linux it is the default
backend. Please disregard that recommendation.

The framing was also slightly off: nothing had to opt into c-ares. Any node:dns.lookup call in
Bun on Linux gets it, which is why the updater and WebFetch fail identically.

You can fix this without waiting for Bun

Bun's own fetch() is unaffected — fresh process, no DNS cache involved:

$ bun -e 'fetch("https://docs.aws.amazon.com",{method:"HEAD"}).then(r=>console.log("HTTP",r.status))'
HTTP 200
$ bun -e 'require("node:dns").lookup("docs.aws.amazon.com",(e,a)=>console.log(e?e.code:a))'
EREFUSED

So a Bun app only hits this if it resolves a hostname through node:dns before fetching. From
strings in the binary it looks like Claude Code does exactly that — resolving the host, screening the
addresses against link-local/metadata ranges, then fetching by IP. I can't see your source, so treat
that as an inference from behavior rather than a claim about the code; the behavioral evidence is
that bare fetch succeeds where WebFetch fails on the same host in the same second.

If that's right, the one-line fix is to keep the guard and change only the resolver:

// instead of require("node:dns").lookup(host, { all: true })
await Bun.dns.lookup(host, { all: true, backend: "libc" })

Please don't fix it by dropping the pre-resolution and letting fetch take the hostname — that
would make the symptom vanish while removing the DNS-rebinding protection the pre-resolve provides.
A fallback (retry on EREFUSED/ESERVFAIL via libc) also works, but for a guard lookup there's no
benefit to c-ares' async behavior in the first place.

Why this is worth more than a failed lookup

getaddrinfo EREFUSED docs.aws.amazon.com reads as "that site is unreachable from this machine."
In an agent session it did real damage: the model concluded the docs host was unresolvable and
recorded that as a durable fact about the environment, then worked around a problem that didn't
exist. The site was reachable the whole time by every tool on the host that uses the system resolver.
A resolver-level failure that presents as a network-level one is especially costly in an agentic
tool, so an error message distinguishing the two — or a libc fallback — would help even before the
upstream default changes.

Workarounds for anyone else landing here

  • WebFetch on a split-DNS VPN: retry off-VPN, or fetch through Bash + curl (glibc

getaddrinfo, works on VPN). WebSearch is unaffected — it runs server-side.

  • claude update: use the signed apt/rpm/apk repo or the curl installer; both resolve via glibc.
  • "DISABLE_AUTOUPDATER": "1" silences the failing background checks but does not fix resolution.