tailscale/kube/egressservices/egressservices.go
Irbe Krumina 096b090caf
cmd/containerboot,kube,util/linuxfw: configure kube egress proxies to route to 1+ tailnet targets (#13531)
* cmd/containerboot,kube,util/linuxfw: configure kube egress proxies to route to 1+ tailnet targets

This commit is first part of the work to allow running multiple
replicas of the Kubernetes operator egress proxies per tailnet service +
to allow exposing multiple tailnet services via each proxy replica.

This expands the existing iptables/nftables-based proxy configuration
mechanism.

A proxy can now be configured to route to one or more tailnet targets
via a (mounted) config file that, for each tailnet target, specifies:
- the target's tailnet IP or FQDN
- mappings of container ports to which cluster workloads will send traffic to
tailnet target ports where the traffic should be forwarded.

Example configfile contents:
{
  "some-svc": {"tailnetTarget":{"fqdn":"foo.tailnetxyz.ts.net","ports"{"tcp:4006:80":{"protocol":"tcp","matchPort":4006,"targetPort":80},"tcp:4007:443":{"protocol":"tcp","matchPort":4007,"targetPort":443}}}}
}

A proxy that is configured with this config file will configure firewall rules
to route cluster traffic to the tailnet targets. It will then watch the config file
for updates as well as monitor relevant netmap updates and reconfigure firewall
as needed.

This adds a bunch of new iptables/nftables functionality to make it easier to dynamically update
the firewall rules without needing to restart the proxy Pod as well as to make
it easier to debug/understand the rules:

- for iptables, each portmapping is a DNAT rule with a comment pointing
at the 'service',i.e:

-A PREROUTING ! -i tailscale0 -p tcp -m tcp --dport 4006 -m comment --comment "some-svc:tcp:4006 -> tcp:80" -j DNAT --to-destination 100.64.1.18:80
Additionally there is a SNAT rule for each tailnet target, to mask the source address.

- for nftables, a separate prerouting chain is created for each tailnet target
and all the portmapping rules are placed in that chain. This makes it easier
to look up rules and delete services when no longer needed.
(nftables allows hooking a custom chain to a prerouting hook, so no extra work
is needed to ensure that the rules in the service chains are evaluated).

The next steps will be to get the Kubernetes Operator to generate
the configfile and ensure it is mounted to the relevant proxy nodes.

Updates tailscale/tailscale#13406

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-09-29 16:30:53 +01:00

104 lines
3.5 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package egressservices contains shared types for exposing tailnet services to
// cluster workloads.
// These are split into a separate package for consumption of
// non-Kubernetes shared libraries and binaries. Be mindful of not increasing
// dependency size for those consumers when adding anything new here.
package egressservices
import (
"encoding"
"fmt"
"net/netip"
"strconv"
"strings"
)
// KeyEgressServices is name of the proxy state Secret field that contains the
// currently applied egress proxy config.
const KeyEgressServices = "egress-services"
// Configs contains the desired configuration for egress services keyed by
// service name.
type Configs map[string]Config
// Config is an egress service configuration.
// TODO(irbekrm): version this?
type Config struct {
// TailnetTarget is the target to which cluster traffic for this service
// should be proxied.
TailnetTarget TailnetTarget `json:"tailnetTarget"`
// Ports contains mappings for ports that can be accessed on the tailnet target.
Ports map[PortMap]struct{} `json:"ports"`
}
// TailnetTarget is the tailnet target to which traffic for the egress service
// should be proxied. Exactly one of IP or FQDN should be set.
type TailnetTarget struct {
// IP is the tailnet IP of the target.
IP string `json:"ip"`
// FQDN is the full tailnet FQDN of the target.
FQDN string `json:"fqdn"`
}
// PorMap is a mapping between match port on which proxy receives cluster
// traffic and target port where traffic received on match port should be
// fowardded to.
type PortMap struct {
Protocol string `json:"protocol"`
MatchPort uint16 `json:"matchPort"`
TargetPort uint16 `json:"targetPort"`
}
// PortMap is used as a Config.Ports map key. Config needs to be serialized/deserialized to/from JSON. JSON only
// supports string map keys, so we need to implement TextMarshaler/TextUnmarshaler to convert PortMap to string and
// back.
var _ encoding.TextMarshaler = PortMap{}
var _ encoding.TextUnmarshaler = &PortMap{}
func (pm *PortMap) UnmarshalText(t []byte) error {
tt := string(t)
ss := strings.Split(tt, ":")
if len(ss) != 3 {
return fmt.Errorf("error unmarshalling portmap from JSON, wants a portmap in form <protocol>:<matchPort>:<targetPor>, got %q", tt)
}
(*pm).Protocol = ss[0]
matchPort, err := strconv.ParseUint(ss[1], 10, 16)
if err != nil {
return fmt.Errorf("error converting match port %q to uint16: %w", ss[1], err)
}
(*pm).MatchPort = uint16(matchPort)
targetPort, err := strconv.ParseUint(ss[2], 10, 16)
if err != nil {
return fmt.Errorf("error converting target port %q to uint16: %w", ss[2], err)
}
(*pm).TargetPort = uint16(targetPort)
return nil
}
func (pm PortMap) MarshalText() ([]byte, error) {
s := fmt.Sprintf("%s:%d:%d", pm.Protocol, pm.MatchPort, pm.TargetPort)
return []byte(s), nil
}
// Status represents the currently configured firewall rules for all egress
// services for a proxy identified by the PodIP.
type Status struct {
PodIP string `json:"podIP"`
// All egress service status keyed by service name.
Services map[string]*ServiceStatus `json:"services"`
}
// ServiceStatus is the currently configured firewall rules for an egress
// service.
type ServiceStatus struct {
Ports map[PortMap]struct{} `json:"ports"`
// TailnetTargetIPs are the tailnet target IPs that were used to
// configure these firewall rules. For a TailnetTarget with IP set, this
// is the same as IP.
TailnetTargetIPs []netip.Addr `json:"tailnetTargetIPs"`
TailnetTarget TailnetTarget `json:"tailnetTarget"`
}