Skip to content

fix(sandbox): restore sandbox DNS resolution for web tools (fixes #626) - #1062

Merged
kjw3 merged 2 commits into
NVIDIA:mainfrom
senthilr-nv:fix/sandbox-dns-resolution-626
Mar 29, 2026
Merged

fix(sandbox): restore sandbox DNS resolution for web tools (fixes #626)#1062
kjw3 merged 2 commits into
NVIDIA:mainfrom
senthilr-nv:fix/sandbox-dns-resolution-626

Conversation

@senthilr-nv

@senthilr-nv senthilr-nv commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

The sandbox network namespace has no working DNS — getaddrinfo returns EAI_AGAIN for every public hostname. This breaks web_fetch, web_search, and any Node.js tool that resolves hostnames before connecting.

Root cause: Two independent problems block DNS from the sandbox:

  1. The sandbox's resolv.conf points to CoreDNS ClusterIP (10.43.0.10), which is only routable in the pod namespace — not the sandbox's nested namespace (10.200.0.0/24)
  2. OpenShell's sandbox iptables reject all UDP — even if the address were routable, DNS packets would be blocked

Fix: A three-step DNS bridge deployed automatically during onboard:

  1. A Python UDP DNS forwarder on the pod-side veth gateway (10.200.0.1:53), forwarding to the CoreDNS pod IP
  2. An iptables rule in the sandbox namespace allowing UDP to 10.200.0.1:53 (the only non-proxy firewall exception)
  3. Updated sandbox resolv.conf pointing to 10.200.0.1 instead of the unreachable 10.43.0.10

Additionally broadens the CoreDNS upstream patch to all Docker-based runtimes (not just Colima), since k3s-inside-Docker has broken DNS forwarding on all platforms.

DNS setup is applied automatically during onboard/setup and currently remains best-effort: failures are logged clearly but do not block sandbox creation.

Credit

The DNS forwarder approach originated in #732 by @jestyr27. The core idea — run a lightweight Python UDP forwarder in the pod to relay DNS queries from the sandbox — was correct and materially guided this fix.

The part that needed more iteration was the deployment mechanism (nsenter vs kubectl exec) and the discovery that the sandbox iptables reject all UDP to non-loopback addresses, which meant the forwarder address and firewall rules had to be coordinated together. This PR is a clean rewrite addressing all 6 CodeRabbit issues from #732 plus the iptables constraint it didn't account for.

Changes

File Change
scripts/setup-dns-proxy.sh NEW — DNS forwarder deployment (Python UDP forwarder + iptables rule + resolv.conf) with built-in runtime verification
test/dns-proxy.test.js NEW — unit tests for DNS proxy and CoreDNS scripts
bin/lib/onboard.js Call setup-dns-proxy.sh after sandbox creation
bin/lib/platform.js shouldPatchCoredns() → true for all runtimes (not just Colima)
scripts/fix-coredns.sh Replace Colima-specific socket logic with generic Docker host detection
scripts/setup.sh Add DNS proxy setup after sandbox creation
test/platform.test.js Update tests for broadened shouldPatchCoredns

Testing

Unit tests:

  • npm test — 616 passed, 2 skipped (DGX Spark ARM64)
  • make check — lint, format, shellcheck all green

Built-in runtime verification: setup-dns-proxy.sh now verifies all three layers post-deployment from inside the sandbox namespace: (1) resolv.conf points to the veth gateway, (2) the UDP DNS iptables rule is present (iptables -C OUTPUT), and (3) actual DNS resolution works (getent hosts github.com). Output shows [PASS]/[FAIL] per check.

End-to-end (fresh destroy + onboard on both platforms):

Test DGX Spark (ARM64) Brev VM (x86_64)
DNS proxy starts 10.200.0.1:53 → 10.42.0.6 10.200.0.1:53 → 10.42.0.2
getent hosts google.com Resolved Resolved
getent hosts techcrunch.com Resolved Resolved
Node.js dns.lookup() Resolved Resolved
Python getaddrinfo() Resolved Resolved
Proxy CONNECT (after policy approval) 200 OK 200 OK
HTTPS fetch via proxy 200 OK 200 OK, 423KB
web_search (Perplexity) Works
web_fetch see note see note

Firewall proof (iptables -S OUTPUT inside sandbox namespace)

DGX Spark (ARM64):

$ ip netns exec sandbox-758bf060 iptables -S OUTPUT
-P OUTPUT ACCEPT
-A OUTPUT -d 10.200.0.1/32 -p udp -m udp --dport 53 -j ACCEPT   ← DNS rule
-A OUTPUT -d 10.200.0.1/32 -p tcp -m tcp --dport 3128 -j ACCEPT
-A OUTPUT -o lo -j ACCEPT
-A OUTPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A OUTPUT -p tcp ... -j REJECT --reject-with icmp-port-unreachable
-A OUTPUT -p udp ... -j REJECT --reject-with icmp-port-unreachable

Brev VM (x86_64):

$ ip netns exec sandbox-a7626571 iptables -S OUTPUT
-P OUTPUT ACCEPT
-A OUTPUT -d 10.200.0.1/32 -p udp -m udp --dport 53 -j ACCEPT   ← DNS rule
-A OUTPUT -d 10.200.0.1/32 -p tcp -m tcp --dport 3128 -j ACCEPT
-A OUTPUT -o lo -j ACCEPT
-A OUTPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A OUTPUT -p tcp ... -j REJECT --reject-with icmp-port-unreachable
-A OUTPUT -p udp ... -j REJECT --reject-with icmp-port-unreachable

The UDP DNS rule (-A OUTPUT -d 10.200.0.1/32 -p udp --dport 53 -j ACCEPT) is inserted at position 1 by setup-dns-proxy.sh, appearing before the blanket UDP REJECT. Without this rule, all DNS queries are dropped.

Note on web_fetch: After DNS and proxy are working, web_fetch still fails with "fetch failed" due to an upstream OpenClaw issue — the SSRF guard in strict mode bypasses HTTPS_PROXY. This is tracked in openclaw/openclaw#47598 with a fix pending in openclaw/openclaw#50650. web_search works because it uses the env-proxy path. The exec tool with curl also works. This PR fixes the NemoClaw-side DNS prerequisite; the OpenClaw fix is needed for full web_fetch support.

Related

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced DNS resolution handling for sandbox environments with improved fallback mechanisms
  • New Features

    • Extended CoreDNS patching support to additional container runtimes
    • Added DNS proxy configuration for sandboxes with isolated network environments
  • Tests

    • New integration tests for DNS proxy and CoreDNS configuration

…NVIDIA#626)

The sandbox runs in an isolated network namespace (10.200.0.0/24) where
OpenShell's iptables rules reject all non-proxy traffic including UDP.
This causes getaddrinfo EAI_AGAIN for every outbound DNS lookup,
breaking web_fetch, web_search, and all Node.js HTTP tools.

Fix (three steps in new scripts/setup-dns-proxy.sh):
1. Run a Python UDP DNS forwarder on the pod-side veth gateway
   (10.200.0.1:53), forwarding to the real CoreDNS pod IP
2. Add an iptables rule in the sandbox namespace allowing UDP to
   the gateway on port 53 (the only non-proxy firewall exception)
3. Update the sandbox's /etc/resolv.conf to point to 10.200.0.1

Also broadens the CoreDNS patch (fix-coredns.sh / shouldPatchCoredns)
to run on all Docker-based runtimes, not just Colima — k3s-inside-Docker
has broken DNS forwarding on Linux hosts with systemd-resolved too.

Inspired by PR NVIDIA#732's DNS forwarder approach. Key differences:
- Uses kubectl exec instead of nsenter (fixes PR NVIDIA#732's launch bug)
- Handles the sandbox iptables constraint (all UDP blocked)
- Resolves systemd-resolved upstreams via resolvectl before falling
  back to 8.8.8.8
- Uses grep -F for fixed-string sandbox name matching

Tested on DGX Spark (ARM64): fresh destroy + onboard, getent hosts
and node dns.lookup both resolve from inside the sandbox.
setup-dns-proxy.sh now verifies all three DNS bridge layers from inside
the sandbox namespace post-deployment: resolv.conf target, iptables UDP
rule, and actual getent hosts resolution. Reports [PASS]/[FAIL] per
check so failures are immediately visible in onboard logs.
@coderabbitai

coderabbitai Bot commented Mar 29, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The changes expand CoreDNS patching and DNS forwarding capabilities beyond Colima to all identified container runtimes, introduce a new DNS proxy setup script for isolated sandbox network namespaces, refactor Docker host detection logic, and add corresponding test coverage for the new functionality and modified behaviors.

Changes

Cohort / File(s) Summary
DNS Patching Expansion
bin/lib/platform.js, bin/lib/onboard.js, scripts/setup.sh
Modified shouldPatchCoredns() to enable CoreDNS patching for all identified runtimes (not just Colima); updated orchestration steps to invoke DNS proxy setup post-sandbox-creation; adjusted log messaging to reflect broader DNS forwarding scenarios.
CoreDNS Upstream Resolution
scripts/fix-coredns.sh
Generalized Docker host detection from Colima-specific socket lookup to runtime-agnostic detect_docker_host flow; introduced RUNTIME variable for conditional logic; added resolvectl fallback for upstream DNS discovery; implemented 8.8.8.8 fallback when upstream cannot be determined; expanded scope from Colima-only to Docker-based setups broadly.
DNS Proxy Setup
scripts/setup-dns-proxy.sh
New 249-line script that configures UDP DNS forwarding for sandboxes in isolated network namespaces via Python forwarder deployment, veth gateway discovery, iptables OUTPUT rules, /etc/resolv.conf rewriting, and multi-step runtime verification (process PID, log markers, DNS resolution test).
Test Coverage
test/dns-proxy.test.js, test/platform.test.js
Added comprehensive integration tests for new DNS proxy and CoreDNS fix scripts (existence, executability, sourcing, usage messages, logic validation); updated shouldPatchCoredns test expectations to include docker-desktop, docker, podman as true cases and "unknown" as false.

Sequence Diagram

sequenceDiagram
    actor User
    participant Gateway as Gateway Container
    participant K8s as Kubernetes API
    participant Core as CoreDNS Pod
    participant Target as Target Sandbox Pod
    participant NS as Sandbox Netns

    User->>Gateway: scripts/setup-dns-proxy.sh (gateway, sandbox)
    Gateway->>K8s: kubectl get endpoints kube-dns
    K8s-->>Gateway: CoreDNS Pod IP (e.g., 10.200.0.2)
    Gateway->>K8s: kubectl get pod (target sandbox)
    K8s-->>Gateway: Sandbox pod details
    Gateway->>Target: Deploy Python DNS forwarder
    Target->>Target: Start dns-proxy.py (listen on 10.200.0.1:53)
    Target-->>Gateway: Forwarder PID logged
    Gateway->>NS: Insert iptables OUTPUT rule (ACCEPT UDP/53)
    Gateway->>Target: Overwrite /etc/resolv.conf
    Gateway->>NS: getent hosts github.com (verify DNS)
    NS-->>Gateway: Resolution result
    Gateway-->>User: Verification pass/fail report
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 ears twitch with DNS delight
No more Colima walls to confine,
Gateway pods and veth IPs align,
Python forwarders through namespace gates,
iptables rules seal the UDP fates,
From Docker-desktop to Podman we shine! 🌐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(sandbox): restore sandbox DNS resolution for web tools (fixes #626)' clearly and specifically describes the main change—fixing DNS resolution in the sandbox namespace for web tools. It is concise, directly related to the changeset, and references the issue being fixed.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@senthilr-nv senthilr-nv self-assigned this Mar 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
scripts/setup-dns-proxy.sh (2)

122-135: Consider adding basic error logging in the DNS forwarder.

The except Exception: pass block silently swallows all forwarding errors, which can make debugging DNS issues difficult. Consider logging failures to /tmp/dns-proxy.log for observability.

♻️ Suggested improvement
 def forward(data, addr):
     try:
         f = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         f.settimeout(5)
         f.sendto(data, UPSTREAM)
         r, _ = f.recvfrom(4096)
         sock.sendto(r, addr)
         f.close()
-    except Exception:
-        pass
+    except Exception as e:
+        try:
+            with open('/tmp/dns-proxy.log', 'a') as log:
+                log.write('forward error: {} -> {}\n'.format(addr, e))
+        except:
+            pass
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/setup-dns-proxy.sh` around lines 122 - 135, The forward function
currently swallows all exceptions (except Exception: pass) which hides
forwarding failures; update forward to catch exceptions, log the error with
context (include exception message and stack trace) to /tmp/dns-proxy.log
(append mode), and ensure the upstream socket f is closed in a finally block;
reference function name forward and variable UPSTREAM and sock so you add
logging around the f.sendto/f.recvfrom and sock.sendto calls and write
timestamped messages to /tmp/dns-proxy.log for observability.

133-135: Unbounded thread creation under high DNS load.

Each incoming DNS query spawns a new daemon thread with no concurrency limit. Under heavy DNS load, this could exhaust resources. For a sandbox environment with limited DNS traffic, this is likely acceptable, but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/setup-dns-proxy.sh` around lines 133 - 135, The loop currently spawns
an unbounded daemon thread per incoming packet (sock.recvfrom ->
threading.Thread(target=forward, args=(d, a), daemon=True).start()), which can
exhaust resources under load; change to a bounded worker pool by using a
ThreadPoolExecutor or a fixed set of worker threads reading from a Queue and
submit tasks to it (or protect creation with a Semaphore) so only a limited
number of concurrent forward(...) calls run; update the loop to submit incoming
(d, a) to the pool/queue and ensure forward is compatible with being called by
pooled workers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@scripts/setup-dns-proxy.sh`:
- Around line 122-135: The forward function currently swallows all exceptions
(except Exception: pass) which hides forwarding failures; update forward to
catch exceptions, log the error with context (include exception message and
stack trace) to /tmp/dns-proxy.log (append mode), and ensure the upstream socket
f is closed in a finally block; reference function name forward and variable
UPSTREAM and sock so you add logging around the f.sendto/f.recvfrom and
sock.sendto calls and write timestamped messages to /tmp/dns-proxy.log for
observability.
- Around line 133-135: The loop currently spawns an unbounded daemon thread per
incoming packet (sock.recvfrom -> threading.Thread(target=forward, args=(d, a),
daemon=True).start()), which can exhaust resources under load; change to a
bounded worker pool by using a ThreadPoolExecutor or a fixed set of worker
threads reading from a Queue and submit tasks to it (or protect creation with a
Semaphore) so only a limited number of concurrent forward(...) calls run; update
the loop to submit incoming (d, a) to the pool/queue and ensure forward is
compatible with being called by pooled workers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a4be866d-0ab1-4580-9dc4-2f5e14380254

📥 Commits

Reviewing files that changed from the base of the PR and between eb4ba8c and 0c265ee.

📒 Files selected for processing (7)
  • bin/lib/onboard.js
  • bin/lib/platform.js
  • scripts/fix-coredns.sh
  • scripts/setup-dns-proxy.sh
  • scripts/setup.sh
  • test/dns-proxy.test.js
  • test/platform.test.js

@senthilr-nv
senthilr-nv requested a review from kjw3 March 29, 2026 05:24
@kjw3 kjw3 self-assigned this Mar 29, 2026
@kjw3

kjw3 commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

I reran this end to end and the result looks good.

Validation I ran:

  • refreshed to the latest PR tip
  • ran the targeted branch tests
  • then did a fresh nemoclaw onboard --non-interactive on a Linux CPU host with a new sandbox
  • confirmed the new sandbox was created through the normal product path, not a one-off script invocation
  • connected into that fresh sandbox and checked DNS behavior directly

What I saw during onboard:

  • Setting up sandbox DNS proxy...
  • DNS forwarder running
  • resolv.conf -> nameserver 10.200.0.1
  • UDP/53 iptables exception present
  • getent hosts github.com succeeded
  • DNS verification reported 4 passed, 0 failed

What I verified inside the fresh sandbox after onboard:

  • /etc/resolv.conf points to 10.200.0.1
  • getent hosts github.com succeeds
  • node DNS lookup succeeds and returns an IP instead of getaddrinfo EAI_AGAIN

I also did one extra check with Node fetch("https://github.com"). That failed with proxy 403, which is actually a good sign here: the request reached proxy/policy enforcement instead of dying at local DNS resolution.

So from my side this looks like the right fix for #626:

  • DNS is restored inside the isolated sandbox netns
  • normal outbound traffic still appears to stay behind the existing proxy/policy gate

@kjw3
kjw3 merged commit cae0f87 into NVIDIA:main Mar 29, 2026
10 checks passed
laitingsheng pushed a commit that referenced this pull request Apr 2, 2026
… (#1062)

* fix(sandbox): add DNS forwarder so web_fetch resolves hostnames (fixes #626)

The sandbox runs in an isolated network namespace (10.200.0.0/24) where
OpenShell's iptables rules reject all non-proxy traffic including UDP.
This causes getaddrinfo EAI_AGAIN for every outbound DNS lookup,
breaking web_fetch, web_search, and all Node.js HTTP tools.

Fix (three steps in new scripts/setup-dns-proxy.sh):
1. Run a Python UDP DNS forwarder on the pod-side veth gateway
   (10.200.0.1:53), forwarding to the real CoreDNS pod IP
2. Add an iptables rule in the sandbox namespace allowing UDP to
   the gateway on port 53 (the only non-proxy firewall exception)
3. Update the sandbox's /etc/resolv.conf to point to 10.200.0.1

Also broadens the CoreDNS patch (fix-coredns.sh / shouldPatchCoredns)
to run on all Docker-based runtimes, not just Colima — k3s-inside-Docker
has broken DNS forwarding on Linux hosts with systemd-resolved too.

Inspired by PR #732's DNS forwarder approach. Key differences:
- Uses kubectl exec instead of nsenter (fixes PR #732's launch bug)
- Handles the sandbox iptables constraint (all UDP blocked)
- Resolves systemd-resolved upstreams via resolvectl before falling
  back to 8.8.8.8
- Uses grep -F for fixed-string sandbox name matching

Tested on DGX Spark (ARM64): fresh destroy + onboard, getent hosts
and node dns.lookup both resolve from inside the sandbox.

* fix(sandbox): add runtime DNS verification after setup

setup-dns-proxy.sh now verifies all three DNS bridge layers from inside
the sandbox namespace post-deployment: resolv.conf target, iptables UDP
rule, and actual getent hosts resolution. Reports [PASS]/[FAIL] per
check so failures are immediately visible in onboard logs.
lakamsani pushed a commit to lakamsani/NemoClaw that referenced this pull request Apr 4, 2026
…DIA#626) (NVIDIA#1062)

* fix(sandbox): add DNS forwarder so web_fetch resolves hostnames (fixes NVIDIA#626)

The sandbox runs in an isolated network namespace (10.200.0.0/24) where
OpenShell's iptables rules reject all non-proxy traffic including UDP.
This causes getaddrinfo EAI_AGAIN for every outbound DNS lookup,
breaking web_fetch, web_search, and all Node.js HTTP tools.

Fix (three steps in new scripts/setup-dns-proxy.sh):
1. Run a Python UDP DNS forwarder on the pod-side veth gateway
   (10.200.0.1:53), forwarding to the real CoreDNS pod IP
2. Add an iptables rule in the sandbox namespace allowing UDP to
   the gateway on port 53 (the only non-proxy firewall exception)
3. Update the sandbox's /etc/resolv.conf to point to 10.200.0.1

Also broadens the CoreDNS patch (fix-coredns.sh / shouldPatchCoredns)
to run on all Docker-based runtimes, not just Colima — k3s-inside-Docker
has broken DNS forwarding on Linux hosts with systemd-resolved too.

Inspired by PR NVIDIA#732's DNS forwarder approach. Key differences:
- Uses kubectl exec instead of nsenter (fixes PR NVIDIA#732's launch bug)
- Handles the sandbox iptables constraint (all UDP blocked)
- Resolves systemd-resolved upstreams via resolvectl before falling
  back to 8.8.8.8
- Uses grep -F for fixed-string sandbox name matching

Tested on DGX Spark (ARM64): fresh destroy + onboard, getent hosts
and node dns.lookup both resolve from inside the sandbox.

* fix(sandbox): add runtime DNS verification after setup

setup-dns-proxy.sh now verifies all three DNS bridge layers from inside
the sandbox namespace post-deployment: resolv.conf target, iptables UDP
rule, and actual getent hosts resolution. Reports [PASS]/[FAIL] per
check so failures are immediately visible in onboard logs.
gemini2026 pushed a commit to gemini2026/NemoClaw that referenced this pull request Apr 14, 2026
…DIA#626) (NVIDIA#1062)

* fix(sandbox): add DNS forwarder so web_fetch resolves hostnames (fixes NVIDIA#626)

The sandbox runs in an isolated network namespace (10.200.0.0/24) where
OpenShell's iptables rules reject all non-proxy traffic including UDP.
This causes getaddrinfo EAI_AGAIN for every outbound DNS lookup,
breaking web_fetch, web_search, and all Node.js HTTP tools.

Fix (three steps in new scripts/setup-dns-proxy.sh):
1. Run a Python UDP DNS forwarder on the pod-side veth gateway
   (10.200.0.1:53), forwarding to the real CoreDNS pod IP
2. Add an iptables rule in the sandbox namespace allowing UDP to
   the gateway on port 53 (the only non-proxy firewall exception)
3. Update the sandbox's /etc/resolv.conf to point to 10.200.0.1

Also broadens the CoreDNS patch (fix-coredns.sh / shouldPatchCoredns)
to run on all Docker-based runtimes, not just Colima — k3s-inside-Docker
has broken DNS forwarding on Linux hosts with systemd-resolved too.

Inspired by PR NVIDIA#732's DNS forwarder approach. Key differences:
- Uses kubectl exec instead of nsenter (fixes PR NVIDIA#732's launch bug)
- Handles the sandbox iptables constraint (all UDP blocked)
- Resolves systemd-resolved upstreams via resolvectl before falling
  back to 8.8.8.8
- Uses grep -F for fixed-string sandbox name matching

Tested on DGX Spark (ARM64): fresh destroy + onboard, getent hosts
and node dns.lookup both resolve from inside the sandbox.

* fix(sandbox): add runtime DNS verification after setup

setup-dns-proxy.sh now verifies all three DNS bridge layers from inside
the sandbox namespace post-deployment: resolv.conf target, iptables UDP
rule, and actual getent hosts resolution. Reports [PASS]/[FAIL] per
check so failures are immediately visible in onboard logs.
@wscurran wscurran added the bug-fix PR fixes a bug or regression label Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix PR fixes a bug or regression

Projects

None yet

3 participants