Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,8 @@ jobs:
QUICKSAND_AUTO_INSTALL: "1"
run: |
uv venv --seed --clear
source .venv/bin/activate
uv sync --frozen
uvr jobs build
uv run --no-sync uvr jobs build
- id: upload
uses: actions/upload-artifact@v4
with:
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ All notable changes to the quicksand project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [quicksand-core v0.11.14, quicksand-qemu v0.5.11] - 2026-06-22

Stable release of the macOS VPN/split-DNS fix (previously alpha).

### Added
- **quicksand-core:** `SandboxConfig.host_dns_proxy` and a host-side DNS proxy (`quicksand_core.host.dns_proxy.HostDnsProxy`) that resolves guest DNS via the host OS resolver (`getaddrinfo`). Auto-enabled on macOS hosts with `network_mode=FULL`. Fixes intermittent — often total — guest DNS failures on macOS while connected to a VPN, where stock libslirp forwards DNS to a single libresolv-picked resolver and ignores the system's scoped/split-DNS configuration.
- **quicksand-qemu:** The bundled macOS libslirp is rebuilt from source with a patch that redirects guest DNS to `$QUICKSAND_DNS_PROXY` (the host proxy above). Inert unless that variable is set; Linux and Windows wheels are unchanged.

### Fixed
- **quicksand-core:** Silenced dnslib's per-request/reply logging, which flooded host logs (even at DEBUG) once a guest did any DNS. Only error logging remains, routed to the `quicksand.dns_proxy` logger at DEBUG.

## [quicksand-core v0.11.14a0, quicksand-qemu v0.5.11a0] - 2026-06-04

Alpha release fixing macOS guest DNS failures under a VPN.

### Added
- **quicksand-core:** `SandboxConfig.host_dns_proxy` and a host-side DNS proxy (`quicksand_core.host.dns_proxy.HostDnsProxy`) that resolves guest DNS via the host OS resolver (`getaddrinfo`). Auto-enabled on macOS hosts with `network_mode=FULL`. Fixes intermittent — often total — guest DNS failures on macOS while connected to a VPN, where stock libslirp forwards DNS to a single libresolv-picked resolver and ignores the system's scoped/split-DNS configuration.
- **quicksand-qemu:** The bundled macOS libslirp is rebuilt from source with a patch that redirects guest DNS to `$QUICKSAND_DNS_PROXY` (the host proxy above). Inert unless that variable is set; Linux and Windows wheels are unchanged.

## [v0.11.14] - 2026-05-14

### Fixed
Expand Down
10 changes: 8 additions & 2 deletions packages/quicksand-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
[project]
name = "quicksand-core"
version = "0.11.14.dev0"
version = "0.11.15.dev0"
description = "Core sandbox functionality for the quicksand VM harness."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = ["httpx>=0.27,<1.0", "pydantic>=2.0", "quicksand-smb>=0.4.9,<0.5.0"]
dependencies = [
"httpx>=0.27,<1.0",
"pydantic>=2.0",
"quicksand-smb>=0.4.9,<0.5.0",
# Host-side DNS proxy for macOS VPN/split-DNS resolution (see host/dns_proxy.py).
"dnslib>=0.9.24; sys_platform == 'darwin'",
]

[build-system]
requires = ["hatchling"]
Expand Down
11 changes: 11 additions & 0 deletions packages/quicksand-core/quicksand_core/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ class EnvironmentVariables:

QEMU_MODULE_DIR = "QEMU_MODULE_DIR"

# Read by the bundled (patched) libslirp on macOS: when set to a port
# number, guest DNS forwarded to 10.0.2.3 is redirected to 127.0.0.1:<port>
# where the host DNS proxy answers via the OS resolver. See host/dns_proxy.py.
DNS_PROXY = "QUICKSAND_DNS_PROXY"


# =============================================================================
# Guest Commands
Expand Down Expand Up @@ -587,6 +592,11 @@ class SandboxConfig(BaseModel):
accel: Accelerator | Literal["auto"] | None = "auto"
disk_size: str | None = None
enable_display: bool = False
host_dns_proxy: bool | None = None
"""Route guest DNS through a host-side proxy that resolves via the OS
resolver. Fixes macOS VPN/split-DNS failures (see host/dns_proxy.py).
``None`` (default) auto-enables on macOS hosts with ``network_mode=FULL``;
``True``/``False`` force it on/off. No effect on Linux/Windows hosts."""

