mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 13:05:46 +00:00
WIP: MagicDNS resolution in cluster
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
This commit is contained in:
parent
75f1d3e7d7
commit
5d1cc44fa3
8
Makefile
8
Makefile
@ -100,6 +100,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="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=operator ./build_docker.sh
|
||||
|
||||
publishdevnameserver: ## Build and publish k8s-nameserver image 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/k8s-nameserver" || (echo "REPO=... must not be tailscale/k8s-nameserver" && exit 1)
|
||||
@test "${REPO}" != "ghcr.io/tailscale/k8s-nameserver" || (echo "REPO=... must not be ghcr.io/tailscale/k8s-nameserver" && exit 1)
|
||||
TAGS="${TAGS}" REPOS=${REPO} PLATFORM=${PLATFORM} PUSH=true TARGET=k8s-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}'
|
||||
|
@ -70,6 +70,21 @@ case "$TARGET" in
|
||||
--target="${PLATFORM}" \
|
||||
/usr/local/bin/operator
|
||||
;;
|
||||
k8s-nameserver)
|
||||
DEFAULT_REPOS="tailscale/k8s-nameserver"
|
||||
REPOS="${REPOS:-${DEFAULT_REPOS}}"
|
||||
go run github.com/tailscale/mkctr \
|
||||
--gopaths="tailscale.com/cmd/k8s-nameserver:/usr/local/bin/k8s-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/k8s-nameserver
|
||||
;;
|
||||
*)
|
||||
echo "unknown target: $TARGET"
|
||||
exit 1
|
||||
|
240
cmd/k8s-nameserver/main.go
Normal file
240
cmd/k8s-nameserver/main.go
Normal file
@ -0,0 +1,240 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
// k8s-nameserver is a simple nameserver implementation meant to be used with
|
||||
// k8s-operator to allow to resolve magicDNS names of Tailscale nodes in a
|
||||
// Kubernetes cluster.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/net/dns/resolver"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDNSConfigDir = "/config"
|
||||
defaultDNSFile = "dns.json"
|
||||
udpEndpoint = ":1053"
|
||||
|
||||
kubeletMountedConfigLn = "..data"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
logger := log.Printf
|
||||
|
||||
res := resolver.New(logger, nil, nil, &tsdial.Dialer{Logf: logger}, nil)
|
||||
|
||||
var configReader configReaderFunc = func() ([]byte, error) {
|
||||
if contents, err := os.ReadFile(filepath.Join(defaultDNSConfigDir, defaultDNSFile)); err == nil {
|
||||
return contents, nil
|
||||
|
||||
} else if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c := make(chan string)
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatalf("error creating a new configfile watcher: %v", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
// 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
|
||||
// TODO (irbekrm): we need e2e tests to make sure that this keeps working for new kube versions etc
|
||||
toWatch := filepath.Join(defaultDNSConfigDir, kubeletMountedConfigLn)
|
||||
go func() {
|
||||
logger("starting file watch for %s", defaultDNSConfigDir)
|
||||
if err != nil {
|
||||
log.Fatalf("error starting a new configfile watcher: %v", err)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
logger("watcher finished")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
if event.Name == toWatch {
|
||||
msg := fmt.Sprintf("config update received: %s", event)
|
||||
logger(msg)
|
||||
c <- msg
|
||||
}
|
||||
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
logger("errors watcher finished: %v", err)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger("error watching directory: %w", err)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err = watcher.Add(defaultDNSConfigDir); err != nil {
|
||||
log.Fatalf("failed setting up file watch for DNS config: %v", err)
|
||||
}
|
||||
|
||||
ns := &nameserver{
|
||||
configReader: configReader,
|
||||
configWatcher: c,
|
||||
logger: logger,
|
||||
res: res,
|
||||
}
|
||||
|
||||
if err := ns.run(ctx, cancel); err != nil {
|
||||
log.Fatalf("error running nameserver: %v", err)
|
||||
}
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp", udpEndpoint)
|
||||
if err != nil {
|
||||
log.Fatalf("error resolving UDP address: %v", err)
|
||||
}
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
log.Fatalf("error opening udp connection: %v", err)
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
logger("k8s-nameserver listening on: %v", addr)
|
||||
|
||||
for {
|
||||
payloadBuff := make([]byte, 10000)
|
||||
metadataBuff := make([]byte, 512)
|
||||
_, _, _, addr, err := conn.ReadMsgUDP(payloadBuff, metadataBuff)
|
||||
if err != nil {
|
||||
logger(fmt.Sprintf("error reading UDP message: %v", err))
|
||||
continue
|
||||
}
|
||||
dnsAnswer, err := ns.query(ctx, payloadBuff, addr.AddrPort())
|
||||
if err != nil {
|
||||
// reply with the dnsAnswer anyway- in some cases
|
||||
// resolver might have written some useful data there
|
||||
}
|
||||
conn.WriteToUDP(dnsAnswer, addr)
|
||||
}
|
||||
}
|
||||
|
||||
type nameserver struct {
|
||||
configReader configReaderFunc
|
||||
configWatcher <-chan string
|
||||
res *resolver.Resolver
|
||||
logger logger.Logf
|
||||
}
|
||||
|
||||
type configReaderFunc func() ([]byte, error)
|
||||
|
||||
// run ensures that resolver configuration is up to date with regards to its
|
||||
// source. will update config once before returning and keep monitoring it in a
|
||||
// thread.
|
||||
func (n *nameserver) run(ctx context.Context, cancelF context.CancelFunc) error {
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
n.logger("nameserver exiting")
|
||||
return
|
||||
case <-n.configWatcher:
|
||||
// TODO (irbekrm): this does not actually log anything
|
||||
n.logger("attempting to update resolver config...")
|
||||
if err := n.updateResolverConfig(); err != nil {
|
||||
n.logger("error updating resolver config: %w", err)
|
||||
cancelF()
|
||||
}
|
||||
// TODO (irbekrm): this does not actually log anything
|
||||
n.logger("successfully updated resolver config")
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := n.updateResolverConfig(); err != nil {
|
||||
return fmt.Errorf("error updating resolver config: %w", err)
|
||||
}
|
||||
n.logger("successfully updated resolver config")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *nameserver) query(ctx context.Context, payload []byte, add netip.AddrPort) ([]byte, error) {
|
||||
return n.res.Query(ctx, payload, "udp", add)
|
||||
}
|
||||
|
||||
func (n *nameserver) updateResolverConfig() error {
|
||||
dnsCfgBytes, err := n.configReader()
|
||||
if err != nil {
|
||||
n.logger("error reading config: %v", err)
|
||||
return err
|
||||
}
|
||||
if dnsCfgBytes == nil || len(dnsCfgBytes) < 1 {
|
||||
n.logger("no DNS config provided")
|
||||
return nil
|
||||
}
|
||||
dnsCfg := &operatorutils.TSHosts{}
|
||||
err = json.Unmarshal(dnsCfgBytes, dnsCfg)
|
||||
if err != nil {
|
||||
n.logger("error unmarshaling json: %v", err)
|
||||
return err
|
||||
}
|
||||
if dnsCfg.Hosts == nil || len(dnsCfg.Hosts) < 1 {
|
||||
n.logger("no host records found")
|
||||
}
|
||||
c := resolver.Config{}
|
||||
|
||||
// Ensure that queries for ts.net subdomains are never forwarded to
|
||||
// external resolvers
|
||||
c.LocalDomains = []dnsname.FQDN{"ts.net", "ts.net."}
|
||||
|
||||
c.Hosts = make(map[dnsname.FQDN][]netip.Addr)
|
||||
for fqdn, ips := range dnsCfg.Hosts {
|
||||
fqdn, err := dnsname.ToFQDN(fqdn)
|
||||
if err != nil {
|
||||
n.logger("invalid DNS config: cannot convert %s to FQDN: %v", fqdn, err)
|
||||
return err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
ip, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
n.logger("invalid DNS config: cannot convert %s to netip.Addr: %v", ip, 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
|
||||
}
|
181
cmd/k8s-nameserver/main_test.go
Normal file
181
cmd/k8s-nameserver/main_test.go
Normal file
@ -0,0 +1,181 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
"tailscale.com/net/dns/resolver"
|
||||
"tailscale.com/net/tsdial"
|
||||
)
|
||||
|
||||
func TestNameserver(t *testing.T) {
|
||||
|
||||
// Setup
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
hostConfig := `{"hosts":{"foo.bar.ts.net.": "10.20.30.40"}}`
|
||||
|
||||
var mockConfigReader configReaderFunc = func() ([]byte, error) {
|
||||
return []byte(hostConfig), nil
|
||||
}
|
||||
configWatcher := make(chan string)
|
||||
logger := log.Printf
|
||||
res := resolver.New(logger, nil, nil, &tsdial.Dialer{Logf: logger}, nil)
|
||||
|
||||
ns := &nameserver{
|
||||
configReader: mockConfigReader,
|
||||
configWatcher: configWatcher,
|
||||
logger: logger,
|
||||
res: *res,
|
||||
}
|
||||
assert.NoError(t, ns.run(ctx, cancel), "error running nameserver")
|
||||
|
||||
// Test that nameserver can resolve a DNS name from provided hosts config
|
||||
|
||||
wantedResponse := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{
|
||||
ID: 0x0,
|
||||
Response: true,
|
||||
OpCode: 0,
|
||||
Authoritative: true,
|
||||
Truncated: false,
|
||||
RecursionDesired: false,
|
||||
RecursionAvailable: false,
|
||||
AuthenticData: false,
|
||||
CheckingDisabled: false,
|
||||
RCode: dnsmessage.RCodeSuccess,
|
||||
},
|
||||
|
||||
Answers: []dnsmessage.Resource{{
|
||||
Header: dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName("foo.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0x258,
|
||||
Length: 0x4,
|
||||
},
|
||||
Body: &dnsmessage.AResource{
|
||||
A: [4]byte{10, 20, 30, 40},
|
||||
},
|
||||
}},
|
||||
Questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("foo.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
Additionals: []dnsmessage.Resource{},
|
||||
Authorities: []dnsmessage.Resource{},
|
||||
}
|
||||
testQuery := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{Authoritative: true},
|
||||
Questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("foo.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
}
|
||||
testAddr, err := netip.ParseAddrPort("10.40.30.20:0")
|
||||
assert.NoError(t, err, "error parsing IP address")
|
||||
packedTestQuery, err := testQuery.Pack()
|
||||
assert.NoError(t, err, "error parsing DNS query")
|
||||
answer, err := ns.query(ctx, packedTestQuery, testAddr)
|
||||
assert.NoError(t, err, "error querying nameserver")
|
||||
var gotResponse dnsmessage.Message
|
||||
assert.NoError(t, gotResponse.Unpack(answer), "error unpacking DNS answer")
|
||||
assert.Equal(t, gotResponse, wantedResponse)
|
||||
|
||||
// Test that nameserver's hosts config gets dynamically updated
|
||||
|
||||
newHostConfig := `{"hosts": {"baz.bar.ts.net.": "10.40.30.20"}}`
|
||||
var newMockConfigReader configReaderFunc = func() ([]byte, error) {
|
||||
return []byte(newHostConfig), nil
|
||||
}
|
||||
ns.configReader = newMockConfigReader
|
||||
|
||||
timeout := 3 * time.Second
|
||||
timer := time.NewTimer(timeout)
|
||||
select {
|
||||
case <-timer.C:
|
||||
t.Fatalf("nameserver failed to process config update within %v", timeout)
|
||||
case configWatcher <- "config update":
|
||||
}
|
||||
wantedResponse = dnsmessage.Message{
|
||||
Header: dnsmessage.Header{
|
||||
ID: 0x0,
|
||||
Response: true,
|
||||
OpCode: 0,
|
||||
Authoritative: true,
|
||||
Truncated: false,
|
||||
RecursionDesired: false,
|
||||
RecursionAvailable: false,
|
||||
AuthenticData: false,
|
||||
CheckingDisabled: false,
|
||||
RCode: dnsmessage.RCodeSuccess,
|
||||
},
|
||||
|
||||
Answers: []dnsmessage.Resource{{
|
||||
Header: dnsmessage.ResourceHeader{
|
||||
Name: dnsmessage.MustNewName("baz.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
TTL: 0x258,
|
||||
Length: 0x4,
|
||||
},
|
||||
Body: &dnsmessage.AResource{
|
||||
A: [4]byte{10, 40, 30, 20},
|
||||
},
|
||||
}},
|
||||
Questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("baz.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
Additionals: []dnsmessage.Resource{},
|
||||
Authorities: []dnsmessage.Resource{},
|
||||
}
|
||||
testQuery = dnsmessage.Message{
|
||||
Header: dnsmessage.Header{Authoritative: true},
|
||||
Questions: []dnsmessage.Question{
|
||||
{
|
||||
Name: dnsmessage.MustNewName("baz.bar.ts.net."),
|
||||
Type: dnsmessage.TypeA,
|
||||
Class: dnsmessage.ClassINET,
|
||||
},
|
||||
},
|
||||
}
|
||||
packedTestQuery, err = testQuery.Pack()
|
||||
assert.NoError(t, err, "error parsing DNS query")
|
||||
|
||||
// retry a couple times as the nameserver will have eventually processed
|
||||
// the update
|
||||
assert.Eventually(t, func() bool {
|
||||
answer, err = ns.query(ctx, packedTestQuery, testAddr)
|
||||
assert.NoError(t, err, "error querying nameserver")
|
||||
gotResponse = dnsmessage.Message{}
|
||||
|
||||
assert.NoError(t, gotResponse.Unpack(answer), "error unpacking DNS answer")
|
||||
if reflect.DeepEqual(wantedResponse, gotResponse) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, time.Second*5, time.Second)
|
||||
}
|
@ -24,6 +24,9 @@ rules:
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["connectors", "connectors/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
- apiGroups: ["tailscale.com"]
|
||||
resources: ["dnsconfigs", "dnsconfigs/status"]
|
||||
verbs: ["get", "list", "watch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@ -45,10 +48,16 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resources: ["secrets", "serviceaccounts", "configmaps"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["statefulsets"]
|
||||
resources: ["statefulsets", "deployments"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["discovery.k8s.io"]
|
||||
resources: ["endpointslices"]
|
||||
verbs: ["*"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
97
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
97
cmd/k8s-operator/deploy/crds/tailscale.com_dnsconfigs.yaml
Normal file
@ -0,0 +1,97 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.13.0
|
||||
name: dnsconfigs.tailscale.com
|
||||
spec:
|
||||
group: tailscale.com
|
||||
names:
|
||||
kind: DNSConfig
|
||||
listKind: DNSConfigList
|
||||
plural: dnsconfigs
|
||||
shortNames:
|
||||
- dc
|
||||
singular: dnsconfig
|
||||
scope: Cluster
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- description: Status of the deployed Connector resources.
|
||||
jsonPath: .status.nameserverStatus.ip
|
||||
name: NameserverIP
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
properties:
|
||||
apiVersion:
|
||||
description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
|
||||
type: string
|
||||
kind:
|
||||
description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
type: object
|
||||
required:
|
||||
- nameserver
|
||||
properties:
|
||||
nameserver:
|
||||
type: object
|
||||
properties:
|
||||
image:
|
||||
type: object
|
||||
properties:
|
||||
repo:
|
||||
type: string
|
||||
tag:
|
||||
type: string
|
||||
status:
|
||||
type: object
|
||||
properties:
|
||||
conditions:
|
||||
description: 'TODO: rename ConnectorCondition to sth like ComponentCondition'
|
||||
type: array
|
||||
items:
|
||||
description: ConnectorCondition contains condition information for a Connector.
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- type
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: LastTransitionTime is the timestamp corresponding to the last status change of this condition.
|
||||
type: string
|
||||
format: date-time
|
||||
message:
|
||||
description: Message is a human readable description of the details of the last transition, complementing reason.
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: If set, this represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.condition[x].observedGeneration is 9, the condition is out of date with respect to the current state of the Connector.
|
||||
type: integer
|
||||
format: int64
|
||||
reason:
|
||||
description: Reason is a brief machine readable explanation for the condition's last transition.
|
||||
type: string
|
||||
status:
|
||||
description: Status of the condition, one of ('True', 'False', 'Unknown').
|
||||
type: string
|
||||
type:
|
||||
description: Type of the condition, known values are (`SubnetRouterReady`).
|
||||
type: string
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
nameserverStatus:
|
||||
type: object
|
||||
properties:
|
||||
ip:
|
||||
type: string
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
9
cmd/k8s-operator/deploy/examples/dnsconfig.yaml
Normal file
9
cmd/k8s-operator/deploy/examples/dnsconfig.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
apiVersion: tailscale.com/v1alpha1
|
||||
kind: DNSConfig
|
||||
metadata:
|
||||
name: ts-dns
|
||||
spec:
|
||||
nameserver:
|
||||
image:
|
||||
repo: gcr.io/csi-test-290908/nameserver
|
||||
tag: v0.0.18dns
|
7
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
7
cmd/k8s-operator/deploy/manifests/nameserver/cm.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: dnsconfig
|
||||
labels:
|
||||
app.kubernetes.io/name: tailscale
|
||||
app.kubernetes.io/component: nameserver
|
34
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
34
cmd/k8s-operator/deploy/manifests/nameserver/deploy.yaml
Normal file
@ -0,0 +1,34 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 5
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nameserver
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nameserver
|
||||
spec:
|
||||
containers:
|
||||
- imagePullPolicy: IfNotPresent
|
||||
name: nameserver
|
||||
ports:
|
||||
- name: udp
|
||||
protocol: UDP
|
||||
containerPort: 1053
|
||||
volumeMounts:
|
||||
- name: dnsconfig
|
||||
mountPath: /config
|
||||
restartPolicy: Always
|
||||
serviceAccount: nameserver
|
||||
serviceAccountName: nameserver
|
||||
volumes:
|
||||
- name: dnsconfig
|
||||
configMap:
|
||||
name: dnsconfig
|
4
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
4
cmd/k8s-operator/deploy/manifests/nameserver/sa.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: nameserver
|
12
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
12
cmd/k8s-operator/deploy/manifests/nameserver/svc.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: nameserver
|
||||
spec:
|
||||
selector:
|
||||
app: nameserver
|
||||
ports:
|
||||
- name: udp
|
||||
targetPort: 1053
|
||||
port: 53
|
||||
protocol: UDP
|
@ -191,6 +191,16 @@ rules:
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- apiGroups:
|
||||
- tailscale.com
|
||||
resources:
|
||||
- dnsconfigs
|
||||
- dnsconfigs/status
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
@ -215,12 +225,29 @@ rules:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- configmaps
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- pods
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- statefulsets
|
||||
- deployments
|
||||
verbs:
|
||||
- '*'
|
||||
- apiGroups:
|
||||
- discovery.k8s.io
|
||||
resources:
|
||||
- endpointslices
|
||||
verbs:
|
||||
- '*'
|
||||
---
|
||||
|
324
cmd/k8s-operator/dnsrecords.go
Normal file
324
cmd/k8s-operator/dnsrecords.go
Normal file
@ -0,0 +1,324 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
kube "tailscale.com/k8s-operator"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
const (
|
||||
dnsConfigKey = "dns.json"
|
||||
configMapName = "dnsconfig"
|
||||
|
||||
dnsRecordsRecocilerFinalizer = "tailscale.com/dns-records-reconciler"
|
||||
annotationTSMagicDNSName = "tailscale.com/magic-dns"
|
||||
)
|
||||
|
||||
// dnsRecordsReconciler knows how to update ts.net nameserver with records
|
||||
// of a tailnet MagicDNS name to kube Service endpoints.
|
||||
type dnsRecordsReconciler struct {
|
||||
client.Client
|
||||
// namespace in which tailscale resources get provisioned
|
||||
tsNamespace string
|
||||
// localClient knows how to talk to tailscaled local API
|
||||
localAPIClient localClient
|
||||
logger *zap.SugaredLogger
|
||||
isDefaultLoadBalancer bool
|
||||
}
|
||||
|
||||
type localClient interface {
|
||||
WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := dnsRR.logger.With("EndpointSlice", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
// Check that this is an EndpointSlice is for a headless Service for a
|
||||
// tailscale proxy type that we support creating DNS records for.
|
||||
// Currently this is cluster egress or L7 cluster ingress.
|
||||
eps := new(discoveryv1.EndpointSlice)
|
||||
err = dnsRR.Get(ctx, req.NamespacedName, eps)
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.Debugf("EndpointSlice not found")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get EndpointSlice: %w", err)
|
||||
}
|
||||
if !eps.DeletionTimestamp.IsZero() {
|
||||
logger.Debug("EndpointSlice is being deleted, clean up resources")
|
||||
return reconcile.Result{}, dnsRR.maybeCleanup(ctx, eps, logger)
|
||||
}
|
||||
|
||||
maybeHeadlessSvcName, ok := eps.Labels[discoveryv1.LabelServiceName]
|
||||
if !ok {
|
||||
logger.Debugf("EndpointSlice does not have %s label, do nothing", discoveryv1.LabelServiceName)
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
maybyHeadlessSvc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: maybeHeadlessSvcName, Namespace: dnsRR.tsNamespace}}
|
||||
if err = dnsRR.Get(ctx, client.ObjectKeyFromObject(maybyHeadlessSvc), maybyHeadlessSvc); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error retrieving Service for EndpointSlice: %w", err)
|
||||
}
|
||||
ok, err = dnsRR.isHeadlessSvcForSupportedProxy(ctx, maybyHeadlessSvc)
|
||||
if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error validating proxy for DNS records: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
logger.Debugf("EndpointSlice is not for a proxy type that we create DNS records for, do nothing")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
dnsCfgLst := new(tsapi.DNSConfigList)
|
||||
if err = dnsRR.List(ctx, dnsCfgLst); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||
}
|
||||
|
||||
if len(dnsCfgLst.Items) == 0 {
|
||||
logger.Debugf("DNSConfig does not exist, not creating DNS records")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
if len(dnsCfgLst.Items) > 1 {
|
||||
logger.Errorf("Invalid cluster state - more than one DNSConfig found in cluster. Please ensure no more than one exists")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
dnsCfg := dnsCfgLst.Items[0]
|
||||
|
||||
if !kube.DNSCfgIsReady(&dnsCfg) {
|
||||
logger.Info("DNSConfig is not ready yet, waiting...")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
return reconcile.Result{}, dnsRR.maybeProvision(ctx, eps, logger)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) maybeProvision(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) error {
|
||||
logger.Debugf("provisioning record")
|
||||
if eps == nil {
|
||||
return nil
|
||||
}
|
||||
fqdn, err := dnsRR.fqdnForDNSRecord(ctx, eps, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error determining DNS name for record: %w", err)
|
||||
}
|
||||
if fqdn == "" {
|
||||
logger.Debugf("MagicDNS name does not (yet) exist, not provisioning DNS record")
|
||||
return nil // a new reconcile will be triggered once it's added
|
||||
}
|
||||
oldEps := eps.DeepCopy()
|
||||
if !slices.Contains(eps.Finalizers, dnsRecordsRecocilerFinalizer) {
|
||||
eps.Finalizers = append(eps.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
}
|
||||
if _, ok := eps.Annotations[annotationTSMagicDNSName]; !ok {
|
||||
mak.Set(&eps.Annotations, annotationTSMagicDNSName, fqdn) // label eps with the assocated MagicDNS name to make record cleanup easier
|
||||
}
|
||||
if !apiequality.Semantic.DeepEqual(oldEps, eps) {
|
||||
logger.Infof("provisioning DNS record for MagicDNS name: %s", fqdn) // this will be printed exactly once
|
||||
if err := dnsRR.Update(ctx, eps); err != nil {
|
||||
return fmt.Errorf("error updating EndpointSlice metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ips := make([]string, 0)
|
||||
for _, ep := range eps.Endpoints {
|
||||
ips = append(ips, ep.Addresses...)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
logger.Debugf("No endpoint addresses found")
|
||||
return nil // a new reconcile will be triggered once the EndpointSlice is updated with addresses
|
||||
}
|
||||
updateFunc := func(cfg *operatorutils.TSHosts) {
|
||||
mak.Set(&cfg.Hosts, fqdn, ips)
|
||||
}
|
||||
if err = dnsRR.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS records: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *dnsRecordsReconciler) maybeCleanup(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) error {
|
||||
ix := slices.Index(eps.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
if ix == -1 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return nil
|
||||
}
|
||||
cm := &corev1.ConfigMap{}
|
||||
err := h.Client.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: h.tsNamespace}, cm)
|
||||
if apierrors.IsNotFound(err) { // If the ConfigMap with the DNS config does not exist, just remove the finalizer
|
||||
logger.Debug("CM not found")
|
||||
return h.removeEPSFinalizer(ctx, eps)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving ConfigMap: %w", err)
|
||||
}
|
||||
_, ok := cm.Data[dnsConfigKey]
|
||||
if !ok {
|
||||
logger.Debug("config key not found")
|
||||
return h.removeEPSFinalizer(ctx, eps)
|
||||
}
|
||||
fqdn, ok := eps.GetAnnotations()[annotationTSMagicDNSName]
|
||||
if !ok || fqdn == "" {
|
||||
return h.removeEPSFinalizer(ctx, eps)
|
||||
}
|
||||
logger.Infof("removing DNS record for MagicDNS name %s", fqdn)
|
||||
updateFunc := func(cfg *operatorutils.TSHosts) {
|
||||
delete(cfg.Hosts, fqdn)
|
||||
}
|
||||
if err = h.updateDNSConfig(ctx, updateFunc); err != nil {
|
||||
return fmt.Errorf("error updating DNS config: %w", err)
|
||||
}
|
||||
return h.removeEPSFinalizer(ctx, eps)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) isHeadlessSvcForSupportedProxy(ctx context.Context, svc *corev1.Service) (bool, error) {
|
||||
if isManagedByType(svc, "ingress") {
|
||||
return true, nil
|
||||
}
|
||||
if !isManagedByType(svc, "svc") {
|
||||
return false, nil
|
||||
}
|
||||
parentNSName := parentFromObjectLabels(svc)
|
||||
parentSvc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, parentNSName, parentSvc); err != nil {
|
||||
return false, fmt.Errorf("error retrieving parent Service: %w", err)
|
||||
}
|
||||
if ip := tailnetTargetAnnotation(parentSvc); ip != "" {
|
||||
return true, nil // egress Service
|
||||
}
|
||||
if _, ok := parentSvc.GetAnnotations()[AnnotationTailnetTargetFQDN]; ok {
|
||||
return true, nil // egress Service
|
||||
}
|
||||
return false, nil // ingress Service
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) removeEPSFinalizer(ctx context.Context, eps *discoveryv1.EndpointSlice) error {
|
||||
idx := slices.Index(eps.Finalizers, dnsRecordsRecocilerFinalizer)
|
||||
if idx == -1 {
|
||||
return nil
|
||||
}
|
||||
eps.Finalizers = append(eps.Finalizers[:idx], eps.Finalizers[idx+1:]...)
|
||||
return dnsRR.Update(ctx, eps)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecord(ctx context.Context, eps *discoveryv1.EndpointSlice, logger *zap.SugaredLogger) (string, error) {
|
||||
svcName, ok := eps.Labels[discoveryv1.LabelServiceName] // https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#ownership
|
||||
if !ok {
|
||||
logger.Debugf("EndpointSlice is not managed by a Service")
|
||||
return "", nil
|
||||
}
|
||||
maybeHeadlessSvc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, types.NamespacedName{Namespace: dnsRR.tsNamespace, Name: svcName}, maybeHeadlessSvc); err != nil {
|
||||
return "", fmt.Errorf("error retrieving owning Service for EndpointSlice: %w", err)
|
||||
}
|
||||
parentName := parentFromObjectLabels(maybeHeadlessSvc)
|
||||
if isManagedByType(maybeHeadlessSvc, "ingress") {
|
||||
ing := new(networkingv1.Ingress)
|
||||
if err := dnsRR.Get(ctx, parentName, ing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ing.Status.LoadBalancer.Ingress) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return ing.Status.LoadBalancer.Ingress[0].Hostname, nil
|
||||
}
|
||||
if isManagedByType(maybeHeadlessSvc, "svc") {
|
||||
svc := new(corev1.Service)
|
||||
if err := dnsRR.Get(ctx, parentName, svc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dnsRR.fqdnForDNSRecordFromService(ctx, svc)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *dnsRecordsReconciler) updateDNSConfig(ctx context.Context, update func(*operatorutils.TSHosts)) error {
|
||||
cm := &corev1.ConfigMap{}
|
||||
if err := h.Client.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: h.tsNamespace}, cm); err != nil {
|
||||
return fmt.Errorf("error retrieving nameserver config: %w", err)
|
||||
}
|
||||
dnsCfg := operatorutils.TSHosts{Hosts: make(map[string][]string)}
|
||||
if cm.Data != nil && cm.Data[dnsConfigKey] != "" {
|
||||
if err := json.Unmarshal([]byte(cm.Data[dnsConfigKey]), &dnsCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
update(&dnsCfg)
|
||||
configBytes, err := json.Marshal(dnsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshalling DNS config: %w", err)
|
||||
}
|
||||
mak.Set(&cm.Data, dnsConfigKey, string(configBytes))
|
||||
return h.Update(ctx, cm)
|
||||
}
|
||||
|
||||
func (dnsRR *dnsRecordsReconciler) fqdnForDNSRecordFromService(ctx context.Context, svc *corev1.Service) (string, error) {
|
||||
if tailnetIP := tailnetTargetAnnotation(svc); tailnetIP != "" {
|
||||
return dnsRR.tailnetFQDNForIP(ctx, tailnetIP)
|
||||
}
|
||||
if tailnetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]; tailnetFQDN != "" {
|
||||
return tailnetFQDN, nil
|
||||
}
|
||||
if hasLoadBalancerClass(svc, dnsRR.isDefaultLoadBalancer) {
|
||||
if len(svc.Status.LoadBalancer.Ingress) > 0 {
|
||||
return svc.Status.LoadBalancer.Ingress[0].Hostname, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if hasExposeAnnotation(svc) {
|
||||
return dnsRR.fqdnFromSecretData(ctx, svc)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *dnsRecordsReconciler) tailnetFQDNForIP(ctx context.Context, ip string) (string, error) {
|
||||
whois, err := h.localAPIClient.WhoIs(ctx, ip)
|
||||
if err != nil {
|
||||
h.logger.Errorf("error determining Tailscale node: %v", err)
|
||||
return "", err
|
||||
}
|
||||
fqdn := whois.Node.Name
|
||||
fqdn = strings.TrimSuffix(fqdn, ".")
|
||||
return fqdn, nil
|
||||
}
|
||||
|
||||
func (h *dnsRecordsReconciler) fqdnFromSecretData(ctx context.Context, svc *corev1.Service) (string, error) {
|
||||
childResourceLabels := map[string]string{
|
||||
LabelManaged: "true",
|
||||
LabelParentName: svc.Name,
|
||||
LabelParentNamespace: svc.Namespace,
|
||||
LabelParentType: "svc",
|
||||
}
|
||||
secret, err := getSingleObject[corev1.Secret](ctx, h.Client, h.tsNamespace, childResourceLabels)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(secret.Data["device_fqdn"]), nil
|
||||
}
|
@ -68,7 +68,7 @@ func (a *IngressReconciler) Reconcile(ctx context.Context, req reconcile.Request
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get ing: %w", err)
|
||||
}
|
||||
if !ing.DeletionTimestamp.IsZero() || !a.shouldExpose(ing) {
|
||||
if !ing.DeletionTimestamp.IsZero() || !isTailscaleIngress(ing) {
|
||||
logger.Debugf("ingress is being deleted or should not be exposed, cleaning up")
|
||||
return reconcile.Result{}, a.maybeCleanup(ctx, logger, ing)
|
||||
}
|
||||
@ -291,7 +291,7 @@ func (a *IngressReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *IngressReconciler) shouldExpose(ing *networkingv1.Ingress) bool {
|
||||
func isTailscaleIngress(ing *networkingv1.Ingress) bool {
|
||||
return ing != nil &&
|
||||
ing.Spec.IngressClassName != nil &&
|
||||
*ing.Spec.IngressClassName == tailscaleIngressClassName
|
||||
|
293
cmd/k8s-operator/nameserver.go
Normal file
293
cmd/k8s-operator/nameserver.go
Normal file
@ -0,0 +1,293 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
// tailscale-operator provides a way to expose services running in a Kubernetes
|
||||
// cluster to your Tailnet and to make Tailscale nodes available to cluster
|
||||
// workloads
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
xslices "golang.org/x/exp/slices"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiequality "k8s.io/apimachinery/pkg/api/equality"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
"sigs.k8s.io/yaml"
|
||||
tsoperator "tailscale.com/k8s-operator"
|
||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||
"tailscale.com/tstime"
|
||||
"tailscale.com/types/ptr"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
type deployable struct {
|
||||
yaml []byte
|
||||
obj client.Object
|
||||
objTemplate func() client.Object
|
||||
updateObj func(client.Object, deployCfg) (client.Object, error)
|
||||
getPatch func(client.Object, deployCfg) (client.Patch, error)
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed deploy/manifests/nameserver/cm.yaml
|
||||
cmYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/deploy.yaml
|
||||
deployYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/sa.yaml
|
||||
saYaml []byte
|
||||
//go:embed deploy/manifests/nameserver/svc.yaml
|
||||
svcYaml []byte
|
||||
|
||||
cmDeployable = deployable{
|
||||
yaml: cmYaml,
|
||||
objTemplate: func() client.Object {
|
||||
return &corev1.ConfigMap{TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"}}
|
||||
},
|
||||
obj: &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "ConfigMap", APIVersion: "v1"},
|
||||
},
|
||||
getPatch: func(obj client.Object, _ deployCfg) (client.Patch, error) { return client.MergeFrom(obj), nil },
|
||||
updateObj: func(obj client.Object, _ deployCfg) (client.Object, error) { return obj, nil },
|
||||
}
|
||||
deployDeployable = deployable{
|
||||
yaml: deployYaml,
|
||||
obj: &appsv1.Deployment{
|
||||
TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()},
|
||||
},
|
||||
objTemplate: func() client.Object {
|
||||
return &appsv1.Deployment{TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: appsv1.SchemeGroupVersion.Identifier()}}
|
||||
},
|
||||
getPatch: func(o client.Object, cfg deployCfg) (client.Patch, error) {
|
||||
deploy, ok := o.(*appsv1.Deployment)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to convert obj to Deployment")
|
||||
}
|
||||
deploy.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
|
||||
return client.MergeFrom(deploy), nil
|
||||
},
|
||||
updateObj: func(obj client.Object, cfg deployCfg) (client.Object, error) {
|
||||
deploy, ok := obj.(*appsv1.Deployment)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to convert obj to Deployment")
|
||||
}
|
||||
deploy.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("%s:%s", cfg.imageRepo, cfg.imageTag)
|
||||
return deploy, nil
|
||||
},
|
||||
}
|
||||
saDeployable = deployable{
|
||||
yaml: saYaml,
|
||||
obj: &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}},
|
||||
getPatch: func(obj client.Object, _ deployCfg) (client.Patch, error) { return client.MergeFrom(obj), nil },
|
||||
updateObj: func(obj client.Object, _ deployCfg) (client.Object, error) { return obj, nil },
|
||||
objTemplate: func() client.Object {
|
||||
return &corev1.ServiceAccount{TypeMeta: metav1.TypeMeta{Kind: "ServiceAccount", APIVersion: "v1"}}
|
||||
},
|
||||
}
|
||||
svcDeployable = deployable{
|
||||
yaml: svcYaml,
|
||||
obj: &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}},
|
||||
getPatch: func(obj client.Object, _ deployCfg) (client.Patch, error) { return client.MergeFrom(obj), nil },
|
||||
updateObj: func(obj client.Object, _ deployCfg) (client.Object, error) { return obj, nil },
|
||||
objTemplate: func() client.Object {
|
||||
return &corev1.Service{TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type patch struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (p patch) Data(client.Object) []byte {
|
||||
return p.data
|
||||
}
|
||||
func (p patch) Type() types.PatchType {
|
||||
return types.ApplyPatchType
|
||||
}
|
||||
|
||||
const (
|
||||
reasonNameserverCreationFailed = "NameserverCreationFailed"
|
||||
reasonMultipleDNSConfigsPresent = "MultipleDNSConfigsPresent"
|
||||
|
||||
reasonNameserverCreated = "NameserverCreated"
|
||||
|
||||
messageNameserverCreationFailed = "Failed creating nameserver resources: %v"
|
||||
messageMultipleDNSConfigsPresent = "Multiple DNSConfig resources found in cluster. Please ensure no more than one is present."
|
||||
)
|
||||
|
||||
type NameserverReconciler struct {
|
||||
client.Client
|
||||
logger *zap.SugaredLogger
|
||||
recorder record.EventRecorder
|
||||
clock tstime.Clock
|
||||
tsNamespace string
|
||||
|
||||
mu sync.Mutex // protects following
|
||||
managedNameservers set.Slice[types.UID] // one or none
|
||||
}
|
||||
|
||||
var (
|
||||
gaugeNameserverResources = clientmetric.NewGauge("k8s_nameserver_resources")
|
||||
)
|
||||
|
||||
func (a *NameserverReconciler) Reconcile(ctx context.Context, req reconcile.Request) (res reconcile.Result, err error) {
|
||||
logger := a.logger.With("dnsConfig", req.Name)
|
||||
logger.Debugf("starting reconcile")
|
||||
defer logger.Debugf("reconcile finished")
|
||||
|
||||
// get the dnsconfig in question
|
||||
var dnsCfg tsapi.DNSConfig
|
||||
err = a.Get(ctx, req.NamespacedName, &dnsCfg)
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Request object not found, could have been deleted after reconcile request.
|
||||
logger.Debugf("dnsconfig not found, assuming it was deleted")
|
||||
return reconcile.Result{}, nil
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get dnsconfig: %w", err)
|
||||
}
|
||||
if !dnsCfg.DeletionTimestamp.IsZero() {
|
||||
logger.Debugf("DNSConfig is being deleted, cleaning up resources")
|
||||
ix := xslices.Index(dnsCfg.Finalizers, FinalizerName)
|
||||
if ix < 0 {
|
||||
logger.Debugf("no finalizer, nothing to do")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
if err := a.maybeCleanup(ctx, &dnsCfg, logger); err != nil {
|
||||
logger.Errorf("error cleaning up reconciler resource: %v", err)
|
||||
return res, err
|
||||
}
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers[:ix], dnsCfg.Finalizers[ix+1:]...)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
logger.Errorf("error removing finalizer: %v", err)
|
||||
return reconcile.Result{}, err
|
||||
}
|
||||
logger.Infof("Nameserver resources cleaned up")
|
||||
return reconcile.Result{}, nil
|
||||
}
|
||||
|
||||
oldCnStatus := dnsCfg.Status.DeepCopy()
|
||||
setStatus := func(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string) (reconcile.Result, error) {
|
||||
tsoperator.SetDNSConfigCondition(dnsCfg, tsapi.NameserverReady, status, reason, message, dnsCfg.Generation, a.clock, logger)
|
||||
if !apiequality.Semantic.DeepEqual(oldCnStatus, dnsCfg.Status) {
|
||||
// An error encountered here should get returned by the Reconcile function.
|
||||
if updateErr := a.Client.Status().Update(ctx, dnsCfg); updateErr != nil {
|
||||
err = errors.Wrap(err, updateErr.Error())
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
var dnsCfgs tsapi.DNSConfigList
|
||||
if err := a.List(ctx, &dnsCfgs); err != nil {
|
||||
return res, fmt.Errorf("error listing DNSConfigs: %w", err)
|
||||
}
|
||||
if len(dnsCfgs.Items) > 1 {
|
||||
msg := "invalid cluster configuration: more than one tailscale.com/dnsconfigs found. Please ensure that no more than one is created."
|
||||
logger.Error(msg)
|
||||
a.recorder.Event(&dnsCfg, corev1.EventTypeWarning, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonMultipleDNSConfigsPresent, messageMultipleDNSConfigsPresent)
|
||||
}
|
||||
|
||||
if !slices.Contains(dnsCfg.Finalizers, FinalizerName) {
|
||||
logger.Infof("ensuring nameserver resources")
|
||||
dnsCfg.Finalizers = append(dnsCfg.Finalizers, FinalizerName)
|
||||
if err := a.Update(ctx, &dnsCfg); err != nil {
|
||||
msg := fmt.Sprintf(messageNameserverCreationFailed, err)
|
||||
logger.Error(msg)
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionFalse, reasonNameserverCreationFailed, msg)
|
||||
}
|
||||
}
|
||||
if err := a.maybeProvision(ctx, &dnsCfg, logger); err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("error provisioning nameserver resources: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Add(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "nameserver", Namespace: a.tsNamespace},
|
||||
}
|
||||
if err := a.Client.Get(ctx, client.ObjectKeyFromObject(svc), svc); err != nil {
|
||||
return res, fmt.Errorf("error getting Service: %w", err)
|
||||
}
|
||||
if ip := svc.Spec.ClusterIP; ip != "" && ip != "None" {
|
||||
dnsCfg.Status.NameserverStatus = &tsapi.NameserverStatus{
|
||||
IP: ip,
|
||||
}
|
||||
return setStatus(&dnsCfg, tsapi.NameserverReady, metav1.ConditionTrue, reasonNameserverCreated, reasonNameserverCreated)
|
||||
}
|
||||
logger.Info("nameserver Service does not yet have an IP address, waiting..")
|
||||
return reconcile.Result{Requeue: true}, nil
|
||||
}
|
||||
|
||||
type deployCfg struct {
|
||||
imageRepo string
|
||||
imageTag string
|
||||
}
|
||||
|
||||
func (a *NameserverReconciler) maybeProvision(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
crl := childResourceLabels(dnsCfg.Name, a.tsNamespace, "nameserver")
|
||||
cfg := deployCfg{
|
||||
imageRepo: "tailscale/k8s-nameserver",
|
||||
imageTag: "unstable",
|
||||
}
|
||||
if dnsCfg.Spec.Nameserver.Image.Repo != "" {
|
||||
cfg.imageRepo = dnsCfg.Spec.Nameserver.Image.Repo
|
||||
}
|
||||
if dnsCfg.Spec.Nameserver.Image.Tag != "" {
|
||||
cfg.imageTag = dnsCfg.Spec.Nameserver.Image.Tag
|
||||
}
|
||||
for _, deployable := range []deployable{cmDeployable, saDeployable, svcDeployable, deployDeployable} {
|
||||
obj := deployable.objTemplate()
|
||||
if err := yaml.Unmarshal(deployable.yaml, obj); err != nil {
|
||||
return fmt.Errorf("error unmarshalling yaml: %w", err)
|
||||
}
|
||||
obj.SetLabels(crl)
|
||||
obj.SetNamespace(a.tsNamespace)
|
||||
obj.SetOwnerReferences([]metav1.OwnerReference{*metav1.NewControllerRef(dnsCfg, tsapi.SchemeGroupVersion.WithKind("DNSConfig"))})
|
||||
obj, err := deployable.updateObj(obj, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating object of kind: %s", obj.GetObjectKind().GroupVersionKind().Kind)
|
||||
}
|
||||
bs, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling object: %s", obj.GetObjectKind().GroupVersionKind().Kind)
|
||||
}
|
||||
patch := client.RawPatch(types.ApplyPatchType, bs)
|
||||
logger.Infof("about to apply patch for group: %s, kind: %s, version: %s", obj.GetObjectKind().GroupVersionKind().Group, obj.DeepCopyObject().GetObjectKind().GroupVersionKind().Kind, obj.GetObjectKind().GroupVersionKind().Version)
|
||||
if err := a.Client.Patch(ctx, obj, patch, &client.PatchOptions{
|
||||
Force: ptr.To(true),
|
||||
FieldManager: "nameserver-reconciler",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error patching resource: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NameserverReconciler) maybeCleanup(ctx context.Context, dnsCfg *tsapi.DNSConfig, logger *zap.SugaredLogger) error {
|
||||
a.mu.Lock()
|
||||
a.managedNameservers.Remove(dnsCfg.UID)
|
||||
a.mu.Unlock()
|
||||
gaugeNameserverResources.Set(int64(a.managedNameservers.Len()))
|
||||
return nil
|
||||
}
|
@ -10,7 +10,6 @@
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -20,6 +19,7 @@
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
discoveryv1 "k8s.io/api/discovery/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/rest"
|
||||
@ -221,7 +221,11 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
&corev1.Secret{}: nsFilter,
|
||||
&corev1.ServiceAccount{}: nsFilter,
|
||||
&corev1.ConfigMap{}: nsFilter,
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
&appsv1.Deployment{}: nsFilter,
|
||||
&discoveryv1.EndpointSlice{}: nsFilter,
|
||||
},
|
||||
},
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
@ -291,7 +295,63 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
startlog.Fatalf("could not create connector reconciler: %v", err)
|
||||
}
|
||||
// TODO (irbekrm): switch to metadata-only watches for resources whose
|
||||
// spec we don't need to inspect to reduce memory consumption
|
||||
// https://github.com/kubernetes-sigs/controller-runtime/issues/1159
|
||||
nameserverFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("nameserver"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.DNSConfig{}).
|
||||
Watches(&appsv1.Deployment{}, nameserverFilter).
|
||||
Watches(&corev1.ConfigMap{}, nameserverFilter).
|
||||
Watches(&corev1.Service{}, nameserverFilter).
|
||||
Watches(&corev1.ServiceAccount{}, nameserverFilter).
|
||||
Complete(&NameserverReconciler{
|
||||
recorder: eventRecorder,
|
||||
tsNamespace: tsNamespace,
|
||||
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("nameserver-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create nameserver reconciler: %v", err)
|
||||
}
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
startlog.Fatalf("error retrieving local client: %w", err)
|
||||
}
|
||||
// On DNSConfig changes, reconcile all EndpointSlices in operator namespace.
|
||||
dnsConfigFilter := handler.EnqueueRequestsFromMapFunc(enqueueAllEndpointSlicesInNS(tsNamespace, mgr.GetClient()))
|
||||
// On Secret changes, if it has the tailscale labels and is for an
|
||||
// ingress/egress proxy, reconcile the EndpointSlice for the proxy's
|
||||
// headless Service. We need to watch Secrets because this is where the
|
||||
// dns-records-reconciler reads the MagicDNS name from for ingress
|
||||
// proxies exposed via an annotation.
|
||||
epsForSecretFilter := handler.EnqueueRequestsFromMapFunc(enqueueEndpointSliceForSecret(tsNamespace, mgr.GetClient()))
|
||||
// The only Service changes the dns-records-reconciler is interested in
|
||||
// are changes to svc.status.loadBalancer.ingress.hostname, so only
|
||||
// reconcile proxy EndpointSlices associated with LoadBalancer Services
|
||||
// exposed via Tailscale.
|
||||
epsForServiceFilter := handler.EnqueueRequestsFromMapFunc(enqueueEndpointSliceForService(tsNamespace, mgr.GetClient(), startlog, isDefaultLoadBalancer))
|
||||
// If a tailscale Ingress changes, reconcile the EndpointSlice for the proxy's headless Service.
|
||||
epsForIngressFilter := handler.EnqueueRequestsFromMapFunc(enqueueEndpointSliceForIngress(tsNamespace, mgr.GetClient(), startlog))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&discoveryv1.EndpointSlice{}).
|
||||
Watches(&tsapi.DNSConfig{}, dnsConfigFilter).
|
||||
Watches(&corev1.Secret{}, epsForSecretFilter).
|
||||
Watches(&corev1.Service{}, epsForServiceFilter).
|
||||
Watches(&networkingv1.Ingress{}, epsForIngressFilter).
|
||||
Complete(&dnsRecordsReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
tsNamespace: tsNamespace,
|
||||
localAPIClient: lc,
|
||||
logger: zlog.Named("dns-records-reconciler"),
|
||||
isDefaultLoadBalancer: isDefaultLoadBalancer,
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not create DNS records reconciler: %v", err)
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
@ -330,14 +390,87 @@ func managedResourceHandlerForType(typ string) handler.MapFunc {
|
||||
{NamespacedName: parentFromObjectLabels(o)},
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func enqueueAllEndpointSlicesInNS(ns string, cl client.Reader) handler.MapFunc {
|
||||
return func(ctx context.Context, _ client.Object) []reconcile.Request {
|
||||
eps := &discoveryv1.EndpointSliceList{}
|
||||
if err := cl.List(ctx, eps, client.InNamespace(ns)); err != nil {
|
||||
return nil
|
||||
}
|
||||
reqs := make([]reconcile.Request, 0)
|
||||
for _, ep := range eps.Items {
|
||||
reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ep.Namespace, Name: ep.Name}})
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueEndpointSliceForSecret(ns string, cl client.Client) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
if !isManagedByType(o, "ingress") && !isManagedByType(o, "svc") {
|
||||
return nil
|
||||
}
|
||||
svcName := o.GetName()[:strings.LastIndexAny(o.GetName(), "-")] // secret name is <service-name>-0
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, cl, ns, map[string]string{discoveryv1.LabelServiceName: svcName})
|
||||
if err != nil || eps == nil {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: eps.Namespace, Name: eps.Name}}}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueEndpointSliceForService(ns string, cl client.Client, log *zap.SugaredLogger, isDefaultLoadBalancerClass bool) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
svc, ok := o.(*corev1.Service)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !hasLoadBalancerClass(svc, isDefaultLoadBalancerClass) {
|
||||
return nil
|
||||
}
|
||||
crl := childResourceLabels(svc.Name, svc.Namespace, "svc")
|
||||
return endpointSliceRequests(ctx, cl, ns, crl)
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueEndpointSliceForIngress(ns string, cl client.Client, log *zap.SugaredLogger) handler.MapFunc {
|
||||
return func(ctx context.Context, o client.Object) []reconcile.Request {
|
||||
ing, ok := o.(*networkingv1.Ingress)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !isTailscaleIngress(ing) {
|
||||
return nil
|
||||
}
|
||||
crl := childResourceLabels(ing.Name, ing.Namespace, "ingress")
|
||||
return endpointSliceRequests(ctx, cl, ns, crl)
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSliceRequests(ctx context.Context, cl client.Client, ns string, crl map[string]string) []reconcile.Request {
|
||||
// TODO (irbekrm): experiment with indexing endpoint slices in
|
||||
// cache so that they can be directly filtered for a parent
|
||||
// Service- this might be more efficient than filtering than
|
||||
// getting the headless Service each time.
|
||||
svc, err := getSingleObject[corev1.Service](ctx, cl, ns, crl) // get headless Service for proxy
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
epsLabels := map[string]string{discoveryv1.LabelServiceName: svc.Name}
|
||||
eps, err := getSingleObject[discoveryv1.EndpointSlice](ctx, cl, ns, epsLabels)
|
||||
if err != nil || eps == nil {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: eps.Namespace, Name: eps.Name}}}
|
||||
}
|
||||
func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
if isManagedByType(o, "svc") {
|
||||
// If this is a Service managed by a Service we want to enqueue its parent
|
||||
return []reconcile.Request{{NamespacedName: parentFromObjectLabels(o)}}
|
||||
|
||||
}
|
||||
if isManagedResource(o) {
|
||||
// If this is a Servce managed by a resource that is not a Service, we leave it alone
|
||||
@ -352,12 +485,4 @@ func serviceHandler(_ context.Context, o client.Object) []reconcile.Request {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or
|
||||
// without final dot).
|
||||
func isMagicDNSName(name string) bool {
|
||||
validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`)
|
||||
return validMagicDNSName.MatchString(name)
|
||||
}
|
||||
|
@ -20,6 +20,7 @@
|
||||
"k8s.io/client-go/tools/record"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
operatorutils "tailscale.com/k8s-operator"
|
||||
"tailscale.com/util/clientmetric"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
@ -80,7 +81,7 @@ func (a *ServiceReconciler) Reconcile(ctx context.Context, req reconcile.Request
|
||||
} else if err != nil {
|
||||
return reconcile.Result{}, fmt.Errorf("failed to get svc: %w", err)
|
||||
}
|
||||
targetIP := a.tailnetTargetAnnotation(svc)
|
||||
targetIP := tailnetTargetAnnotation(svc)
|
||||
targetFQDN := svc.Annotations[AnnotationTailnetTargetFQDN]
|
||||
if !svc.DeletionTimestamp.IsZero() || !a.shouldExpose(svc) && targetIP == "" && targetFQDN == "" {
|
||||
logger.Debugf("service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
|
||||
@ -190,7 +191,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga
|
||||
sts.ClusterTargetIP = svc.Spec.ClusterIP
|
||||
a.managedIngressProxies.Add(svc.UID)
|
||||
gaugeIngressProxies.Set(int64(a.managedIngressProxies.Len()))
|
||||
} else if ip := a.tailnetTargetAnnotation(svc); ip != "" {
|
||||
} else if ip := tailnetTargetAnnotation(svc); ip != "" {
|
||||
sts.TailnetTargetIP = ip
|
||||
a.managedEgressProxies.Add(svc.UID)
|
||||
gaugeEgressProxies.Set(int64(a.managedEgressProxies.Len()))
|
||||
@ -275,7 +276,7 @@ func validateService(svc *corev1.Service) []string {
|
||||
violations = append(violations, "only one of annotations %s and %s can be set", AnnotationTailnetTargetIP, AnnotationTailnetTargetFQDN)
|
||||
}
|
||||
if fqdn := svc.Annotations[AnnotationTailnetTargetFQDN]; fqdn != "" {
|
||||
if !isMagicDNSName(fqdn) {
|
||||
if !operatorutils.IsMagicDNSName(fqdn) {
|
||||
violations = append(violations, fmt.Sprintf("invalid value of annotation %s: %q does not appear to be a valid MagicDNS name", AnnotationTailnetTargetFQDN, fqdn))
|
||||
}
|
||||
}
|
||||
@ -289,7 +290,7 @@ func (a *ServiceReconciler) shouldExpose(svc *corev1.Service) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.hasLoadBalancerClass(svc) || a.hasExposeAnnotation(svc)
|
||||
return hasLoadBalancerClass(svc, a.isDefaultLoadBalancer) || hasExposeAnnotation(svc)
|
||||
}
|
||||
|
||||
func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
|
||||
@ -301,7 +302,7 @@ func (a *ServiceReconciler) hasLoadBalancerClass(svc *corev1.Service) bool {
|
||||
|
||||
// hasExposeAnnotation reports whether Service has the tailscale.com/expose
|
||||
// annotation set
|
||||
func (a *ServiceReconciler) hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
func hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
return svc != nil && svc.Annotations[AnnotationExpose] == "true"
|
||||
}
|
||||
|
||||
@ -309,7 +310,7 @@ func (a *ServiceReconciler) hasExposeAnnotation(svc *corev1.Service) bool {
|
||||
// annotation or of the deprecated tailscale.com/ts-tailnet-target-ip
|
||||
// annotation. If neither is set, it returns an empty string. If both are set,
|
||||
// it returns the value of the new annotation.
|
||||
func (a *ServiceReconciler) tailnetTargetAnnotation(svc *corev1.Service) string {
|
||||
func tailnetTargetAnnotation(svc *corev1.Service) string {
|
||||
if svc == nil {
|
||||
return ""
|
||||
}
|
||||
@ -318,3 +319,10 @@ func (a *ServiceReconciler) tailnetTargetAnnotation(svc *corev1.Service) string
|
||||
}
|
||||
return svc.Annotations[annotationTailnetTargetIPOld]
|
||||
}
|
||||
|
||||
func hasLoadBalancerClass(svc *corev1.Service, isDefaultLoadBalancer bool) bool {
|
||||
return svc != nil &&
|
||||
svc.Spec.Type == corev1.ServiceTypeLoadBalancer &&
|
||||
(svc.Spec.LoadBalancerClass != nil && *svc.Spec.LoadBalancerClass == "tailscale" ||
|
||||
svc.Spec.LoadBalancerClass == nil && isDefaultLoadBalancer)
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func init() {
|
||||
|
||||
// Adds the list of known types to api.Scheme.
|
||||
func addKnownTypes(scheme *runtime.Scheme) error {
|
||||
scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{})
|
||||
scheme.AddKnownTypes(SchemeGroupVersion, &Connector{}, &ConnectorList{}, &DNSConfig{}, &DNSConfigList{})
|
||||
|
||||
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
|
||||
return nil
|
||||
|
72
k8s-operator/apis/v1alpha1/types_tsdnsconfig.go
Normal file
72
k8s-operator/apis/v1alpha1/types_tsdnsconfig.go
Normal file
@ -0,0 +1,72 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// Code comments on these types should be treated as user facing documentation-
|
||||
// they will appear on the DNSConfig CRD i.e if someone runs kubectl explain dnsconfig.
|
||||
|
||||
var DNSConfigKind = "DNSConfig"
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster,shortName=dc
|
||||
// +kubebuilder:printcolumn:name="NameserverIP",type="string",JSONPath=`.status.nameserverStatus.ip`,description="Status of the deployed Connector resources."
|
||||
|
||||
type DNSConfig struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec DNSConfigSpec `json:"spec"`
|
||||
|
||||
// +optional
|
||||
Status DNSConfigStatus `json:"status"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
type DNSConfigList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata"`
|
||||
|
||||
Items []DNSConfig `json:"items"`
|
||||
}
|
||||
|
||||
type DNSConfigSpec struct {
|
||||
Nameserver *Nameserver `json:"nameserver"`
|
||||
}
|
||||
|
||||
type Nameserver struct {
|
||||
// +optional
|
||||
Image *Image `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
// +optional
|
||||
Repo string `json:"repo,omitempty"`
|
||||
// +optional
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
type DNSConfigStatus struct {
|
||||
// TODO: rename ConnectorCondition to sth like ComponentCondition
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []ConnectorCondition `json:"conditions"`
|
||||
// +optional
|
||||
NameserverStatus *NameserverStatus `json:"nameserverStatus"`
|
||||
}
|
||||
|
||||
type NameserverStatus struct {
|
||||
// +optional
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
const NameserverReady ConnectorConditionType = `NameserverReady`
|
@ -136,6 +136,162 @@ func (in *ConnectorStatus) DeepCopy() *ConnectorStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DNSConfig) DeepCopyInto(out *DNSConfig) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfig.
|
||||
func (in *DNSConfig) DeepCopy() *DNSConfig {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DNSConfig)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DNSConfig) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DNSConfigList) DeepCopyInto(out *DNSConfigList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]DNSConfig, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigList.
|
||||
func (in *DNSConfigList) DeepCopy() *DNSConfigList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DNSConfigList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *DNSConfigList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DNSConfigSpec) DeepCopyInto(out *DNSConfigSpec) {
|
||||
*out = *in
|
||||
if in.Nameserver != nil {
|
||||
in, out := &in.Nameserver, &out.Nameserver
|
||||
*out = new(Nameserver)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigSpec.
|
||||
func (in *DNSConfigSpec) DeepCopy() *DNSConfigSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DNSConfigSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DNSConfigStatus) DeepCopyInto(out *DNSConfigStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]ConnectorCondition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.NameserverStatus != nil {
|
||||
in, out := &in.NameserverStatus, &out.NameserverStatus
|
||||
*out = new(NameserverStatus)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSConfigStatus.
|
||||
func (in *DNSConfigStatus) DeepCopy() *DNSConfigStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DNSConfigStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Image) DeepCopyInto(out *Image) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Image.
|
||||
func (in *Image) DeepCopy() *Image {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Image)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Nameserver) DeepCopyInto(out *Nameserver) {
|
||||
*out = *in
|
||||
if in.Image != nil {
|
||||
in, out := &in.Image, &out.Image
|
||||
*out = new(Image)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Nameserver.
|
||||
func (in *Nameserver) DeepCopy() *Nameserver {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Nameserver)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *NameserverStatus) DeepCopyInto(out *NameserverStatus) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameserverStatus.
|
||||
func (in *NameserverStatus) DeepCopy() *NameserverStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(NameserverStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in Routes) DeepCopyInto(out *Routes) {
|
||||
{
|
||||
|
@ -19,6 +19,26 @@
|
||||
// given attributes. LastTransitionTime gets set every time condition's status
|
||||
// changes
|
||||
func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
|
||||
conds := updateCondition(cn.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
|
||||
cn.Status.Conditions = conds
|
||||
}
|
||||
|
||||
// RemoveConnectorCondition will remove condition of the given type
|
||||
func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConnectorConditionType) {
|
||||
conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond tsapi.ConnectorCondition) bool {
|
||||
return cond.Type == conditionType
|
||||
})
|
||||
}
|
||||
|
||||
// SetDNSConfigCondition ensures that DNSConfig status has a condition with the
|
||||
// given attributes. LastTransitionTime gets set every time condition's status
|
||||
// changes
|
||||
func SetDNSConfigCondition(dnsCfg *tsapi.DNSConfig, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) {
|
||||
conds := updateCondition(dnsCfg.Status.Conditions, conditionType, status, reason, message, gen, clock, logger)
|
||||
dnsCfg.Status.Conditions = conds
|
||||
}
|
||||
|
||||
func updateCondition(conds []tsapi.ConnectorCondition, conditionType tsapi.ConnectorConditionType, status metav1.ConditionStatus, reason, message string, gen int64, clock tstime.Clock, logger *zap.SugaredLogger) []tsapi.ConnectorCondition {
|
||||
newCondition := tsapi.ConnectorCondition{
|
||||
Type: conditionType,
|
||||
Status: status,
|
||||
@ -30,31 +50,34 @@ func SetConnectorCondition(cn *tsapi.Connector, conditionType tsapi.ConnectorCon
|
||||
nowTime := metav1.NewTime(clock.Now())
|
||||
newCondition.LastTransitionTime = &nowTime
|
||||
|
||||
idx := xslices.IndexFunc(cn.Status.Conditions, func(cond tsapi.ConnectorCondition) bool {
|
||||
idx := xslices.IndexFunc(conds, func(cond tsapi.ConnectorCondition) bool {
|
||||
return cond.Type == conditionType
|
||||
})
|
||||
|
||||
if idx == -1 {
|
||||
cn.Status.Conditions = append(cn.Status.Conditions, newCondition)
|
||||
return
|
||||
conds = append(conds, newCondition)
|
||||
return conds
|
||||
}
|
||||
|
||||
// Update the existing condition
|
||||
cond := cn.Status.Conditions[idx]
|
||||
cond := conds[idx]
|
||||
// If this update doesn't contain a state transition, we don't update
|
||||
// the conditions LastTransitionTime to Now()
|
||||
if cond.Status == status {
|
||||
newCondition.LastTransitionTime = cond.LastTransitionTime
|
||||
} else {
|
||||
logger.Info("Status change for Connector condition %s from %s to %s", conditionType, cond.Status, status)
|
||||
logger.Info("Status change for condition %s from %s to %s", conditionType, cond.Status, status)
|
||||
}
|
||||
return conds
|
||||
}
|
||||
|
||||
cn.Status.Conditions[idx] = newCondition
|
||||
}
|
||||
|
||||
// RemoveConnectorCondition will remove condition of the given type
|
||||
func RemoveConnectorCondition(conn *tsapi.Connector, conditionType tsapi.ConnectorConditionType) {
|
||||
conn.Status.Conditions = slices.DeleteFunc(conn.Status.Conditions, func(cond tsapi.ConnectorCondition) bool {
|
||||
return cond.Type == conditionType
|
||||
func DNSCfgIsReady(cfg *tsapi.DNSConfig) bool {
|
||||
idx := xslices.IndexFunc(cfg.Status.Conditions, func(cond tsapi.ConnectorCondition) bool {
|
||||
return cond.Type == tsapi.NameserverReady
|
||||
})
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
cond := cfg.Status.Conditions[idx]
|
||||
return cond.Status == metav1.ConditionTrue && cond.ObservedGeneration == cfg.Generation
|
||||
}
|
||||
|
48
k8s-operator/tsdns.go
Normal file
48
k8s-operator/tsdns.go
Normal file
@ -0,0 +1,48 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package kube
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/dnsname"
|
||||
)
|
||||
|
||||
// TSHosts is a mapping of MagicDNS names to a list IPv4 or IPv6 addresses.
|
||||
type TSHosts struct {
|
||||
Hosts map[string][]string `json:"hosts"`
|
||||
}
|
||||
|
||||
func NewTSHosts(bs []byte, log logger.Logf) (*TSHosts, error) {
|
||||
cfg := &TSHosts{}
|
||||
if err := json.Unmarshal(bs, cfg); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling json bytes: %w", err)
|
||||
}
|
||||
// Validate the unmarshalled Hosts entries. In case of an invalid entry,
|
||||
// delete it and log an error, but do not invalidate the result.
|
||||
for key, val := range cfg.Hosts {
|
||||
fqdn, err := dnsname.ToFQDN(key)
|
||||
if err != nil {
|
||||
log("error parsing DNS name %s: %v, skipping", key, err)
|
||||
delete(cfg.Hosts, key)
|
||||
break
|
||||
}
|
||||
if !IsMagicDNSName(string(fqdn)) {
|
||||
log("DNS name %s is not a MagicDNS name, skipping", fqdn)
|
||||
delete(cfg.Hosts, key)
|
||||
break
|
||||
}
|
||||
for _, ip := range val {
|
||||
if _, err := netip.ParseAddr(ip); err != nil {
|
||||
log("IP %s is not a valid IP address, skipping", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
15
k8s-operator/utils.go
Normal file
15
k8s-operator/utils.go
Normal file
@ -0,0 +1,15 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !plan9
|
||||
|
||||
package kube
|
||||
|
||||
import "regexp"
|
||||
|
||||
// isMagicDNSName reports whether name is a full tailnet node FQDN (with or
|
||||
// without final dot).
|
||||
func IsMagicDNSName(name string) bool {
|
||||
validMagicDNSName := regexp.MustCompile(`^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.ts\.net\.?$`)
|
||||
return validMagicDNSName.MatchString(name)
|
||||
}
|
Loading…
Reference in New Issue
Block a user