Files
moby/daemon/libnetwork/internal/resolvconf/resolvconf.go
Sebastiaan van Stijn 07e2a782c7 libnet/internal/resolvconf: Parse: improve error message
When attempting to read a (malformed) resolv.conf with a very long line,
a obscure error would be produced that didn't provide much context to
identify the problem;

    Handler for POST /v1.51/containers/mariadb11/start returned error: bufio.Scanner: token too long

This patch adds some additional error-handling to detect this situation,
and includes the filename of the resolv.conf to help the user locating
the file that failed to be parsed.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2026-01-06 14:43:51 +01:00

542 lines
15 KiB
Go

// Package resolvconf is used to generate a container's /etc/resolv.conf file.
//
// Constructor Load and Parse read a resolv.conf file from the filesystem or
// a reader respectively, and return a ResolvConf object.
//
// The ResolvConf object can then be updated with overrides for nameserver,
// search domains, and DNS options.
//
// ResolvConf can then be transformed to make it suitable for legacy networking,
// a network with an internal nameserver, or used as-is for host networking.
//
// This package includes methods to write the file for the container, along with
// a hash that can be used to detect modifications made by the user to avoid
// overwriting those updates.
package resolvconf
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"io/fs"
"net/netip"
"os"
"slices"
"strconv"
"strings"
"github.com/containerd/log"
"github.com/moby/sys/atomicwriter"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
)
// Fallback nameservers, to use if none can be obtained from the host or command
// line options.
var (
defaultIPv4NSs = []netip.Addr{
netip.MustParseAddr("8.8.8.8"),
netip.MustParseAddr("8.8.4.4"),
}
defaultIPv6NSs = []netip.Addr{
netip.MustParseAddr("2001:4860:4860::8888"),
netip.MustParseAddr("2001:4860:4860::8844"),
}
)
// ResolvConf represents a resolv.conf file. It can be constructed by
// reading a resolv.conf file, using method Parse().
type ResolvConf struct {
nameServers []netip.Addr
search []string
options []string
other []string // Unrecognised directives from the host's file, if any.
md metadata
}
// ExtDNSEntry represents a nameserver address that was removed from the
// container's resolv.conf when it was transformed by TransformForIntNS(). These
// are addresses read from the host's file, or applied via an override ('--dns').
type ExtDNSEntry struct {
Addr netip.Addr
HostLoopback bool // The address is loopback, in the host's namespace.
}
func (ed ExtDNSEntry) String() string {
if ed.HostLoopback {
return "host(" + ed.Addr.String() + ")"
}
return ed.Addr.String()
}
// metadata is used to track where components of the generated file have come
// from, in order to generate comments in the file for debug/info. Struct members
// are exported for use by 'text/template'.
type metadata struct {
SourcePath string
Header string
NSOverride bool
SearchOverride bool
OptionsOverride bool
NDotsFrom string
Transform string
InvalidNSs []string
ExtNameServers []ExtDNSEntry
Warnings []string
}
// Load opens a file at path and parses it as a resolv.conf file.
// On error, the returned ResolvConf will be zero-valued.
func Load(path string) (ResolvConf, error) {
f, err := os.Open(path)
if err != nil {
return ResolvConf{}, err
}
defer f.Close()
return Parse(f, path)
}
// Parse parses a resolv.conf file from reader.
// path is optional if reader is an *os.File.
// On error, the returned ResolvConf will be zero-valued.
func Parse(reader io.Reader, path string) (ResolvConf, error) {
var rc ResolvConf
rc.md.SourcePath = path
if path == "" {
if namer, ok := reader.(interface{ Name() string }); ok {
rc.md.SourcePath = namer.Name()
}
}
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
rc.processLine(scanner.Text())
}
if err := scanner.Err(); err != nil {
src := rc.md.SourcePath
if errors.Is(err, bufio.ErrTooLong) {
return ResolvConf{}, systemError{
fmt.Errorf("failed to parse resolv.conf from %s: line too long (exceeds %d)", src, bufio.MaxScanTokenSize),
}
}
return ResolvConf{}, systemError{
fmt.Errorf("failed to parse resolv.conf from %s: %w", src, err),
}
}
if _, ok := rc.Option("ndots"); ok {
rc.md.NDotsFrom = "host"
}
return rc, nil
}
// SetHeader sets the content to be included verbatim at the top of the
// generated resolv.conf file. No formatting or checking is done on the
// string. It must be valid resolv.conf syntax. (Comments must have '#'
// or ';' in the first column of each line).
//
// For example:
//
// SetHeader("# My resolv.conf\n# This file was generated.")
func (rc *ResolvConf) SetHeader(c string) {
rc.md.Header = c
}
// NameServers returns addresses used in nameserver directives.
func (rc *ResolvConf) NameServers() []netip.Addr {
return slices.Clone(rc.nameServers)
}
// OverrideNameServers replaces the current set of nameservers.
func (rc *ResolvConf) OverrideNameServers(nameServers []netip.Addr) {
rc.nameServers = nameServers
rc.md.NSOverride = true
}
// Search returns the current DNS search domains.
func (rc *ResolvConf) Search() []string {
return slices.Clone(rc.search)
}
// OverrideSearch replaces the current DNS search domains.
func (rc *ResolvConf) OverrideSearch(search []string) {
var filtered []string
for _, s := range search {
if s != "." {
filtered = append(filtered, s)
}
}
rc.search = filtered
rc.md.SearchOverride = true
}
// Options returns the current options.
func (rc *ResolvConf) Options() []string {
return slices.Clone(rc.options)
}
// Option finds the last option named search, and returns (value, true) if
// found, else ("", false). Options are treated as "name:value", where the
// ":value" may be omitted.
//
// For example, for "ndots:1 edns0":
//
// Option("ndots") -> ("1", true)
// Option("edns0") -> ("", true)
func (rc *ResolvConf) Option(search string) (string, bool) {
for i := len(rc.options) - 1; i >= 0; i-- {
k, v, _ := strings.Cut(rc.options[i], ":")
if k == search {
return v, true
}
}
return "", false
}
// OverrideOptions replaces the current DNS options.
func (rc *ResolvConf) OverrideOptions(options []string) {
rc.options = slices.Clone(options)
rc.md.NDotsFrom = ""
if _, exists := rc.Option("ndots"); exists {
rc.md.NDotsFrom = "override"
}
rc.md.OptionsOverride = true
}
// AddOption adds a single DNS option.
func (rc *ResolvConf) AddOption(option string) {
if len(option) > 6 && option[:6] == "ndots:" {
rc.md.NDotsFrom = "internal"
}
rc.options = append(rc.options, option)
}
// TransformForLegacyNw makes sure the resolv.conf file will be suitable for
// use in a legacy network (one that has no internal resolver).
// - Remove loopback addresses inherited from the host's resolv.conf, because
// they'll only work in the host's namespace.
// - Remove IPv6 addresses if !ipv6.
// - Add default nameservers if there are no addresses left.
func (rc *ResolvConf) TransformForLegacyNw(ipv6 bool) {
rc.md.Transform = "legacy"
if rc.md.NSOverride {
return
}
var filtered []netip.Addr
for _, addr := range rc.nameServers {
if !addr.IsLoopback() && (!addr.Is6() || ipv6) {
filtered = append(filtered, addr)
}
}
rc.nameServers = filtered
if len(rc.nameServers) == 0 {
log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers")
rc.nameServers = defaultNSAddrs(ipv6)
rc.md.Warnings = append(rc.md.Warnings, "Used default nameservers.")
}
}
// TransformForIntNS makes sure the resolv.conf file will be suitable for
// use in a network sandbox that has an internal DNS resolver.
// - Add internalNS as a nameserver.
// - Remove other nameservers, stashing them as ExtNameServers for the
// internal resolver to use.
// - Mark ExtNameServers that must be accessed from the host namespace.
// - If no ExtNameServer addresses are found, use the defaults.
// - Ensure there's an 'options' value for each entry in reqdOptions. If the
// option includes a ':', and an option with a matching prefix exists, it
// is not modified.
func (rc *ResolvConf) TransformForIntNS(
internalNS netip.Addr,
reqdOptions []string,
) ([]ExtDNSEntry, error) {
// Add each of the nameservers read from the host's /etc/hosts or supplied as an
// override to ExtNameServers, for the internal resolver to talk to. Addresses
// read from host config should be accessed from the host's network namespace
// (HostLoopback=true). Addresses supplied as overrides are accessed from the
// container's namespace.
rc.md.ExtNameServers = nil
for _, addr := range rc.nameServers {
rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{
Addr: addr,
HostLoopback: !rc.md.NSOverride,
})
}
// The transformed config only lists the internal nameserver.
rc.nameServers = []netip.Addr{internalNS}
// For each option required by the nameserver, add it if not already present. If
// the option is already present, don't override it. Apart from ndots - if the
// ndots value is invalid and an ndots option is required, replace the existing
// value.
for _, opt := range reqdOptions {
optName, _, _ := strings.Cut(opt, ":")
if optName == "ndots" {
rc.options = removeInvalidNDots(rc.options)
// No need to update rc.md.NDotsFrom, if there is no ndots option remaining,
// it'll be set to "internal" when the required value is added.
}
if _, exists := rc.Option(optName); !exists {
rc.AddOption(opt)
}
}
rc.md.Transform = "internal resolver"
if len(rc.md.ExtNameServers) == 0 {
rc.md.Warnings = append(rc.md.Warnings, "NO EXTERNAL NAMESERVERS DEFINED")
}
return slices.Clone(rc.md.ExtNameServers), nil
}
// Generate returns content suitable for writing to a resolv.conf file. If comments
// is true, the file will include header information if supplied, and a trailing
// comment that describes how the file was constructed and lists external resolvers.
func (rc *ResolvConf) Generate(comments bool) ([]byte, error) {
var b bytes.Buffer
b.Grow(512) // estimated size for a regular resolv.conf we produce.
if comments && rc.md.Header != "" {
b.WriteString(rc.md.Header + "\n")
b.WriteByte('\n')
}
for _, ns := range rc.nameServers {
b.WriteString("nameserver ")
b.WriteString(ns.String())
b.WriteByte('\n')
}
if len(rc.search) > 0 {
b.WriteString("search ")
for i, s := range rc.search {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(s)
}
b.WriteByte('\n')
}
if len(rc.options) > 0 {
b.WriteString("options ")
for i, s := range rc.options {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(s)
}
b.WriteByte('\n')
}
for _, o := range rc.other {
b.WriteString(o)
b.WriteByte('\n')
}
if comments {
b.WriteByte('\n')
b.WriteString("# Based on host file: '" + rc.md.SourcePath + "'")
if rc.md.Transform != "" {
b.WriteString(" (" + rc.md.Transform + ")")
}
b.WriteByte('\n')
for _, w := range rc.md.Warnings {
b.WriteString("# ")
b.WriteString(w)
b.WriteByte('\n')
}
if len(rc.md.ExtNameServers) > 0 {
b.WriteString("# ExtServers: [")
for i, ext := range rc.md.ExtNameServers {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(ext.String())
}
b.WriteByte(']')
b.WriteByte('\n')
}
if len(rc.md.InvalidNSs) > 0 {
b.WriteString("# Invalid nameservers: [")
for i, ext := range rc.md.InvalidNSs {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(ext)
}
b.WriteByte(']')
b.WriteByte('\n')
}
b.WriteString("# Overrides: [")
var overrides int
if rc.md.NSOverride {
b.WriteString("nameservers")
overrides++
}
if rc.md.SearchOverride {
if overrides > 0 {
b.WriteByte(' ')
}
b.WriteString("search")
overrides++
}
if rc.md.OptionsOverride {
if overrides > 0 {
b.WriteByte(' ')
}
b.WriteString("options")
}
b.WriteByte(']')
b.WriteByte('\n')
if rc.md.NDotsFrom != "" {
b.WriteString("# Option ndots from: " + rc.md.NDotsFrom + "\n")
}
}
return b.Bytes(), nil
}
// WriteFile generates content and writes it to path. If hashPath is non-zero, it
// also writes a file containing a hash of the content, to enable UserModified()
// to determine whether the file has been modified.
func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error {
content, err := rc.Generate(true)
if err != nil {
return err
}
// Write the resolv.conf file - it's bind-mounted into the container, so can't
// move a temp file into place, just have to truncate and write it.
if err := os.WriteFile(path, content, perm); err != nil {
return systemError{err}
}
// Write the hash file.
if hashPath != "" {
hashFile, err := atomicwriter.New(hashPath, perm)
if err != nil {
return systemError{err}
}
defer hashFile.Close()
if _, err = hashFile.Write([]byte(digest.FromBytes(content))); err != nil {
return err
}
}
return nil
}
// UserModified can be used to determine whether the resolv.conf file has been
// modified since it was generated. It returns false with no error if the file
// matches the hash, true with no error if the file no longer matches the hash,
// and false with an error if the result cannot be determined.
func UserModified(rcPath, rcHashPath string) (bool, error) {
currRCHash, err := os.ReadFile(rcHashPath)
if err != nil {
// If the hash file doesn't exist, can only assume it hasn't been written
// yet (so, the user hasn't modified the file it hashes).
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, errors.Wrapf(err, "failed to read hash file %s", rcHashPath)
}
expected, err := digest.Parse(string(currRCHash))
if err != nil {
return false, errors.Wrapf(err, "failed to parse hash file %s", rcHashPath)
}
v := expected.Verifier()
currRC, err := os.Open(rcPath)
if err != nil {
return false, errors.Wrapf(err, "failed to open %s to check for modifications", rcPath)
}
defer currRC.Close()
if _, err := io.Copy(v, currRC); err != nil {
return false, errors.Wrapf(err, "failed to hash %s to check for modifications", rcPath)
}
return !v.Verified(), nil
}
func (rc *ResolvConf) processLine(line string) {
// Strip blank lines and comments.
if line == "" || line[0] == '#' || line[0] == ';' {
return
}
fields := strings.Fields(line)
if len(fields) == 0 {
return
}
switch fields[0] {
case "nameserver":
if len(fields) < 2 {
return
}
if addr, err := netip.ParseAddr(fields[1]); err != nil {
rc.md.InvalidNSs = append(rc.md.InvalidNSs, fields[1])
} else {
rc.nameServers = append(rc.nameServers, addr)
}
case "domain":
// 'domain' is an obsolete name for 'search'.
fallthrough
case "search":
if len(fields) < 2 {
return
}
// Only the last 'search' directive is used.
rc.search = fields[1:]
case "options":
if len(fields) < 2 {
return
}
// Accumulate options.
rc.options = append(rc.options, fields[1:]...)
default:
// Copy anything that's not a recognised directive.
rc.other = append(rc.other, line)
}
}
func defaultNSAddrs(ipv6 bool) []netip.Addr {
var addrs []netip.Addr
addrs = append(addrs, defaultIPv4NSs...)
if ipv6 {
addrs = append(addrs, defaultIPv6NSs...)
}
return addrs
}
// removeInvalidNDots filters ill-formed "ndots" settings from options.
// The backing array of the options slice is reused.
func removeInvalidNDots(options []string) []string {
n := 0
for _, opt := range options {
k, v, hasSep := strings.Cut(opt, ":")
if k == "ndots" {
if !hasSep || v == "" {
continue
}
ndots, err := strconv.Atoi(v)
if err != nil || ndots < 0 {
continue
}
}
options[n] = opt
n++
}
clear(options[n:]) // Zero out the obsolete elements, for GC.
return options[:n]
}
// systemError implements [github.com/moby/moby/v2/errdefs.ErrSystem].
type systemError struct{ error }
func (systemError) System() {}
func (e systemError) Unwrap() error {
return e.error
}