@field_validator("memory")
@classmethod
Expand Down Expand Up @@ -623,6 +633,7 @@ class SandboxConfigParams(TypedDict, total=False):
accel: Accelerator | Literal["auto"] | None
disk_size: str | None
enable_display: bool
host_dns_proxy: bool | None


_td_keys = set(SandboxConfigParams.__annotations__) | {"image"}
Expand Down
125 changes: 125 additions & 0 deletions packages/quicksand-core/quicksand_core/host/dns_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Host-side DNS proxy for macOS VPN/split-DNS resolution.

On macOS, libslirp discovers a single upstream nameserver via libresolv and
forwards all guest DNS to it from an unbound socket. It does not honor the
system's scoped resolvers (``scutil --dns``), so when a VPN is active the
guest's DNS fails — often entirely — because the chosen resolver is reachable
only through the VPN interface, or the wrong scope is picked. Toggling the VPN
just reshuffles which resolver gets picked, which is the intermittent symptom
users hit.

The bundled libslirp is patched so that, when ``$QUICKSAND_DNS_PROXY`` names a
port, it redirects the DNS it forwards (guest -> 10.0.2.3) to
``127.0.0.1:<port>``. This module is the host-side endpoint of that redirect: a
tiny DNS server that answers A/AAAA via :func:`socket.getaddrinfo`, the same OS
resolver every working macOS app uses — so it honors scoped/VPN/split-DNS
automatically.

The guest's slirp network is IPv4-only, so AAAA answers are intentionally left
empty (macOS returns v4-mapped addresses for AAAA, which are not real IPv6);
clients fall back to A.
"""

from __future__ import annotations

import logging
import socket
from typing import TYPE_CHECKING

from ..utils import find_free_port

if TYPE_CHECKING:
from dnslib import DNSRecord
from dnslib.server import DNSHandler

logger = logging.getLogger("quicksand.dns_proxy")


class _GetAddrInfoResolver:
"""dnslib resolver that answers A/AAAA via the host's system resolver."""

def resolve(self, request: DNSRecord, handler: DNSHandler) -> DNSRecord:
from dnslib import AAAA, QTYPE, RR, A

reply = request.reply()
qtype = QTYPE[request.q.qtype]
name = str(request.q.qname).rstrip(".")

# Only A/AAAA are answered via getaddrinfo. Other types get an empty
# NOERROR so the guest's resolver falls back cleanly instead of hanging.
if qtype not in ("A", "AAAA"):
return reply

family = socket.AF_INET if qtype == "A" else socket.AF_INET6
try:
infos = socket.getaddrinfo(name, None, family, socket.SOCK_STREAM)
except socket.gaierror:
return reply # name does not resolve — empty answer

seen: set[str] = set()
for info in infos:
fam = info[0]
ip = str(info[4][0]).split("%")[0] # strip any IPv6 scope id
if ip in seen:
continue
try:
if qtype == "A" and fam == socket.AF_INET:
rdata = A(ip)
elif qtype == "AAAA" and fam == socket.AF_INET6:
# macOS returns v4-mapped (::ffff:1.2.3.4) for AAAA; those
# are not real IPv6 and the guest network is IPv4-only.
if ip.startswith("::ffff:") or "." in ip:
continue
rdata = AAAA(ip)
else:
continue
except Exception:
logger.debug("Skipping unparseable address %s for %s", ip, name)
continue
seen.add(ip)
reply.add_answer(RR(request.q.qname, request.q.qtype, rdata=rdata, ttl=60))

return reply


class HostDnsProxy:
"""A loopback DNS server that resolves via the host OS resolver.

Lifecycle mirrors :class:`~quicksand_core.host.smb.SMBServer`: construct,
:meth:`start`, then :meth:`stop`. The chosen :attr:`port` is passed to the
patched libslirp via the ``QUICKSAND_DNS_PROXY`` environment variable.
"""

def __init__(self) -> None:
self._port = find_free_port()
self._servers: list = []

@property
def port(self) -> int:
return self._port

def start(self) -> None:
from dnslib.server import DNSLogger, DNSServer

resolver = _GetAddrInfoResolver()
# dnslib logs every request/reply, which floods host logs once a guest
# does any DNS (and shows even at DEBUG). Disable those chatty hooks
# entirely and keep only errors, routed to our logger at DEBUG
# (prefix=False drops dnslib's own timestamp since the logging framework
# adds one).
dns_logger = DNSLogger(log="-request,-reply,-truncated", prefix=False, logf=logger.debug)
for tcp in (False, True):
server = DNSServer(
resolver, port=self._port, address="127.0.0.1", tcp=tcp, logger=dns_logger
)
server.start_thread()
self._servers.append(server)
logger.info("Host DNS proxy listening on 127.0.0.1:%d (udp+tcp)", self._port)

