Skip to content

DNS not configured on host when /etc/resolv.conf is bind-mounted into container #1297

Description

@DylanBergmann2502

Summary

When running netclient inside a Docker container with network_mode: host and /etc/resolv.conf bind-mounted from a systemd-resolved host, netclient fails to detect the systemd environment and falls back to fileManager instead of systemdStubManager. This causes DNS for the netmaker interface to never be registered with systemd-resolved, so VPN hostnames (e.g. *.nm.internal) resolve correctly inside the container but not from the host shell.

Environment

  • netclient: v1.5.1
  • OS: Ubuntu 22.04 (systemd-resolved active, stub mode)
  • Deployment: Docker container, network_mode: host, privileged: true, pid: host
  • /etc/resolv.conf bind-mounted into container: -v /etc/resolv.conf:/etc/resolv.conf:rw

Steps to Reproduce

  1. Run netclient in a Docker container with the following compose config:
services:
  netclient:
    network_mode: host
    privileged: true
    pid: host
    volumes:
      - /etc/resolv.conf:/etc/resolv.conf:rw
      - /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
  1. The host's /etc/resolv.conf is a symlink to /run/systemd/resolve/stub-resolv.conf (standard Ubuntu 22.04 setup).

  2. Netclient joins a network successfully. VPN interface comes up, WireGuard peers establish handshakes.

  3. From inside the container: VPN hostnames resolve correctly.

  4. From the host shell: VPN IP addresses are reachable (ping 100.64.0.x works), but VPN hostnames fail (nslookup *.nm.internal → NXDOMAIN).

Root Cause

getResolvconfFlavor() in dns/config/manager_linux.go detects the systemd environment exclusively by checking whether /etc/resolv.conf is a symlink:

stat, err := os.Lstat("/etc/resolv.conf")
// ...
if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
    target, err := os.Readlink("/etc/resolv.conf")
    if strings.HasSuffix(target, "/run/systemd/resolve/stub-resolv.conf") {
        return systemdStub, nil
    }
    // ...
}
// Falls through to resolvconf / file detection

When Docker bind-mounts /etc/resolv.conf, it resolves the symlink on the host and mounts the target file directly. Inside the container, /etc/resolv.conf appears as a plain regular file — the symlink is gone. The symlink check fails, systemd is never detected, and netclient falls back to fileManager.

With fileManager, netclient writes nameserver <vpn_ip> and search nm.internal directly into /etc/resolv.conf. Since this file is the bind-mounted target of the host's symlink (/run/systemd/resolve/stub-resolv.conf), systemd-resolved owns and periodically rewrites it — clobbering netclient's changes on every systemd-resolved restart. The result is that DNS for the VPN interface works intermittently, or only immediately after the container starts.

The correct path — systemdStubManager — would call resolvectl dns netmaker <vpn_ip> and resolvectl domain netmaker ~nm.internal nm.internal, which registers DNS persistently with systemd-resolved and survives restarts.

Confirmed observations:

  • Inside the container: stat /etc/resolv.conf → regular file, Links: 0 (bind-mounted target, not a symlink)
  • resolvectl status on host → netmaker interface shows Current Scopes: none (no DNS registered)
  • resolvectl binary inside the container works and communicates with the host's systemd-resolved via the dbus socket
  • After container restart, hostname resolution works briefly, then breaks when systemd-resolved next rewrites the stub file

Why the Bind Mount Is Needed

The /etc/resolv.conf bind mount is the standard and necessary deployment pattern for network_mode: host containers that embed netclient. Without it, netclient cannot write DNS config to the host at all — only the container's own ephemeral resolv.conf is modified, which has no effect on the host shell.

The upstream netclient Docker image (gravitl/netclient) avoids this issue because it is Alpine-based and installs openresolv. Inside that container, resolvconf is present and getResolvconfFlavor() detects openresolv — a code path that does not depend on the symlink check at all. This bug only surfaces when netclient is embedded in a non-Alpine container image (e.g. Debian/Ubuntu-based) on a systemd-resolved host, which is a valid and increasingly common deployment pattern.

Suggested Fix

Add a fallback check in getResolvconfFlavor(): if /etc/resolv.conf is not a symlink, also check whether systemd-resolved is actively running before falling through to file/resolvconf detection.

func getResolvconfFlavor() (resolvconfFlavor, error) {
    stat, err := os.Lstat("/etc/resolv.conf")
    if err != nil {
        return unknown, err
    }

    if stat.Mode()&os.ModeSymlink == os.ModeSymlink {
        target, err := os.Readlink("/etc/resolv.conf")
        if err != nil {
            return unknown, err
        }

        if strings.HasSuffix(target, "/run/systemd/resolve/stub-resolv.conf") {
            return systemdStub, nil
        } else if strings.HasSuffix(target, "/run/systemd/resolve/resolv.conf") {
            return systemdUplink, nil
        }
    }

    // Fallback: /etc/resolv.conf is not a symlink (e.g. bind-mounted into a container from a
    // systemd-resolved host — Docker resolves the symlink and mounts the target file directly).
    // Check if systemd-resolved is running before falling through to resolvconf/file detection.
    if exec.Command("systemctl", "is-active", "--quiet", "systemd-resolved").Run() == nil {
        if isSystemdStubMode() {
            return systemdStub, nil
        }
        return systemdUplink, nil
    }

    // ... existing resolvconf / file detection unchanged
}

func isSystemdStubMode() bool {
    // Read /etc/resolv.conf directly — works on bare hosts (symlink target) and inside
    // containers where the file is bind-mounted from the host. Do NOT read
    // /run/systemd/resolve/stub-resolv.conf: that path may exist inside the container
    // image itself (baked in at build time) and does not reflect the host's DNS mode.
    data, err := os.ReadFile("/etc/resolv.conf")
    if err != nil {
        return false
    }
    return strings.Contains(string(data), "127.0.0.53")
}

This change is backward-compatible: the symlink check still runs first (no behavior change on non-containerized hosts), and the systemctl fallback only fires when the symlink is absent.

Additional Context

This issue affects any deployment pattern where netclient runs inside a container on a systemd-resolved host with a bind-mounted /etc/resolv.conf — which is the necessary approach for network_mode: host containerized deployments where the host's DNS must be updated.

The release.md for v1.5.1 notes a related DNS limitation:

systemd-resolved DNS limitation: On systems using systemd-resolved in uplink mode, only the first 3 entries in resolv.conf are honored. Stub mode is recommended.

This bug is distinct from that limitation: it affects stub mode as well, and prevents systemd-resolved from being configured at all rather than being a capacity constraint.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions