diff --git a/Makefile b/Makefile index c4391b90a..d44046290 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,14 @@ publishdevoperator: ## Build and publish k8s-operator image to location specifie @test "${REPO}" != "ghcr.io/tailscale/k8s-operator" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-operator" && exit 1) TAGS=latest REPOS=${REPO} PUSH=true TARGET=operator ./build_docker.sh +publishdevnameserver: ## Build and publish nameserver to location specified by ${REPO} + @test -n "${REPO}" || (echo "REPO=... required; e.g. REPO=ghcr.io/${USER}/tailscale" && exit 1) + @test "${REPO}" != "tailscale/tailscale" || (echo "REPO=... must not be tailscale/tailscale" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/tailscale" || (echo "REPO=... must not be ghcr.io/tailscale/tailscale" && exit 1) + @test "${REPO}" != "tailscale/nameserver" || (echo "REPO=... must not be tailscale/nameserver" && exit 1) + @test "${REPO}" != "ghcr.io/tailscale/nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/nameserver" && exit 1) + TAGS=latest REPOS=${REPO} PUSH=true TARGET=nameserver ./build_docker.sh + help: ## Show this help @echo "\nSpecify a command. The choices are:\n" @grep -hE '^[0-9a-zA-Z_-]+:.*?## .*$$' ${MAKEFILE_LIST} | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[0;36m%-20s\033[m %s\n", $$1, $$2}' diff --git a/build_docker.sh b/build_docker.sh index 161dfd280..9f5834557 100755 --- a/build_docker.sh +++ b/build_docker.sh @@ -67,6 +67,21 @@ case "$TARGET" in --push="${PUSH}" \ /usr/local/bin/operator ;; + nameserver) + DEFAULT_REPOS="tailscale/nameserver" + REPOS="${REPOS:-${DEFAULT_REPOS}}" + go run github.com/tailscale/mkctr \ + --gopaths="tailscale.com/cmd/nameserver:/usr/local/bin/nameserver" \ + --ldflags=" \ + -X tailscale.com/version.longStamp=${VERSION_LONG} \ + -X tailscale.com/version.shortStamp=${VERSION_SHORT} \ + -X tailscale.com/version.gitCommitStamp=${VERSION_GIT_HASH}" \ + --base="${BASE}" \ + --tags="${TAGS}" \ + --repos="${REPOS}" \ + --push="${PUSH}" \ + /usr/local/bin/nameserver + ;; *) echo "unknown target: $TARGET" exit 1 diff --git a/cmd/k8s-operator/manifests/nameserver.yaml b/cmd/k8s-operator/manifests/nameserver.yaml new file mode 100644 index 000000000..3cdf8691c --- /dev/null +++ b/cmd/k8s-operator/manifests/nameserver.yaml @@ -0,0 +1,72 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nameserver + namespace: tailscale +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nameserver + namespace: tailscale +spec: + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: nameserver + strategy: + type: Recreate # TODO: is this the right strategy? + template: + metadata: + labels: + app: nameserver + spec: + containers: + - image: tailscale/nameserver:unstable # TODO: actually publish this image + imagePullPolicy: IfNotPresent + name: nameserver + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + ports: + - name: udp + protocol: UDP + containerPort: 53 + volumeMounts: + - name: dnsconfig + mountPath: /config + restartPolicy: Always + schedulerName: default-scheduler + serviceAccount: nameserver + serviceAccountName: nameserver + terminationGracePeriodSeconds: 30 + volumes: + - name: dnsconfig + configMap: + name: dnsconfig +--- +apiVersion: v1 +kind: Service +metadata: + name: nameserver + namespace: tailscale +spec: + selector: + app: nameserver + ports: + - name: udp + targetPort: 53 + port: 53 + protocol: UDP +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: dnsconfig + namespace: tailscale + labels: + app.kubernetes.io/name: tailscale + app.kubernetes.io/component: nameserver +data: + dns.json: | + {} diff --git a/cmd/nameserver/main.go b/cmd/nameserver/main.go new file mode 100644 index 000000000..60bb9956b --- /dev/null +++ b/cmd/nameserver/main.go @@ -0,0 +1,171 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/netip" + "os" + + "github.com/fsnotify/fsnotify" + "tailscale.com/net/dns/resolver" + "tailscale.com/net/tsdial" + "tailscale.com/types/logger" + "tailscale.com/util/dnsname" +) + +const ( + defaultDNSConfigDir = "/config" + defaultDNSFile = "dns.json" +) + +type nameserver struct { + // config file holds FQDN to IP address mappings + configFilePath string + res resolver.Resolver + logf logger.Logf +} + +func main() { + logger := log.Printf + + res := resolver.New(logger, nil, nil, &tsdial.Dialer{Logf: logger}) + + ns := &nameserver{ + configFilePath: fmt.Sprintf("%s/%s", defaultDNSConfigDir, defaultDNSFile), + logf: logger, + res: *res, // TODO (irbekrm): linter error here + } + + // ensure resolver config is updated before starting to serve + err := ns.updateResolverConfig() + if err != nil { + logger("error updating resolver conf: %v", err) + panic(err) + } + logger("Hosts configured") + + watcher, err := fsnotify.NewWatcher() + if err != nil { + logger("error starting file watcher: %v", err) + } + defer watcher.Close() + go func() { + logger("starting DNS file watch") + for { + logger("in DNS file watch...") + select { + // TODO (irbekrm): it appears like we get a whole bunch + // of different events (except for the WRITE one that + // fsnotify recommends watching) on an update. They also + // come with a delay. Figure out if we can add a + // reliable filter to only trigger update once and + // whether we can speed this up a bit. + case event, ok := <-watcher.Events: + if !ok { + logger("watcher finished") + return + } + // it seems like an update to the file has a + // potential to produce different types of + // events, but only one per configmap update. + // fsnotify docs suggest to only react to Write + // events, however we should be safe to react to + // other events too as we run in a container and + // there shouldn't be random changes to file + // metadata + logger("a %v event detected, updating DNS config...", event) + // resolver locks config on updates so this is safe + err := ns.updateResolverConfig() + if err != nil { + logger("error updating resolver conf: %v", err) + continue + } + logger("Hosts updated") + case err, ok := <-watcher.Errors: + if !ok { + logger("watcher finished") + return + } + if err != nil { + logger("error watching DNS config: %v", err) + } + } + } + + }() + err = watcher.Add(defaultDNSConfigDir) + if err != nil { + panic(fmt.Sprintf("error setting up DNS config watch: %v", err)) + } + + addr, err := net.ResolveUDPAddr("udp", ":53") + if err != nil { + panic("error resolving UDP address") + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + panic(fmt.Sprintf("error opening udp connection: %v", err)) + } + defer conn.Close() + logger("nameserver listening on: %v", addr) + + for { + payloadBuff := make([]byte, 10000) + metadataBuff := make([]byte, 512) + _, _, _, addr, err := conn.ReadMsgUDP(payloadBuff, metadataBuff) + if err != nil { + logger("error reading from UDP socket: %v", err) + continue + } + dnsAnswer, err := ns.res.Query(context.Background(), payloadBuff, addr.AddrPort()) + if err != nil { + logger("error doing DNS query: %v", err) + // reply with the dnsAnswer anyway- in some cases + // resolver might have written some useful data there + } + conn.WriteToUDP(dnsAnswer, addr) + } +} + +func (n *nameserver) updateResolverConfig() error { + // file is mounted to pod from a configmap so it cannot not exist + dnsCfgBytes, err := os.ReadFile(n.configFilePath) + if err != nil { + n.logf("error reading configFile: %v", err) + return err + } + dnsCfgM := make(map[string]string) + err = json.Unmarshal(dnsCfgBytes, &dnsCfgM) + if err != nil { + n.logf("error unmarshaling json: %v", err) + return err + } + c := resolver.Config{} + c.Hosts = make(map[dnsname.FQDN][]netip.Addr) + // TODO (irbekrm): ensure that it handles the case of empty configmap + for key, val := range dnsCfgM { + fqdn, err := dnsname.ToFQDN(key) + if err != nil { + n.logf("invalid DNS config: cannot convert %s to FQDN: %v", key, err) + return err + } + ip, err := netip.ParseAddr(val) + if err != nil { + n.logf("invalid DNS config: cannot convert %s to netip.Addr: %v", val, err) + return err + } + c.Hosts[fqdn] = []netip.Addr{ip} + } + // resolver will lock config so this is safe + n.res.SetConfig(c) + + // TODO (irbekrm): get a diff and log when/if resolver config is actually being changed + + return nil +}