def stop(self) -> None:
for server in self._servers:
try:
server.stop()
except Exception:
logger.debug("Error stopping DNS proxy server", exc_info=True)
self._servers = []
42 changes: 42 additions & 0 deletions packages/quicksand-core/quicksand_core/sandbox/_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import os
import secrets
import shutil
import sys
import tempfile
import warnings
from collections.abc import Callable
Expand Down Expand Up @@ -147,6 +148,12 @@ def _launch_process(self) -> None:
if self.config.network_mode in (NetworkMode.MOUNTS_ONLY, NetworkMode.FULL):
self._ensure_smb_server()

# Start the host DNS proxy (macOS + FULL networking). The bundled
# libslirp is patched to redirect guest DNS to QUICKSAND_DNS_PROXY,
# which this proxy answers via the OS resolver — fixing VPN/split-DNS
# failures that the stock libslirp hits on macOS.
self._maybe_start_dns_proxy()

cmd = self._build_vm_command()
logger.debug(
"Starting VM: port=%s, image=%s",
Expand All @@ -163,10 +170,37 @@ def _launch_process(self) -> None:
):
env[EnvironmentVariables.QEMU_MODULE_DIR] = str(self._runtime_info.module_dir)

if self._dns_proxy is not None:
env[EnvironmentVariables.DNS_PROXY] = str(self._dns_proxy.port)

assert self._temp_dir is not None
console_log_path = self._temp_dir / FilePatterns.CONSOLE_LOG
self._process_manager.start(cmd, env, console_log_path)

def _maybe_start_dns_proxy(self) -> None:
"""Start the host DNS proxy when appropriate.

Auto-enabled on macOS hosts with ``network_mode=FULL`` (the only mode
where the guest reaches the internet). ``config.host_dns_proxy`` forces
it on or off. The proxy is the host-side endpoint of the patched
libslirp's DNS redirect; see :mod:`quicksand_core.host.dns_proxy`.
"""
override = self.config.host_dns_proxy
if override is False:
return
if sys.platform != "darwin":
if override is True:
logger.warning("host_dns_proxy is only supported on macOS hosts; ignoring")
return
if override is None and self.config.network_mode is not NetworkMode.FULL:
return

from ..host.dns_proxy import HostDnsProxy

proxy = HostDnsProxy()
proxy.start()
self._dns_proxy = proxy

async def _connect_to_guest_agent(self) -> None:
"""Wait for the guest agent; clean up and re-raise on failure."""
try:
Expand Down Expand Up @@ -360,6 +394,14 @@ async def _cleanup(self) -> None:

cleanup_errors.extend(await self._cleanup_mounts())

if self._dns_proxy is not None:
try:
self._dns_proxy.stop()
except Exception as e:
cleanup_errors.append(("DNS proxy", e))
logger.warning("Failed to stop DNS proxy: %s", e)
self._dns_proxy = None

if self._agent_client:
try:
await self._agent_client.close()
Expand Down
2 changes: 2 additions & 0 deletions packages/quicksand-core/quicksand_core/sandbox/_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SaveManifest,
Timeouts,
)
from ..host.dns_proxy import HostDnsProxy
from ..host.smb import SMBServer
from ..qemu import OverlayManager, VMProcessManager
from ..qemu.platform import RuntimeInfo
Expand Down Expand Up @@ -43,6 +44,7 @@ class _SandboxProtocol(Protocol):
_temp_dir: Path | None
_process_manager: VMProcessManager
_smb_server: SMBServer | None
_dns_proxy: HostDnsProxy | None
_dynamic_mounts: list[MountHandle]
_progress_callback: Callable[[str, int, int], None] | None
_overlay_manager: OverlayManager | None
Expand Down
1 change: 1 addition & 0 deletions packages/quicksand-core/quicksand_core/sandbox/_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def __init__(
self._temp_dir: Path | None = None
self._process_manager = VMProcessManager()
self._smb_server: Any = None
self._dns_proxy: Any = None
self._dynamic_mounts: list = []
self._agent_client: AgentClient | None = None
self._agent_port: int | None = None
Expand Down
Loading