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.
112 lines
4.1 KiB
Go
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
|
|
}
|