Files
mesh-catalog/modules/docker/cmd/docker-tools/resolv.go
T
jochen 2fde82993d docker: read the resolver file a container really uses (hq issue 348)
On 2026-10-09 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; its copy lives under the runtime's
root-only directory and no tool read it. docker_resolv_conf reads /etc/resolv.conf as the container
sees it through the runtime's archive (docker cp, no exec), parses it, and says whether it lists the
same resolvers as the machine.
2026-10-09 13:33:23 +02:00

112 lines
4.1 KiB
Go

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 <ref>:<path> -` 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
}