cmd/containerboot: support egress to Tailscale Service FQDNs (#17493)

Adds support for targeting FQDNs that are a Tailscale Service. Uses the
same method of searching for Services as the tailscale configure
kubeconfig command. This fixes using the tailscale.com/tailnet-fqdn
annotation for Kubernetes Service when the specified FQDN is a Tailscale
Service.

Fixes #16534

Change-Id: I422795de76dc83ae30e7e757bc4fbd8eec21cc64

Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
Signed-off-by: Becky Pauley <becky@tailscale.com>
This commit is contained in:
Tom Proctor
2025-12-18 17:06:42 +00:00
committed by GitHub
parent eed5e95e27
commit bb3529fcd4
4 changed files with 126 additions and 58 deletions

View File

@@ -27,7 +27,6 @@ import (
"tailscale.com/kube/egressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/kube/kubetypes"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/util/linuxfw"
"tailscale.com/util/mak"
@@ -477,30 +476,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
return addrs, nil
}
var (
node tailcfg.NodeView
nodeFound bool
)
for _, nn := range n.NetMap.Peers {
if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) {
node = nn
nodeFound = true
break
}
egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
if err != nil {
return nil, fmt.Errorf("error fetching backend addresses for %q: %w", svc.TailnetTarget.FQDN, err)
}
if nodeFound {
for _, addr := range node.Addresses().AsSlice() {
if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
continue
}
addrs = append(addrs, addr.Addr())
}
// Egress target endpoints configured via FQDN are stored, so
// that we can determine if a netmap update should trigger a
// resync.
mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice())
if len(egressAddrs) == 0 {
log.Printf("tailnet target %q does not have any backend addresses, skipping", svc.TailnetTarget.FQDN)
return addrs, nil
}
for _, addr := range egressAddrs {
if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
continue
}
addrs = append(addrs, addr.Addr())
}
// Egress target endpoints configured via FQDN are stored, so
// that we can determine if a netmap update should trigger a
// resync.
mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, egressAddrs)
return addrs, nil
}

View File

@@ -127,8 +127,10 @@ import (
"tailscale.com/kube/services"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/netmap"
"tailscale.com/types/ptr"
"tailscale.com/util/deephash"
"tailscale.com/util/dnsname"
"tailscale.com/util/linuxfw"
)
@@ -526,27 +528,14 @@ runLoop:
}
}
if cfg.TailnetTargetFQDN != "" {
var (
egressAddrs []netip.Prefix
newCurentEgressIPs deephash.Sum
egressIPsHaveChanged bool
node tailcfg.NodeView
nodeFound bool
)
for _, n := range n.NetMap.Peers {
if strings.EqualFold(n.Name(), cfg.TailnetTargetFQDN) {
node = n
nodeFound = true
break
}
}
if !nodeFound {
log.Printf("Tailscale node %q not found; it either does not exist, or not reachable because of ACLs", cfg.TailnetTargetFQDN)
egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
if err != nil {
log.Print(err.Error())
break
}
egressAddrs = node.Addresses().AsSlice()
newCurentEgressIPs = deephash.Hash(&egressAddrs)
egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
newCurentEgressIPs := deephash.Hash(&egressAddrs)
egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
// The firewall rules get (re-)installed:
// - on startup
// - when the tailnet IPs of the tailnet target have changed
@@ -892,3 +881,65 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
return errors.Join(err, ln.Close())
}
}
// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
// can be either a peer device or a Tailscale Service.
func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
dnsFQDN, err := dnsname.ToFQDN(fqdn)
if err != nil {
return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
}
// Check all peer devices first.
for _, p := range nm.Peers {
if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
return p.Addresses().AsSlice(), nil
}
}
// If not found yet, check for a matching Tailscale Service.
if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
return svcIPs, nil
}
return nil, fmt.Errorf("could not find Tailscale node or service %q; it either does not exist, or not reachable because of ACLs", fqdn)
}
// serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is
// found in the netmap. Note that Tailscale Services are not a first-class
// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
var extraRecords []tailcfg.DNSRecord
for _, rec := range nm.DNS.ExtraRecords {
recFQDN, err := dnsname.ToFQDN(rec.Name)
if err != nil {
continue
}
if strings.EqualFold(fqdn.WithTrailingDot(), recFQDN.WithTrailingDot()) {
extraRecords = append(extraRecords, rec)
}
}
if len(extraRecords) == 0 {
return nil
}
// Validate we can see a peer advertising the Tailscale Service.
var prefixes []netip.Prefix
for _, extraRecord := range extraRecords {
ip, err := netip.ParseAddr(extraRecord.Value)
if err != nil {
continue
}
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
for _, ps := range nm.Peers {
for _, allowedIP := range ps.AllowedIPs().All() {
if allowedIP == ipPrefix {
prefixes = append(prefixes, ipPrefix)
}
}
}
}
return prefixes
}

