package main // What resolver a container really asks (hq issue 348). A container gets its own copy of the machine's // resolver file, made by the runtime when it is created and kept under the runtime's root-only // directory; a container on the host's network gets a copy too. During the 2026-10-09 outage the // control node's route-proxy failed every lookup against 10.10.0.1 alone for twenty minutes while the // machine's file listed both mesh resolvers — and nothing could show what its copy said. This reads the // file as the container sees it, through the runtime's archive endpoint (`docker cp … -`, a tar stream): // no exec into the container, nothing written, and it works where the program has no shell. import ( "archive/tar" "context" "errors" "fmt" "io" "strings" ) // ContainerResolvConf is the path read inside a container. const ContainerResolvConf = "/etc/resolv.conf" // HostResolvConf is the machine's own resolver file, read for comparison. const HostResolvConf = "/etc/resolv.conf" // ResolvConf is a resolver file, parsed. type ResolvConf struct { Nameservers []string `json:"nameservers"` Search []string `json:"search,omitempty"` Options []string `json:"options,omitempty"` Raw string `json:"raw"` } // parseResolvConf reads nameserver, search (and domain) and options lines; comments are kept in Raw only. func parseResolvConf(text string) ResolvConf { out := ResolvConf{Nameservers: []string{}, Raw: text} for _, line := range strings.Split(text, "\n") { f := strings.Fields(line) if len(f) == 0 || strings.HasPrefix(f[0], "#") || strings.HasPrefix(f[0], ";") { continue } switch f[0] { case "nameserver": if len(f) > 1 { out.Nameservers = append(out.Nameservers, f[1]) } case "search", "domain": out.Search = append(out.Search, f[1:]...) case "options": out.Options = append(out.Options, f[1:]...) } } return out } // fileFromArchive is the one regular file a `docker cp : -` stream carries. func fileFromArchive(stream string) (string, error) { tr := tar.NewReader(strings.NewReader(stream)) for { h, err := tr.Next() if errors.Is(err, io.EOF) { return "", errors.New("the runtime's archive held no file") } if err != nil { return "", fmt.Errorf("the runtime's archive could not be read: %w", err) } switch h.Typeflag { case tar.TypeReg: body, err := io.ReadAll(io.LimitReader(tr, 64<<10)) if err != nil { return "", fmt.Errorf("the runtime's archive could not be read: %w", err) } return string(body), nil case tar.TypeSymlink: return "", fmt.Errorf("inside the container %s is a link to %s, which the runtime's archive does not follow", h.Name, h.Linkname) } } } // ResolvConfOf is a container's resolver file as it sees it, beside the machine's own, and whether they list // the same resolvers in the same order. func (c *Client) ResolvConfOf(ctx context.Context, ref string) (map[string]any, error) { ref, err := Ref(ref) if err != nil { return nil, err } answer := map[string]any{"container": ref, "path": ContainerResolvConf} stream, err := c.docker(ctx, "cp", ref+":"+ContainerResolvConf, "-") if err != nil { return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err) } text, err := fileFromArchive(stream) if err != nil { return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err) } inside := parseResolvConf(text) answer["resolv_conf"] = inside if raw, err := c.ReadFile(HostResolvConf); err != nil { answer["machine"] = "the machine's " + HostResolvConf + " could not be read for comparison: " + err.Error() } else { host := parseResolvConf(string(raw)) answer["machine_nameservers"] = host.Nameservers same := strings.Join(host.Nameservers, " ") == strings.Join(inside.Nameservers, " ") answer["same_as_machine"] = same if !same { answer["note"] = "the container asks other resolvers than the machine lists: its copy was made when it was created, " + "or the runtime's dns setting chose them; a container is given the machine's current file only when it is created again" } } return answer, nil }