mirror of
https://github.com/tailscale/tailscale.git
synced 2026-02-11 02:31:20 +01:00
This file was never truly necessary and has never actually been used in the history of Tailscale's open source releases. A Brief History of AUTHORS files --- The AUTHORS file was a pattern developed at Google, originally for Chromium, then adopted by Go and a bunch of other projects. The problem was that Chromium originally had a copyright line only recognizing Google as the copyright holder. Because Google (and most open source projects) do not require copyright assignemnt for contributions, each contributor maintains their copyright. Some large corporate contributors then tried to add their own name to the copyright line in the LICENSE file or in file headers. This quickly becomes unwieldy, and puts a tremendous burden on anyone building on top of Chromium, since the license requires that they keep all copyright lines intact. The compromise was to create an AUTHORS file that would list all of the copyright holders. The LICENSE file and source file headers would then include that list by reference, listing the copyright holder as "The Chromium Authors". This also become cumbersome to simply keep the file up to date with a high rate of new contributors. Plus it's not always obvious who the copyright holder is. Sometimes it is the individual making the contribution, but many times it may be their employer. There is no way for the proejct maintainer to know. Eventually, Google changed their policy to no longer recommend trying to keep the AUTHORS file up to date proactively, and instead to only add to it when requested: https://opensource.google/docs/releasing/authors. They are also clear that: > Adding contributors to the AUTHORS file is entirely within the > project's discretion and has no implications for copyright ownership. It was primarily added to appease a small number of large contributors that insisted that they be recognized as copyright holders (which was entirely their right to do). But it's not truly necessary, and not even the most accurate way of identifying contributors and/or copyright holders. In practice, we've never added anyone to our AUTHORS file. It only lists Tailscale, so it's not really serving any purpose. It also causes confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header in other open source repos which don't actually have an AUTHORS file, so it's ambiguous what that means. Instead, we just acknowledge that the contributors to Tailscale (whoever they are) are copyright holders for their individual contributions. We also have the benefit of using the DCO (developercertificate.org) which provides some additional certification of their right to make the contribution. The source file changes were purely mechanical with: git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g' Updates #cleanup Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
426 lines
14 KiB
Go
426 lines
14 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !plan9
|
|
|
|
// k8s-nameserver is a simple nameserver implementation meant to be used with
|
|
// k8s-operator to allow to resolve magicDNS names associated with tailnet
|
|
// proxies in cluster.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/miekg/dns"
|
|
operatorutils "tailscale.com/k8s-operator"
|
|
"tailscale.com/util/dnsname"
|
|
)
|
|
|
|
const (
|
|
// tsNetDomain is the domain that this DNS nameserver has registered a handler for.
|
|
tsNetDomain = "ts.net"
|
|
// addr is the the address that the UDP and TCP listeners will listen on.
|
|
addr = ":1053"
|
|
// defaultTTL is the default TTL for DNS records in seconds.
|
|
// Set to 0 to disable caching. Can be increased when usage patterns are better understood.
|
|
defaultTTL = 0
|
|
|
|
// The following constants are specific to the nameserver configuration
|
|
// provided by a mounted Kubernetes Configmap. The Configmap mounted at
|
|
// /config is the only supported way for configuring this nameserver.
|
|
defaultDNSConfigDir = "/config"
|
|
kubeletMountedConfigLn = "..data"
|
|
)
|
|
|
|
// nameserver is a simple nameserver that responds to DNS queries for A and AAAA records
|
|
// for ts.net domain names over UDP or TCP. It serves DNS responses from
|
|
// in-memory IPv4 and IPv6 host records. It is intended to be deployed on Kubernetes with
|
|
// a ConfigMap mounted at /config that should contain the host records. It
|
|
// dynamically reconfigures its in-memory mappings as the contents of the
|
|
// mounted ConfigMap changes.
|
|
type nameserver struct {
|
|
// configReader returns the latest desired configuration (host records)
|
|
// for the nameserver. By default it gets set to a reader that reads
|
|
// from a Kubernetes ConfigMap mounted at /config, but this can be
|
|
// overridden in tests.
|
|
configReader configReaderFunc
|
|
// configWatcher is a watcher that returns an event when the desired
|
|
// configuration has changed and the nameserver should update the
|
|
// in-memory records.
|
|
configWatcher <-chan string
|
|
|
|
mu sync.RWMutex // protects following
|
|
// ip4 are the in-memory hostname -> IP4 mappings that the nameserver
|
|
// uses to respond to A record queries.
|
|
ip4 map[dnsname.FQDN][]net.IP
|
|
// ip6 are the in-memory hostname -> IP6 mappings that the nameserver
|
|
// uses to respond to AAAA record queries.
|
|
ip6 map[dnsname.FQDN][]net.IP
|
|
}
|
|
|
|
func main() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Ensure that we watch the kube Configmap mounted at /config for
|
|
// nameserver configuration updates and send events when updates happen.
|
|
c := ensureWatcherForKubeConfigMap(ctx)
|
|
|
|
ns := &nameserver{
|
|
configReader: configMapConfigReader,
|
|
configWatcher: c,
|
|
}
|
|
|
|
// Ensure that in-memory records get set up to date now and will get
|
|
// reset when the configuration changes.
|
|
ns.runRecordsReconciler(ctx)
|
|
|
|
// Register a DNS server handle for ts.net domain names. Not having a
|
|
// handle registered for any other domain names is how we enforce that
|
|
// this nameserver can only be used for ts.net domains - querying any
|
|
// other domain names returns Rcode Refused.
|
|
dns.HandleFunc(tsNetDomain, ns.handleFunc())
|
|
|
|
// Listen for DNS queries over UDP and TCP.
|
|
udpSig := make(chan os.Signal)
|
|
tcpSig := make(chan os.Signal)
|
|
go listenAndServe("udp", addr, udpSig)
|
|
go listenAndServe("tcp", addr, tcpSig)
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
s := <-sig
|
|
log.Printf("OS signal (%s) received, shutting down", s)
|
|
cancel() // exit the records reconciler and configmap watcher goroutines
|
|
udpSig <- s // stop the UDP listener
|
|
tcpSig <- s // stop the TCP listener
|
|
}
|
|
|
|
// handleFunc is a DNS query handler that can respond to A and AAAA record queries from
|
|
// the nameserver's in-memory records.
|
|
// - For A queries: returns IPv4 addresses if available, NXDOMAIN if the name doesn't exist
|
|
// - For AAAA queries: returns IPv6 addresses if available, NOERROR with no data if only
|
|
// IPv4 exists (per RFC 4074), or NXDOMAIN if the name doesn't exist at all
|
|
// - For invalid domain names: returns Format Error
|
|
// - For other record types: returns Not Implemented
|
|
func (n *nameserver) handleFunc() func(w dns.ResponseWriter, r *dns.Msg) {
|
|
h := func(w dns.ResponseWriter, r *dns.Msg) {
|
|
m := new(dns.Msg)
|
|
defer func() {
|
|
w.WriteMsg(m)
|
|
}()
|
|
if len(r.Question) < 1 {
|
|
log.Print("[unexpected] nameserver received a request with no questions")
|
|
m = r.SetRcodeFormatError(r)
|
|
return
|
|
}
|
|
// TODO (irbekrm): maybe set message compression
|
|
switch r.Question[0].Qtype {
|
|
case dns.TypeA:
|
|
q := r.Question[0].Name
|
|
fqdn, err := dnsname.ToFQDN(q)
|
|
if err != nil {
|
|
m = r.SetRcodeFormatError(r)
|
|
return
|
|
}
|
|
// The only supported use of this nameserver is as a
|
|
// single source of truth for MagicDNS names by
|
|
// non-tailnet Kubernetes workloads.
|
|
m.Authoritative = true
|
|
m.RecursionAvailable = false
|
|
|
|
ips := n.lookupIP4(fqdn)
|
|
if len(ips) == 0 {
|
|
// As we are the authoritative nameserver for MagicDNS
|
|
// names, if we do not have a record for this MagicDNS
|
|
// name, it does not exist.
|
|
m = m.SetRcode(r, dns.RcodeNameError)
|
|
return
|
|
}
|
|
for _, ip := range ips {
|
|
rr := &dns.A{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: defaultTTL}, A: ip}
|
|
m.SetRcode(r, dns.RcodeSuccess)
|
|
m.Answer = append(m.Answer, rr)
|
|
}
|
|
case dns.TypeAAAA:
|
|
q := r.Question[0].Name
|
|
fqdn, err := dnsname.ToFQDN(q)
|
|
if err != nil {
|
|
m = r.SetRcodeFormatError(r)
|
|
return
|
|
}
|
|
// The only supported use of this nameserver is as a
|
|
// single source of truth for MagicDNS names by
|
|
// non-tailnet Kubernetes workloads.
|
|
m.Authoritative = true
|
|
m.RecursionAvailable = false
|
|
|
|
ips := n.lookupIP6(fqdn)
|
|
// Also check if we have IPv4 records to determine correct response code.
|
|
// If the name exists (has A records) but no AAAA records, we return NOERROR
|
|
// per RFC 4074. If the name doesn't exist at all, we return NXDOMAIN.
|
|
ip4s := n.lookupIP4(fqdn)
|
|
|
|
if len(ips) == 0 && len(ip4s) == 0 {
|
|
// As we are the authoritative nameserver for MagicDNS
|
|
// names, if we do not have any record for this MagicDNS
|
|
// name, it does not exist.
|
|
m = m.SetRcode(r, dns.RcodeNameError)
|
|
return
|
|
}
|
|
|
|
// Return IPv6 addresses if available
|
|
for _, ip := range ips {
|
|
rr := &dns.AAAA{Hdr: dns.RR_Header{Name: q, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: defaultTTL}, AAAA: ip}
|
|
m.Answer = append(m.Answer, rr)
|
|
}
|
|
m.SetRcode(r, dns.RcodeSuccess)
|
|
default:
|
|
log.Printf("[unexpected] nameserver received a query for an unsupported record type: %s", r.Question[0].String())
|
|
m.SetRcode(r, dns.RcodeNotImplemented)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// runRecordsReconciler ensures that nameserver's in-memory records are
|
|
// reset when the provided configuration changes.
|
|
func (n *nameserver) runRecordsReconciler(ctx context.Context) {
|
|
log.Print("updating nameserver's records from the provided configuration...")
|
|
if err := n.resetRecords(); err != nil { // ensure records are up to date before the nameserver starts
|
|
log.Fatalf("error setting nameserver's records: %v", err)
|
|
}
|
|
log.Print("nameserver's records were updated")
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("context cancelled, exiting records reconciler")
|
|
return
|
|
case <-n.configWatcher:
|
|
log.Print("configuration update detected, resetting records")
|
|
if err := n.resetRecords(); err != nil {
|
|
// TODO (irbekrm): this runs in a
|
|
// container that will be thrown away,
|
|
// so this should be ok. But maybe still
|
|
// need to ensure that the DNS server
|
|
// terminates connections more
|
|
// gracefully.
|
|
log.Fatalf("error resetting records: %v", err)
|
|
}
|
|
log.Print("nameserver records were reset")
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// resetRecords sets the in-memory DNS records of this nameserver from the
|
|
// provided configuration. It does not check for the diff, so the caller is
|
|
// expected to ensure that this is only called when reset is needed.
|
|
func (n *nameserver) resetRecords() error {
|
|
dnsCfgBytes, err := n.configReader()
|
|
if err != nil {
|
|
log.Printf("error reading nameserver's configuration: %v", err)
|
|
return err
|
|
}
|
|
if len(dnsCfgBytes) == 0 {
|
|
log.Print("nameserver's configuration is empty, any in-memory records will be unset")
|
|
n.mu.Lock()
|
|
n.ip4 = make(map[dnsname.FQDN][]net.IP)
|
|
n.ip6 = make(map[dnsname.FQDN][]net.IP)
|
|
n.mu.Unlock()
|
|
return nil
|
|
}
|
|
dnsCfg := &operatorutils.Records{}
|
|
err = json.Unmarshal(dnsCfgBytes, dnsCfg)
|
|
if err != nil {
|
|
return fmt.Errorf("error unmarshalling nameserver configuration: %v\n", err)
|
|
}
|
|
|
|
if dnsCfg.Version != operatorutils.Alpha1Version {
|
|
return fmt.Errorf("unsupported configuration version %s, supported versions are %s\n", dnsCfg.Version, operatorutils.Alpha1Version)
|
|
}
|
|
|
|
ip4 := make(map[dnsname.FQDN][]net.IP)
|
|
ip6 := make(map[dnsname.FQDN][]net.IP)
|
|
defer func() {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.ip4 = ip4
|
|
n.ip6 = ip6
|
|
}()
|
|
|
|
if len(dnsCfg.IP4) == 0 && len(dnsCfg.IP6) == 0 {
|
|
log.Print("nameserver's configuration contains no records, any in-memory records will be unset")
|
|
return nil
|
|
}
|
|
|
|
// Process IPv4 records
|
|
for fqdn, ips := range dnsCfg.IP4 {
|
|
fqdn, err := dnsname.ToFQDN(fqdn)
|
|
if err != nil {
|
|
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
|
|
continue // one invalid hostname should not break the whole nameserver
|
|
}
|
|
var validIPs []net.IP
|
|
for _, ipS := range ips {
|
|
ip := net.ParseIP(ipS).To4()
|
|
if ip == nil { // To4 returns nil if IP is not a IPv4 address
|
|
log.Printf("invalid nameserver's configuration: %v does not appear to be an IPv4 address; skipping this record", ipS)
|
|
continue // one invalid IP address should not break the whole nameserver
|
|
}
|
|
validIPs = append(validIPs, ip)
|
|
}
|
|
if len(validIPs) > 0 {
|
|
ip4[fqdn] = validIPs
|
|
}
|
|
}
|
|
|
|
// Process IPv6 records
|
|
for fqdn, ips := range dnsCfg.IP6 {
|
|
fqdn, err := dnsname.ToFQDN(fqdn)
|
|
if err != nil {
|
|
log.Printf("invalid nameserver's configuration: %s is not a valid FQDN: %v; skipping this record", fqdn, err)
|
|
continue // one invalid hostname should not break the whole nameserver
|
|
}
|
|
var validIPs []net.IP
|
|
for _, ipS := range ips {
|
|
ip := net.ParseIP(ipS)
|
|
if ip == nil {
|
|
log.Printf("invalid nameserver's configuration: %v does not appear to be a valid IP address; skipping this record", ipS)
|
|
continue
|
|
}
|
|
// Check if it's a valid IPv6 address
|
|
if ip.To4() != nil {
|
|
log.Printf("invalid nameserver's configuration: %v appears to be IPv4 but was in IPv6 records; skipping this record", ipS)
|
|
continue
|
|
}
|
|
validIPs = append(validIPs, ip.To16())
|
|
}
|
|
if len(validIPs) > 0 {
|
|
ip6[fqdn] = validIPs
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// listenAndServe starts a DNS server for the provided network and address.
|
|
func listenAndServe(net, addr string, shutdown chan os.Signal) {
|
|
s := &dns.Server{Addr: addr, Net: net}
|
|
go func() {
|
|
<-shutdown
|
|
log.Printf("shutting down server for %s", net)
|
|
s.Shutdown()
|
|
}()
|
|
log.Printf("listening for %s queries on %s", net, addr)
|
|
if err := s.ListenAndServe(); err != nil {
|
|
log.Fatalf("error running %s server: %v", net, err)
|
|
}
|
|
}
|
|
|
|
// ensureWatcherForKubeConfigMap sets up a new file watcher for the ConfigMap
|
|
// that's expected to be mounted at /config. Returns a channel that receives an
|
|
// event every time the contents get updated.
|
|
func ensureWatcherForKubeConfigMap(ctx context.Context) chan string {
|
|
c := make(chan string)
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
log.Fatalf("error creating a new watcher for the mounted ConfigMap: %v", err)
|
|
}
|
|
// kubelet mounts configmap to a Pod using a series of symlinks, one of
|
|
// which is <mount-dir>/..data that Kubernetes recommends consumers to
|
|
// use if they need to monitor changes
|
|
// https://github.com/kubernetes/kubernetes/blob/v1.28.1/pkg/volume/util/atomic_writer.go#L39-L61
|
|
toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn)
|
|
go func() {
|
|
defer watcher.Close()
|
|
log.Printf("starting file watch for %s", defaultDNSConfigDir)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Print("context cancelled, exiting ConfigMap watcher")
|
|
return
|
|
case event, ok := <-watcher.Events:
|
|
if !ok {
|
|
log.Fatal("watcher finished; exiting")
|
|
}
|
|
if event.Name == toWatch {
|
|
msg := fmt.Sprintf("ConfigMap update received: %s", event)
|
|
log.Print(msg)
|
|
c <- msg
|
|
}
|
|
case err, ok := <-watcher.Errors:
|
|
if err != nil {
|
|
// TODO (irbekrm): this runs in a
|
|
// container that will be thrown away,
|
|
// so this should be ok. But maybe still
|
|
// need to ensure that the DNS server
|
|
// terminates connections more
|
|
// gracefully.
|
|
log.Fatalf("[unexpected] error watching configuration: %v", err)
|
|
}
|
|
if !ok {
|
|
// TODO (irbekrm): this runs in a
|
|
// container that will be thrown away,
|
|
// so this should be ok. But maybe still
|
|
// need to ensure that the DNS server
|
|
// terminates connections more
|
|
// gracefully.
|
|
log.Fatalf("[unexpected] errors watcher exited")
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
|
log.Fatalf("failed setting up a watcher for the mounted ConfigMap: %v", err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// configReaderFunc is a function that returns the desired nameserver configuration.
|
|
type configReaderFunc func() ([]byte, error)
|
|
|
|
// configMapConfigReader reads the desired nameserver configuration from a
|
|
// records.json file in a ConfigMap mounted at /config.
|
|
var configMapConfigReader configReaderFunc = func() ([]byte, error) {
|
|
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, operatorutils.DNSRecordsCMKey)); err == nil {
|
|
return contents, nil
|
|
} else if os.IsNotExist(err) {
|
|
return nil, nil
|
|
} else {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// lookupIP4 returns any IPv4 addresses for the given FQDN from nameserver's
|
|
// in-memory records.
|
|
func (n *nameserver) lookupIP4(fqdn dnsname.FQDN) []net.IP {
|
|
if n.ip4 == nil {
|
|
return nil
|
|
}
|
|
n.mu.RLock()
|
|
defer n.mu.RUnlock()
|
|
f := n.ip4[fqdn]
|
|
return f
|
|
}
|
|
|
|
// lookupIP6 returns any IPv6 addresses for the given FQDN from nameserver's
|
|
// in-memory records.
|
|
func (n *nameserver) lookupIP6(fqdn dnsname.FQDN) []net.IP {
|
|
if n.ip6 == nil {
|
|
return nil
|
|
}
|
|
n.mu.RLock()
|
|
defer n.mu.RUnlock()
|
|
f := n.ip6[fqdn]
|
|
return f
|
|
}
|