View File

@@ -46,7 +46,7 @@ func TestContainerBoot(t *testing.T) {
if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
t.Fatalf("Building containerboot: %v", err)
}
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net")
egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
metricsURL := func(port int) string {
return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
@@ -99,7 +99,7 @@ func TestContainerBoot(t *testing.T) {
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net",
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
@@ -356,7 +356,7 @@ func TestContainerBoot(t *testing.T) {
return testCase{
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net", // resolves to IPv6 address
"TS_TAILNET_TARGET_FQDN": "ipv6-node.test.ts.net.", // resolves to IPv6 address
"TS_USERSPACE": "false",
"TS_TEST_FAKE_NETFILTER_6": "false",
},
@@ -377,13 +377,13 @@ func TestContainerBoot(t *testing.T) {
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net",
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
StableID: tailcfg.StableNodeID("ipv6ID"),
Name: "ipv6-node.test.ts.net",
Name: "ipv6-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
}).View(),
},
@@ -481,7 +481,7 @@ func TestContainerBoot(t *testing.T) {
Notify: runningNotify,
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
@@ -580,7 +580,7 @@ func TestContainerBoot(t *testing.T) {
"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
},
WantKubeSecret: map[string]string{
"device_fqdn": "test-node.test.ts.net",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
@@ -613,7 +613,7 @@ func TestContainerBoot(t *testing.T) {
Notify: runningNotify,
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
@@ -625,14 +625,14 @@ func TestContainerBoot(t *testing.T) {
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("newID"),
Name: "new-name.test.ts.net",
Name: "new-name.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
},
},
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "new-name.test.ts.net",
"device_fqdn": "new-name.test.ts.net.",
"device_id": "newID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
@@ -927,7 +927,7 @@ func TestContainerBoot(t *testing.T) {
Notify: runningNotify,
WantKubeSecret: map[string]string{
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
"https_endpoint": "no-https",
@@ -963,11 +963,27 @@ func TestContainerBoot(t *testing.T) {
},
},
{
Notify: runningNotify,
Notify: &ipn.Notify{
State: ptr.To(ipn.Running),
NetMap: &netmap.NetworkMap{
SelfNode: (&tailcfg.Node{
StableID: tailcfg.StableNodeID("myID"),
Name: "test-node.test.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
}).View(),
Peers: []tailcfg.NodeView{
(&tailcfg.Node{
StableID: tailcfg.StableNodeID("fooID"),
Name: "foo.tailnetxyz.ts.net.",
Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
}).View(),
},
},
},
WantKubeSecret: map[string]string{
"egress-services": string(mustJSON(t, egressStatus)),
"authkey": "tskey-key",
"device_fqdn": "test-node.test.ts.net",
"device_fqdn": "test-node.test.ts.net.",
"device_id": "myID",
"device_ips": `["100.64.0.1"]`,
kubetypes.KeyCapVer: capver,
@@ -1338,6 +1354,11 @@ func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
w.Write([]byte("fake metrics"))
return
case "/localapi/v0/prefs":
if r.Method != "GET" {
panic(fmt.Sprintf("unsupported method %q", r.Method))
}
return
default:
panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
}
@@ -1563,13 +1584,14 @@ func mustJSON(t *testing.T, v any) []byte {
}
// egress services status given one named tailnet target specified by FQDN. As written by the proxy to its state Secret.
func egressSvcStatus(name, fqdn string) egressservices.Status {
func egressSvcStatus(name, fqdn, ip string) egressservices.Status {
return egressservices.Status{
Services: map[string]*egressservices.ServiceStatus{
name: {
TailnetTarget: egressservices.TailnetTarget{
FQDN: fqdn,
},
TailnetTargetIPs: []netip.Addr{netip.MustParseAddr(ip)},
},
},
}

View File

@@ -247,7 +247,7 @@ func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg
}
// If not found, check for a Tailscale Service DNS name.
rec, ok := serviceDNSRecordFromNetMap(nm, st.CurrentTailnet.MagicDNSSuffix, arg)
rec, ok := serviceDNSRecordFromNetMap(nm, arg)
if !ok {
return "", fmt.Errorf("no peer found for %q", arg)
}
@@ -287,7 +287,7 @@ func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
return n.NetMap, nil
}
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, tcd, arg string) (rec tailcfg.DNSRecord, ok bool) {
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.DNSRecord, ok bool) {
argIP, _ := netip.ParseAddr(arg)
argFQDN, err := dnsname.ToFQDN(arg)
argFQDNValid := err == nil