juanfont/headscale · error

unmarshalling dns extra records: %w

Error message

unmarshalling dns extra records: %w

What it means

viper.UnmarshalKey("dns.extra_records", &[]tailcfg.DNSRecord) failed while loading DNS config: the configured value's shape does not match a list of DNS records (name + type + value). Emitted from loadDNSConfig, so the whole DNS config load fails at startup or reload.

Source

Thrown at hscontrol/types/config.go:925

	// err := viper.UnmarshalKey("dns", &dns)
	// if err != nil {
	// 	return DNSConfig{}, fmt.Errorf("unmarshalling dns config: %w", err)
	// }

	dns.MagicDNS = viper.GetBool("dns.magic_dns")
	dns.BaseDomain = viper.GetString("dns.base_domain")
	dns.OverrideLocalDNS = viper.GetBool("dns.override_local_dns")
	dns.Nameservers.Global = viper.GetStringSlice("dns.nameservers.global")
	dns.Nameservers.Split = viper.GetStringMapStringSlice("dns.nameservers.split")
	dns.SearchDomains = viper.GetStringSlice("dns.search_domains")
	dns.ExtraRecordsPath = viper.GetString("dns.extra_records_path")

	if viper.IsSet("dns.extra_records") {
		var extraRecords []tailcfg.DNSRecord

		err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
		if err != nil {
			return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
		}

		dns.ExtraRecords = extraRecords
	}

	return dns, nil
}

// parseResolvers converts nameserver strings into DNS resolvers.
// If a nameserver is a valid IP, it will be used as a regular resolver.
// If a nameserver is a valid URL, it will be used as a DoH resolver.
// If a nameserver is neither a valid URL nor a valid IP, it will be ignored.
// When domain is non-empty, it is included in the warning for invalid entries.
func parseResolvers(nameservers []string, domain string) []*dnstype.Resolver {
	var resolvers []*dnstype.Resolver

	for _, nsStr := range nameservers {
		if _, err := netip.ParseAddr(nsStr); err == nil { //nolint:noinlineerr

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Use the documented list-of-mappings form with name/type/value keys (see the extra_records example in config-example.yaml)
  2. Validate the YAML structure with yamllint before restart
  3. Ensure type is a valid DNS record type string like A, AAAA, or TXT

Example fix

# before
dns:
  extra_records:
    example.com: { type: A, value: 1.2.3.4 }

# after
dns:
  extra_records:
    - name: example.com
      type: A
      value: 1.2.3.4
Defensive patterns

Strategy: validation

Validate before calling

// Structural pre-check mirroring the unmarshal target:
recs := viper.Get("dns.extra_records")
if recs != nil {
    if _, ok := recs.([]any); !ok {
        return errors.New("dns.extra_records must be a list of {name,type,value} mappings")
    }
}

Type guard

func isValidExtraRecords(v any) bool {
    list, ok := v.([]any)
    if !ok { return false }
    for _, r := range list {
        m, ok := r.(map[string]any)
        if !ok { return false }
        if _, ok := m["name"]; !ok { return false }
        if _, ok := m["value"]; !ok { return false }
    }
    return true
}

Prevention

When it happens

Trigger: dns.extra_records set to a mapping instead of a list ({...} vs [...]), records missing required fields, or values of the wrong type (e.g. value: 3600 where a string is expected). Only fires when viper.IsSet sees the key.

Common situations: Converting older headscale configs where extra_records used a different schema; YAML indentation making a record a nested map; copy-pasting a record from docs with the wrong field names (data vs value).

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/dd8da8a74900a4f7. Report an issue: GitHub.