Compare commits

...

5 Commits

Author SHA1 Message Date
Irbe Krumina
388066f2b1 cmd/containerboot: optionally configure tailscaled with a configfile
If TS_EXPERIMENTAL_CONFIGFILE_PATH env var is set only run tailscaled with the provided config file and not tailscale up or tailscale set.

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-05 12:31:16 +00:00
Irbe Krumina
51279ea00c cmd/containerboot: store containerboot accept_dns val in bool pointer
So that we can distinguish between the value being set to
false explicitly bs being unset.

Updates tailscale/tailscale#10708

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-05 11:01:59 +00:00
Irbe Krumina
015197c4da cmd/k8s-operator/deploy/examples: add an example of using Connector to configure exit node
Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-05 10:39:20 +00:00
Irbe Krumina
f8fe1d9182 cmd/k8s-operator: optionally configure Connector as exit node
The Kubernetes operator parses Connector custom resource and,
if .spec.isExitNode is set, configures that Tailscale node deployed
for that connector as an exit node.

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-05 10:36:01 +00:00
Irbe Krumina
72f5312dba cmd/k8s-operator/deploy/crds,k8s-operator/apis/v1alpha1: allow to define an exit node via Connector CR
Make it possible to define an exit node to be deployed to a Kubernetes cluster
via Connector Custom resource.

Also changes to Connector API so that one Connector corresponds
to one Tailnet node that can be either a subnet router or an exit
node or both.

Updates tailscale/tailscale#10708

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
2024-01-05 10:31:47 +00:00
10 changed files with 523 additions and 321 deletions

View File

@@ -48,6 +48,13 @@
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
// and will be re-applied when it changes.
// - TS_EXPERIMENTAL_CONFIGFILE_PATH: if specified, a path to tailscaled
// config. If this is set, TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY,
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
// and not `tailscale up` or `tailscale set`.
// The config file contents are currently read once on container start.
// NB: This env var is currently experimental and the logic will likely change!
//
// When running on Kubernetes, containerboot defaults to storing state in the
// "tailscale" kube secret. To store state on local disk instead, set
@@ -83,6 +90,7 @@ import (
"golang.org/x/sys/unix"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/types/ptr"
@@ -102,39 +110,29 @@ func main() {
tailscale.I_Acknowledge_This_API_Is_Unstable = true
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: defaultEnv("TS_EXPERIMENTAL_CONFIGFILE_PATH", ""),
}
if cfg.ProxyTo != "" && cfg.UserspaceMode {
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.TailnetTargetIP != "" {
log.Fatal("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
if err := cfg.validate(); err != nil {
log.Fatalf("invalid containerboot configuration: %v", err)
}
if !cfg.UserspaceMode {
@@ -171,7 +169,7 @@ func main() {
}
cfg.KubernetesCanPatch = canPatch
if cfg.AuthKey == "" {
if runTailscaleSet(cfg) {
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
if err != nil {
log.Fatalf("Getting authkey from kube secret: %v", err)
@@ -238,7 +236,7 @@ func main() {
// different points in containerboot's lifecycle, hence the helper function.
didLogin := false
authTailscale := func() error {
if didLogin {
if didLogin || runTailscaledOnly(cfg) {
return nil
}
didLogin = true
@@ -253,7 +251,7 @@ func main() {
return nil
}
if !cfg.AuthOnce {
if !runTailscaleSet(cfg) {
if err := authTailscale(); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
}
@@ -269,6 +267,13 @@ authLoop:
if n.State != nil {
switch *n.State {
case ipn.NeedsLogin:
if runTailscaledOnly(cfg) {
// This could happen if this is the
// first time tailscaled was run for
// this device and the auth key was not
// passed via the configfile.
log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.")
}
if err := authTailscale(); err != nil {
log.Fatalf("failed to auth tailscale: %v", err)
}
@@ -293,7 +298,7 @@ authLoop:
ctx, cancel := contextWithExitSignalWatch()
defer cancel()
if cfg.AuthOnce {
if runTailscaleSet(cfg) {
// Now that we are authenticated, we can set/reset any of the
// settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil {
@@ -309,7 +314,7 @@ authLoop:
}
}
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && cfg.AuthOnce {
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && runTailscaleSet(cfg) {
// We were told to only auth once, so any secret-bound
// authkey is no longer needed. We don't strictly need to
// wipe it, but it's good hygiene.
@@ -634,6 +639,9 @@ func tailscaledArgs(cfg *settings) []string {
if cfg.HTTPProxyAddr != "" {
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
}
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
}
@@ -644,7 +652,7 @@ func tailscaledArgs(cfg *settings) []string {
// if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS {
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
@@ -680,7 +688,7 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
// node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS {
if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true")
} else {
args = append(args, "--accept-dns=false")
@@ -873,21 +881,46 @@ type settings struct {
// TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN.
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailnetTargetFQDN string
ServeConfigPath string
DaemonExtraArgs string
ExtraArgs string
InKubernetes bool
UserspaceMode bool
StateDir string
AcceptDNS *bool
KubeSecret string
SOCKSProxyAddr string
HTTPProxyAddr string
Socket string
AuthOnce bool
Root string
KubernetesCanPatch bool
TailscaledConfigFilePath string
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTo != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("TS_EXPERIMENTAL_CONFIGFILE_PATH cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
return nil
}
// defaultEnv returns the value of the given envvar name, or defVal if
@@ -899,16 +932,28 @@ func defaultEnv(name, defVal string) string {
return defVal
}
// defaultEnvPointer returns a pointer to the given envvar value if set, else
// defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset.
func defaultEnvPointer(name string) *string {
func defaultEnvStringPointer(name string) *string {
if v, ok := os.LookupEnv(name); ok {
return &v
}
return nil
}
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being explicitly set to false vs unset.
func defaultEnvBoolPointer(name string) *bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return nil
}
return &ret
}
func defaultEnvs(names []string, defVal string) string {
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
@@ -950,3 +995,17 @@ func contextWithExitSignalWatch() (context.Context, func()) {
}
return ctx, f
}
// runTaiscaleSet determines whether `tailscale set` (rather than the default
// `tailscale up`) should be used to reconfigure tailscaled on every subsequent
// container restart after the tailnet device has logged in.
func runTailscaleSet(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// runTailscaledOnly determines whether tailscaled only should be ran to start,
// configure and log in the tailnet device and the `tailscale up`/`tailscale
// set` steps should be skipped.
func runTailscaledOnly(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}

View File

@@ -310,7 +310,7 @@ func TestContainerBoot(t *testing.T) {
},
},
{
Name: "ingres proxy",
Name: "ingress proxy",
Env: map[string]string{
"TS_AUTHKEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4",
@@ -629,6 +629,22 @@ func TestContainerBoot(t *testing.T) {
},
},
},
{
Name: "experimental tailscaled configfile",
Env: map[string]string{
// TODO - create this file so we don't fail here
"TS_EXPERIMENTAL_CONFIGFILE_PATH": "/conf",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/conf",
},
}, {
Notify: runningNotify,
},
},
},
}
for _, test := range tests {

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -33,17 +32,16 @@ import (
)
const (
reasonSubnetRouterCreationFailed = "SubnetRouterCreationFailed"
reasonSubnetRouterCreated = "SubnetRouterCreated"
reasonSubnetRouterCleanupFailed = "SubnetRouterCleanupFailed"
reasonSubnetRouterCleanupInProgress = "SubnetRouterCleanupInProgress"
reasonSubnetRouterInvalid = "SubnetRouterInvalid"
reasonConnectorCreationFailed = "ConnectorCreationFailed"
messageSubnetRouterCreationFailed = "Failed creating subnet router for routes %s: %v"
messageSubnetRouterInvalid = "Subnet router is invalid: %v"
messageSubnetRouterCreated = "Created subnet router for routes %s"
messageSubnetRouterCleanupFailed = "Failed cleaning up subnet router resources: %v"
msgSubnetRouterCleanupInProgress = "SubnetRouterCleanupInProgress"
reasonConnectorCreated = "ConnectorCreated"
reasonConnectorCleanupFailed = "ConnectorCleanupFailed"
reasonConnectorCleanupInProgress = "ConnectorCleanupInProgress"
reasonConnectorInvalid = "ConnectorInvalid"
messageConnectorCreationFailed = "Failed creating Connector: %v"
messageConnectorInvalid = "Connector is invalid: %v"
messageSubnetRouterCleanupFailed = "Failed cleaning up Connector resources: %v"
shortRequeue = time.Second * 5
)
@@ -61,42 +59,39 @@ type ConnectorReconciler struct {
mu sync.Mutex // protects following
// subnetRouters tracks the subnet routers managed by this Tailscale
// Operator instance.
subnetRouters set.Slice[types.UID]
connectors set.Slice[types.UID] // for connectors gauge
}
var (
// gaugeIngressResources tracks the number of subnet routers that we're
// currently managing.
gaugeSubnetRouterResources = clientmetric.NewGauge("k8s_subnet_router_resources")
// gaugeConnectorResources tracks the number of Connectors currently managed by this operator instance
gaugeConnectorResources = clientmetric.NewGauge("k8s_connector_resources")
)
func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
logger := a.logger.With("connector", req.Name)
logger := a.logger.With("Connector", req.Name)
logger.Debugf("starting reconcile")
defer logger.Debugf("reconcile finished")
cn := new(tsapi.Connector)
err = a.Get(ctx, req.NamespacedName, cn)
if apierrors.IsNotFound(err) {
logger.Debugf("connector not found, assuming it was deleted")
logger.Debugf("Connector not found, assuming it was deleted")
return reconcile.Result{}, nil
} else if err != nil {
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com Connector: %w", err)
}
if !cn.DeletionTimestamp.IsZero() {
logger.Debugf("connector is being deleted or should not be exposed, cleaning up components")
logger.Debugf("Connector is being deleted or should not be exposed, cleaning up resources")
ix := xslices.Index(cn.Finalizers, FinalizerName)
if ix < 0 {
logger.Debugf("no finalizer, nothing to do")
return reconcile.Result{}, nil
}
if done, err := a.maybeCleanupSubnetRouter(ctx, logger, cn); err != nil {
if done, err := a.maybeCleanupConnector(ctx, logger, cn); err != nil {
return reconcile.Result{}, err
} else if !done {
logger.Debugf("cleanup not finished, will retry...")
logger.Debugf("Connector resource cleanup not yet finished, will retry...")
return reconcile.Result{RequeueAfter: shortRequeue}, nil
}
@@ -104,21 +99,20 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
if err := a.Update(ctx, cn); err != nil {
return reconcile.Result{}, err
}
logger.Infof("connector resources cleaned up")
logger.Infof("Connector resources cleaned up")
return reconcile.Result{}, nil
}
var (
reason, message string
readyStatus metav1.ConditionStatus
)
oldCnStatus := cn.Status.DeepCopy()
defer func() {
if cn.Status.SubnetRouter == nil {
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, metav1.ConditionUnknown, "", "", cn.Generation, a.clock, logger)
} else if cn.Status.SubnetRouter.Ready == metav1.ConditionTrue {
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, metav1.ConditionTrue, reasonSubnetRouterCreated, reasonSubnetRouterCreated, cn.Generation, a.clock, logger)
} else {
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, metav1.ConditionFalse, cn.Status.SubnetRouter.Reason, cn.Status.SubnetRouter.Reason, cn.Generation, a.clock, logger)
}
tsoperator.SetConnectorCondition(cn, tsapi.ConnectorReady, readyStatus, reason, message, cn.Generation, a.clock, logger)
if !apiequality.Semantic.DeepEqual(oldCnStatus, cn.Status) {
// an error encountered here should get returned by the Reconcile function
// An error encountered here should get returned by the Reconcile function.
if updateErr := a.Client.Status().Update(ctx, cn); updateErr != nil {
err = updateErr
}
@@ -130,67 +124,85 @@ func (a *ConnectorReconciler) Reconcile(ctx context.Context, req reconcile.Reque
// because once the finalizer is in place this block gets skipped. So,
// this is a nice place to tell the operator that the high level,
// multi-reconcile operation is underway.
logger.Infof("ensuring connector is set up")
logger.Infof("ensuring Connector is set up")
cn.Finalizers = append(cn.Finalizers, FinalizerName)
if err := a.Update(ctx, cn); err != nil {
err = fmt.Errorf("failed to add finalizer: %w", err)
logger.Errorf("error adding finalizer: %v", err)
reason = reasonConnectorCreationFailed
message = fmt.Sprintf(messageConnectorCreationFailed, err)
readyStatus = metav1.ConditionFalse
return reconcile.Result{}, err
}
}
// A Connector with unset .spec.subnetRouter and unset
// cn.spec.subnetRouter.Routes will be rejected at apply time (because
// these fields are set as required by our CRD validation). This check
// is here for if our CRD validation breaks unnoticed we don't crash the
// operator with nil pointer exception.
if cn.Spec.SubnetRouter == nil || len(cn.Spec.SubnetRouter.Routes) < 1 {
if err := a.validate(cn); err != nil {
logger.Errorf("error validating Connector spec: %w", err)
reason = reasonConnectorInvalid
message = fmt.Sprintf(messageConnectorInvalid, err)
readyStatus = metav1.ConditionFalse
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonConnectorInvalid, message)
return reconcile.Result{}, nil
}
if err := validateSubnetRouter(*cn.Spec.SubnetRouter); err != nil {
msg := fmt.Sprintf(messageSubnetRouterInvalid, err)
cn.Status.SubnetRouter = &tsapi.SubnetRouterStatus{
Ready: metav1.ConditionFalse,
Reason: reasonSubnetRouterInvalid,
Message: msg,
if err = a.maybeProvisionConnector(ctx, logger, cn); err != nil {
logger.Errorf("error creating Connector resources: %w", err)
reason = reasonConnectorCreationFailed
message = fmt.Sprintf(messageConnectorCreationFailed, err)
readyStatus = metav1.ConditionFalse
a.recorder.Eventf(cn, corev1.EventTypeWarning, reason, message)
} else {
logger.Info("Connector resources synced")
reason = reasonConnectorCreated
message = reasonConnectorCreated
readyStatus = metav1.ConditionTrue
cn.Status.IsExitNode = cn.Spec.ExitNode
if cn.Spec.SubnetRouter != nil {
cn.Status.SubnetRoutes = cn.Spec.SubnetRouter.Routes.Stringify()
} else {
cn.Status.SubnetRoutes = ""
}
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonSubnetRouterInvalid, msg)
return reconcile.Result{}, nil
}
var sb strings.Builder
sb.WriteString(string(cn.Spec.SubnetRouter.Routes[0]))
for _, r := range cn.Spec.SubnetRouter.Routes[1:] {
sb.WriteString(fmt.Sprintf(",%s", r))
}
cidrsS := sb.String()
logger.Debugf("ensuring a subnet router is deployed")
err = a.maybeProvisionSubnetRouter(ctx, logger, cn, cidrsS)
if err != nil {
msg := fmt.Sprintf(messageSubnetRouterCreationFailed, cidrsS, err)
cn.Status.SubnetRouter = &tsapi.SubnetRouterStatus{
Ready: metav1.ConditionFalse,
Reason: reasonSubnetRouterCreationFailed,
Message: msg,
}
a.recorder.Eventf(cn, corev1.EventTypeWarning, reasonSubnetRouterCreationFailed, msg)
return reconcile.Result{}, err
}
cn.Status.SubnetRouter = &tsapi.SubnetRouterStatus{
Routes: cidrsS,
Ready: metav1.ConditionTrue,
Reason: reasonSubnetRouterCreated,
Message: fmt.Sprintf(messageSubnetRouterCreated, cidrsS),
}
return reconcile.Result{}, nil
return reconcile.Result{}, err
}
func (a *ConnectorReconciler) maybeCleanupSubnetRouter(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {
if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(cn.Name, a.tsnamespace, "subnetrouter")); err != nil {
return false, fmt.Errorf("failed to cleanup: %w", err)
// maybeProvisionConnector ensures that any new resources required for this
// Connector instance are deployed to the cluster.
func (a *ConnectorReconciler) maybeProvisionConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) error {
hostname := cn.Name + "-connector"
if cn.Spec.Hostname != "" {
hostname = string(cn.Spec.Hostname)
}
crl := childResourceLabels(cn.Name, a.tsnamespace, "connector")
sts := &tailscaleSTSConfig{
ParentResourceName: cn.Name,
ParentResourceUID: string(cn.UID),
Hostname: hostname,
ChildResourceLabels: crl,
Tags: cn.Spec.Tags.Stringify(),
Connector: &connector{
isExitNode: cn.Spec.ExitNode,
},
}
if cn.Spec.SubnetRouter != nil && len(cn.Spec.SubnetRouter.Routes) > 0 {
sts.Connector.routes = cn.Spec.SubnetRouter.Routes.Stringify()
}
a.mu.Lock()
a.connectors.Add(cn.UID)
gaugeConnectorResources.Set(int64(a.connectors.Len()))
a.mu.Unlock()
_, err := a.ssr.Provision(ctx, logger, sts)
return err
}
func (a *ConnectorReconciler) maybeCleanupConnector(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector) (bool, error) {
if done, err := a.ssr.Cleanup(ctx, logger, childResourceLabels(cn.Name, a.tsnamespace, "connector")); err != nil {
return false, fmt.Errorf("failed to cleanup Connector resources: %w", err)
} else if !done {
logger.Debugf("cleanup not done yet, waiting for next reconcile")
logger.Debugf("Connector cleanup not done yet, waiting for next reconcile")
return false, nil
}
@@ -198,42 +210,31 @@ func (a *ConnectorReconciler) maybeCleanupSubnetRouter(ctx context.Context, logg
// exactly once at the very end of cleanup, because the final step of
// cleanup removes the tailscale finalizer, which will make all future
// reconciles exit early.
logger.Infof("cleaned up subnet router")
logger.Infof("cleaned up Connector resources")
a.mu.Lock()
defer a.mu.Unlock()
a.subnetRouters.Remove(cn.UID)
gaugeSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
a.connectors.Remove(cn.UID)
gaugeConnectorResources.Set(int64(a.connectors.Len()))
return true, nil
}
// maybeProvisionSubnetRouter maybe deploys subnet router that exposes a subset of cluster cidrs to the tailnet
func (a *ConnectorReconciler) maybeProvisionSubnetRouter(ctx context.Context, logger *zap.SugaredLogger, cn *tsapi.Connector, cidrs string) error {
if cn.Spec.SubnetRouter == nil || len(cn.Spec.SubnetRouter.Routes) < 1 {
func (a *ConnectorReconciler) validate(cn *tsapi.Connector) error {
// Connector fields are already validated at apply time with CEL validation
// on custom resource fields. The checks here are a backup in case the
// CEL validation breaks without us noticing.
if !(cn.Spec.SubnetRouter != nil || cn.Spec.ExitNode) {
return errors.New("invalid Connector spec- a Connector must be either expose subnet routes or act as exit node (or both)")
}
if cn.Spec.SubnetRouter == nil {
return nil
}
a.mu.Lock()
a.subnetRouters.Add(cn.UID)
gaugeSubnetRouterResources.Set(int64(a.subnetRouters.Len()))
a.mu.Unlock()
crl := childResourceLabels(cn.Name, a.tsnamespace, "subnetrouter")
hostname := hostnameForSubnetRouter(cn)
sts := &tailscaleSTSConfig{
ParentResourceName: cn.Name,
ParentResourceUID: string(cn.UID),
Hostname: hostname,
ChildResourceLabels: crl,
Routes: cidrs,
}
for _, tag := range cn.Spec.SubnetRouter.Tags {
sts.Tags = append(sts.Tags, string(tag))
}
_, err := a.ssr.Provision(ctx, logger, sts)
return err
return validateSubnetRouter(cn.Spec.SubnetRouter)
}
func validateSubnetRouter(sb tsapi.SubnetRouter) error {
func validateSubnetRouter(sb *tsapi.SubnetRouter) error {
if len(sb.Routes) < 1 {
return errors.New("invalid subnet router spec: no routes defined")
}
var err error
for _, route := range sb.Routes {
pfx, e := netip.ParsePrefix(string(route))
@@ -247,13 +248,3 @@ func validateSubnetRouter(sb tsapi.SubnetRouter) error {
}
return err
}
func hostnameForSubnetRouter(cn *tsapi.Connector) string {
if cn.Spec.SubnetRouter == nil {
return ""
}
if cn.Spec.SubnetRouter.Hostname != "" {
return string(cn.Spec.SubnetRouter.Hostname)
}
return cn.Name + "-" + "subnetrouter"
}

View File

@@ -7,6 +7,7 @@ package main
import (
"context"
"fmt"
"testing"
"go.uber.org/zap"
@@ -21,6 +22,8 @@ import (
)
func TestConnector(t *testing.T) {
// Create a Connector that defines a Tailscale node that advertises
// 10.40.0.0/14 route and acts as an exit node.
cn := &tsapi.Connector{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
@@ -34,6 +37,7 @@ func TestConnector(t *testing.T) {
SubnetRouter: &tsapi.SubnetRouter{
Routes: []tsapi.Route{"10.40.0.0/14"},
},
ExitNode: true,
},
}
fc := fake.NewClientBuilder().
@@ -48,7 +52,6 @@ func TestConnector(t *testing.T) {
}
cl := tstest.NewClock(tstest.ClockOpts{})
// Create a Connector with a subnet router definition
cr := &ConnectorReconciler{
Client: fc,
ssr: &tailscaleSTSReconciler{
@@ -63,26 +66,54 @@ func TestConnector(t *testing.T) {
}
expectReconciled(t, cr, "", "test")
fullName, shortName := findGenName(t, fc, "", "test", "subnetrouter")
fullName, shortName := findGenName(t, fc, "", "test", "connector")
expectEqual(t, fc, expectedSecret(fullName, "", "subnetrouter"))
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.40.0.0/14"))
expectEqual(t, fc, expectedSecret(fullName, "", "connector"))
opts := connectorSTSOpts{
connectorName: "test",
stsName: shortName,
secretName: fullName,
routes: "10.40.0.0/14",
isExitNode: true,
}
expectEqual(t, fc, expectedConnectorSTS(opts))
// Add another CIDR
// Add another route to be advertised.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.Routes = []tsapi.Route{"10.40.0.0/14", "10.44.0.0/20"}
})
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.40.0.0/14,10.44.0.0/20"))
opts.routes = "10.40.0.0/14,10.44.0.0/20"
// Remove a CIDR
expectEqual(t, fc, expectedConnectorSTS(opts))
// Remove a route.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter.Routes = []tsapi.Route{"10.44.0.0/20"}
})
expectReconciled(t, cr, "", "test")
expectEqual(t, fc, expectedConnectorSTS(shortName, fullName, "10.44.0.0/20"))
opts.routes = "10.44.0.0/20"
expectEqual(t, fc, expectedConnectorSTS(opts))
// Delete the Connector
// Remove the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter = nil
})
expectReconciled(t, cr, "", "test")
opts.routes = ""
expectEqual(t, fc, expectedConnectorSTS(opts))
// Re-add the subnet router.
mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) {
conn.Spec.SubnetRouter = &tsapi.SubnetRouter{
Routes: []tsapi.Route{"10.44.0.0/20"},
}
})
expectReconciled(t, cr, "", "test")
opts.routes = "10.44.0.0/20"
expectEqual(t, fc, expectedConnectorSTS(opts))
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
t.Fatalf("error deleting Connector: %v", err)
}
@@ -93,22 +124,85 @@ func TestConnector(t *testing.T) {
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
// Create a Connector that advertises a route and is not an exit node.
cn = &tsapi.Connector{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
UID: types.UID("1234-UID"),
},
TypeMeta: metav1.TypeMeta{
Kind: tsapi.ConnectorKind,
APIVersion: "tailscale.io/v1alpha1",
},
Spec: tsapi.ConnectorSpec{
SubnetRouter: &tsapi.SubnetRouter{
Routes: []tsapi.Route{"10.40.0.0/14"},
},
},
}
mustCreate(t, fc, cn)
expectReconciled(t, cr, "", "test")
fullName, shortName = findGenName(t, fc, "", "test", "connector")
expectEqual(t, fc, expectedSecret(fullName, "", "connector"))
opts = connectorSTSOpts{
connectorName: "test",
stsName: shortName,
secretName: fullName,
routes: "10.40.0.0/14",
isExitNode: false,
}
expectEqual(t, fc, expectedConnectorSTS(opts))
// Delete the Connector.
if err = fc.Delete(context.Background(), cn); err != nil {
t.Fatalf("error deleting Connector: %v", err)
}
expectRequeue(t, cr, "", "test")
expectReconciled(t, cr, "", "test")
expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName)
expectMissing[corev1.Secret](t, fc, "operator-ns", fullName)
}
func expectedConnectorSTS(stsName, secretName, routes string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
type connectorSTSOpts struct {
stsName string
secretName string
connectorName string
hostname string
routes string
isExitNode bool
}
func expectedConnectorSTS(opts connectorSTSOpts) *appsv1.StatefulSet {
var hostname string
if opts.hostname != "" {
hostname = opts.hostname
} else {
hostname = opts.connectorName + "-connector"
}
containerEnv := []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: opts.secretName},
{Name: "TS_HOSTNAME", Value: hostname},
{Name: "TS_EXTRA_ARGS", Value: fmt.Sprintf("--advertise-exit-node=%v", opts.isExitNode)},
{Name: "TS_ROUTES", Value: opts.routes},
}
sts := &appsv1.StatefulSet{
TypeMeta: metav1.TypeMeta{
Kind: "StatefulSet",
APIVersion: "apps/v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: stsName,
Name: opts.stsName,
Namespace: "operator-ns",
Labels: map[string]string{
"tailscale.com/managed": "true",
"tailscale.com/parent-resource": "test",
"tailscale.com/parent-resource-ns": "",
"tailscale.com/parent-resource-type": "subnetrouter",
"tailscale.com/parent-resource-type": "connector",
},
},
Spec: appsv1.StatefulSetSpec{
@@ -116,13 +210,13 @@ func expectedConnectorSTS(stsName, secretName, routes string) *appsv1.StatefulSe
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "1234-UID"},
},
ServiceName: stsName,
ServiceName: opts.stsName,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
DeletionGracePeriodSeconds: ptr.To[int64](10),
Labels: map[string]string{"app": "1234-UID"},
Annotations: map[string]string{
"tailscale.com/operator-last-set-hostname": "test-subnetrouter",
"tailscale.com/operator-last-set-hostname": hostname,
},
},
Spec: corev1.PodSpec{
@@ -142,13 +236,7 @@ func expectedConnectorSTS(stsName, secretName, routes string) *appsv1.StatefulSe
{
Name: "tailscale",
Image: "tailscale/tailscale",
Env: []corev1.EnvVar{
{Name: "TS_USERSPACE", Value: "false"},
{Name: "TS_AUTH_ONCE", Value: "true"},
{Name: "TS_KUBE_SECRET", Value: secretName},
{Name: "TS_HOSTNAME", Value: "test-subnetrouter"},
{Name: "TS_ROUTES", Value: routes},
},
Env: containerEnv,
SecurityContext: &corev1.SecurityContext{
Capabilities: &corev1.Capabilities{
Add: []corev1.Capability{"NET_ADMIN"},
@@ -161,4 +249,5 @@ func expectedConnectorSTS(stsName, secretName, routes string) *appsv1.StatefulSe
},
},
}
return sts
}

View File

@@ -16,11 +16,15 @@ spec:
scope: Cluster
versions:
- additionalPrinterColumns:
- description: Cluster CIDR ranges exposed to tailnet via subnet router
jsonPath: .status.subnetRouter.routes
- description: CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance.
jsonPath: .status.subnetRoutes
name: SubnetRoutes
type: string
- description: Status of the components deployed by the connector
- description: Whether this Connector instance defines an exit node.
jsonPath: .status.isExitNode
name: IsExitNode
type: string
- description: Status of the deployed Connector resources.
jsonPath: .status.conditions[?(@.type == "ConnectorReady")].reason
name: Status
type: string
@@ -40,38 +44,39 @@ spec:
metadata:
type: object
spec:
description: Desired state of the Connector resource.
description: ConnectorSpec describes the desired Tailscale component.
type: object
required:
- subnetRouter
properties:
exitNode:
description: ExitNode defines whether the Connector node should act as a Tailscale exit node. Defaults to false. https://tailscale.com/kb/1103/exit-nodes
type: boolean
hostname:
description: Hostname is the tailnet hostname that should be assigned to the Connector node. If unset, hostname is defaulted to <connector name>-connector. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
subnetRouter:
description: SubnetRouter configures a Tailscale subnet router to be deployed in the cluster. If unset no subnet router will be deployed. https://tailscale.com/kb/1019/subnets/
description: SubnetRouter defines subnet routes that the Connector node should expose to tailnet. If unset, none are exposed. https://tailscale.com/kb/1019/subnets/
type: object
required:
- routes
properties:
hostname:
description: Hostname is the tailnet hostname that should be assigned to the subnet router node. If unset hostname is defaulted to <connector name>-subnetrouter. Hostname can contain lower case letters, numbers and dashes, it must not start or end with a dash and must be between 2 and 63 characters long.
type: string
pattern: ^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$
routes:
description: Routes refer to in-cluster CIDRs that the subnet router should make available. Route values must be strings that represent a valid IPv4 or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes. https://tailscale.com/kb/1201/4via6-subnets/
type: array
items:
type: string
format: cidr
tags:
description: Tags that the Tailscale node will be tagged with. If you want the subnet router to be autoapproved, you can configure Tailscale ACLs to autoapprove the subnetrouter's CIDRs for these tags. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes Defaults to tag:k8s. If you specify custom tags here, you must also make tag:k8s-operator owner of the custom tag. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
x-kubernetes-validations:
- rule: has(self.tags) == has(oldSelf.tags)
message: Subnetrouter tags cannot be changed. Delete and redeploy the Connector if you need to change it.
tags:
description: Tags that the Tailscale node will be tagged with. Defaults to [tag:k8s]. To autoapprove the subnet routes or exit node defined by a Connector, you can configure Tailscale ACLs to give these tags the necessary permissions. See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes If you specify custom tags here, you must also make the operator an owner of these tags. See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator. Tags cannot be changed once a Connector node has been created. Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
type: array
items:
type: string
pattern: ^tag:[a-zA-Z][a-zA-Z0-9-]*$
x-kubernetes-validations:
- rule: has(self.subnetRouter) || self.exitNode == true
message: A Connector needs to be either an exit node or a subnet router, or both.
status:
description: Status of the Connector. This is set and managed by the Tailscale operator.
description: ConnectorStatus describes the status of the Connector. This is set and managed by the Tailscale operator.
type: object
properties:
conditions:
@@ -107,27 +112,12 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
subnetRouter:
description: SubnetRouter status is the current status of a subnet router
type: object
required:
- message
- ready
- reason
- routes
properties:
message:
description: Message is a more verbose reason for the current subnet router status
type: string
ready:
description: Ready is the ready status of the subnet router
type: string
reason:
description: Reason is the reason for the subnet router status
type: string
routes:
description: Routes are the CIDRs currently exposed via subnet router
type: string
isExitNode:
description: IsExitNode is set to true if the Connector acts as an exit node.
type: boolean
subnetRoutes:
description: SubnetRoutes are the routes currently exposed to tailnet via this Connector instance.
type: string
served: true
storage: true
subresources:

View File

@@ -0,0 +1,19 @@
# Before applying ensure that the operator owns tag:prod.
# https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
# To set up autoapproval set tag:prod as approver for 10.40.0.0/14 route and exit node.
# Otherwise approve it manually in Machines panel once the
# ts-prod Tailscale node has been created.
# See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes
apiVersion: tailscale.com/v1alpha1
kind: Connector
metadata:
name: prod
spec:
tags:
- "tag:prod"
hostname: ts-prod
subnetRouter:
routes:
- "10.40.0.0/14"
- "192.168.0.0/14"
exitNode: true

View File

@@ -1,17 +0,0 @@
# Before applyong this ensure that the operator is owner of tag:subnet.
# https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
# To set up autoapproval set tag:subnet as approver for 10.40.0.0/14 route
# otherwise you will need to approve it manually in control panel once the
# subnet router has been created.
# https://tailscale.com/kb/1019/subnets/#advertise-subnet-routes
apiVersion: tailscale.com/v1alpha1
kind: Connector
metadata:
name: exposepods
spec:
subnetRouter:
routes:
- "10.40.0.0/14"
tags:
- "tag:subnet"
hostname: pods-subnetrouter

View File

@@ -79,9 +79,16 @@ type tailscaleSTSConfig struct {
Hostname string
Tags []string // if empty, use defaultTags
// Routes is a list of CIDRs to pass via --advertise-routes flag
// Should only be set if this is config for subnetRouter
Routes string
// Connector specifies a configuration of a Connector instance if that's
// what this StatefulSet should be created for.
Connector *connector
}
type connector struct {
// routes is a list of subnet routes that this Connector should expose.
routes string
// isExitNode defines whether this Connector should act as an exit node.
isExitNode bool
}
type tailscaleSTSReconciler struct {
@@ -419,19 +426,24 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
},
},
})
} else if len(sts.Routes) > 0 {
} else if sts.Connector != nil {
// We need to provide these env vars even if the values are empty to
// ensure that a transition from a Connector with a defined subnet
// router or exit node to one without succeeds.
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_EXTRA_ARGS",
Value: fmt.Sprintf("--advertise-exit-node=%v", sts.Connector.isExitNode),
})
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_ROUTES",
Value: sts.Routes,
Value: sts.Connector.routes,
})
}
if a.tsFirewallMode != "" {
container.Env = append(container.Env, corev1.EnvVar{
Name: "TS_DEBUG_FIREWALL_MODE",
Value: a.tsFirewallMode,
},
)
})
}
ss.ObjectMeta = metav1.ObjectMeta{
Name: headlessSvc.Name,

View File

@@ -6,6 +6,9 @@
package v1alpha1
import (
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -17,17 +20,19 @@ var ConnectorKind = "Connector"
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster,shortName=cn
// +kubebuilder:printcolumn:name="SubnetRoutes",type="string",JSONPath=`.status.subnetRouter.routes`,description="Cluster CIDR ranges exposed to tailnet via subnet router"
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "ConnectorReady")].reason`,description="Status of the components deployed by the connector"
// +kubebuilder:printcolumn:name="SubnetRoutes",type="string",JSONPath=`.status.subnetRoutes`,description="CIDR ranges exposed to tailnet by a subnet router defined via this Connector instance."
// +kubebuilder:printcolumn:name="IsExitNode",type="string",JSONPath=`.status.isExitNode`,description="Whether this Connector instance defines an exit node."
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=`.status.conditions[?(@.type == "ConnectorReady")].reason`,description="Status of the deployed Connector resources."
type Connector struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
// Desired state of the Connector resource.
// ConnectorSpec describes the desired Tailscale component.
Spec ConnectorSpec `json:"spec"`
// Status of the Connector. This is set and managed by the Tailscale operator.
// ConnectorStatus describes the status of the Connector. This is set
// and managed by the Tailscale operator.
// +optional
Status ConnectorStatus `json:"status"`
}
@@ -41,40 +46,69 @@ type ConnectorList struct {
Items []Connector `json:"items"`
}
// ConnectorSpec defines the desired state of a ConnectorSpec.
// ConnectorSpec describes a Tailscale node to be deployed in the cluster.
// +kubebuilder:validation:XValidation:rule="has(self.subnetRouter) || self.exitNode == true",message="A Connector needs to be either an exit node or a subnet router, or both."
type ConnectorSpec struct {
// SubnetRouter configures a Tailscale subnet router to be deployed in
// the cluster. If unset no subnet router will be deployed.
// Tags that the Tailscale node will be tagged with.
// Defaults to [tag:k8s].
// To autoapprove the subnet routes or exit node defined by a Connector,
// you can configure Tailscale ACLs to give these tags the necessary
// permissions.
// See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes
// If you specify custom tags here, you must also make the operator an owner of these tags.
// See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
// Tags cannot be changed once a Connector node has been created.
// Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
// +optional
Tags Tags `json:"tags,omitempty"`
// Hostname is the tailnet hostname that should be assigned to the
// Connector node. If unset, hostname is defaulted to <connector
// name>-connector. Hostname can contain lower case letters, numbers and
// dashes, it must not start or end with a dash and must be between 2
// and 63 characters long.
// +optional
Hostname Hostname `json:"hostname,omitempty"`
// SubnetRouter defines subnet routes that the Connector node should
// expose to tailnet. If unset, none are exposed.
// https://tailscale.com/kb/1019/subnets/
// +optional
SubnetRouter *SubnetRouter `json:"subnetRouter"`
// ExitNode defines whether the Connector node should act as a
// Tailscale exit node. Defaults to false.
// https://tailscale.com/kb/1103/exit-nodes
// +optional
ExitNode bool `json:"exitNode"`
}
// SubnetRouter describes a subnet router.
// +kubebuilder:validation:XValidation:rule="has(self.tags) == has(oldSelf.tags)",message="Subnetrouter tags cannot be changed. Delete and redeploy the Connector if you need to change it."
// SubnetRouter defines subnet routes that should be exposed to tailnet via a
// Connector node.
type SubnetRouter struct {
// Routes refer to in-cluster CIDRs that the subnet router should make
// available. Route values must be strings that represent a valid IPv4
// or IPv6 CIDR range. Values can be Tailscale 4via6 subnet routes.
// https://tailscale.com/kb/1201/4via6-subnets/
Routes []Route `json:"routes"`
// Tags that the Tailscale node will be tagged with. If you want the
// subnet router to be autoapproved, you can configure Tailscale ACLs to
// autoapprove the subnetrouter's CIDRs for these tags.
// See https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes
// Defaults to tag:k8s.
// If you specify custom tags here, you must also make tag:k8s-operator owner of the custom tag.
// See https://tailscale.com/kb/1236/kubernetes-operator/#setting-up-the-kubernetes-operator.
// Tags cannot be changed once a Connector has been created.
// Tag values must be in form ^tag:[a-zA-Z][a-zA-Z0-9-]*$.
// +optional
Tags []Tag `json:"tags,omitempty"`
// Hostname is the tailnet hostname that should be assigned to the
// subnet router node. If unset hostname is defaulted to <connector
// name>-subnetrouter. Hostname can contain lower case letters, numbers
// and dashes, it must not start or end with a dash and must be between
// 2 and 63 characters long.
// +optional
Hostname Hostname `json:"hostname,omitempty"`
Routes Routes `json:"routes"`
}
type Tags []Tag
func (tags Tags) Stringify() []string {
stringTags := make([]string, len(tags))
for i, t := range tags {
stringTags[i] = string(t)
}
return stringTags
}
type Routes []Route
func (routes Routes) Stringify() string {
var sb strings.Builder
sb.WriteString(string(routes[0]))
for _, r := range routes[1:] {
sb.WriteString(fmt.Sprintf(",%s", r))
}
return sb.String()
}
// +kubebuilder:validation:Type=string
@@ -91,28 +125,19 @@ type Hostname string
// ConnectorStatus defines the observed state of the Connector.
type ConnectorStatus struct {
// List of status conditions to indicate the status of the Connector.
// Known condition types are `ConnectorReady`.
// +listType=map
// +listMapKey=type
// +optional
Conditions []ConnectorCondition `json:"conditions"`
// SubnetRouter status is the current status of a subnet router
// SubnetRoutes are the routes currently exposed to tailnet via this
// Connector instance.
// +optional
SubnetRouter *SubnetRouterStatus `json:"subnetRouter"`
}
// SubnetRouter status is the current status of a subnet router if deployed
type SubnetRouterStatus struct {
// Routes are the CIDRs currently exposed via subnet router
Routes string `json:"routes"`
// Ready is the ready status of the subnet router
Ready metav1.ConditionStatus `json:"ready"`
// Reason is the reason for the subnet router status
Reason string `json:"reason"`
// Message is a more verbose reason for the current subnet router status
Message string `json:"message"`
SubnetRoutes string `json:"subnetRoutes"`
// IsExitNode is set to true if the Connector acts as an exit node.
// +optional
IsExitNode bool `json:"isExitNode"`
}
// ConnectorCondition contains condition information for a Connector.
@@ -147,7 +172,7 @@ type ConnectorCondition struct {
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// ConnectorConditionType represents a Connector condition type
// ConnectorConditionType represents a Connector condition type.
type ConnectorConditionType string
const (

View File

@@ -92,6 +92,11 @@ func (in *ConnectorList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectorSpec) DeepCopyInto(out *ConnectorSpec) {
*out = *in
if in.Tags != nil {
in, out := &in.Tags, &out.Tags
*out = make(Tags, len(*in))
copy(*out, *in)
}
if in.SubnetRouter != nil {
in, out := &in.SubnetRouter, &out.SubnetRouter
*out = new(SubnetRouter)
@@ -119,11 +124,6 @@ func (in *ConnectorStatus) DeepCopyInto(out *ConnectorStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.SubnetRouter != nil {
in, out := &in.SubnetRouter, &out.SubnetRouter
*out = new(SubnetRouterStatus)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectorStatus.
@@ -136,17 +136,31 @@ 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 Routes) DeepCopyInto(out *Routes) {
{
in := &in
*out = make(Routes, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Routes.
func (in Routes) DeepCopy() Routes {
if in == nil {
return nil
}
out := new(Routes)
in.DeepCopyInto(out)
return *out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SubnetRouter) DeepCopyInto(out *SubnetRouter) {
*out = *in
if in.Routes != nil {
in, out := &in.Routes, &out.Routes
*out = make([]Route, len(*in))
copy(*out, *in)
}
if in.Tags != nil {
in, out := &in.Tags, &out.Tags
*out = make([]Tag, len(*in))
*out = make(Routes, len(*in))
copy(*out, *in)
}
}
@@ -162,16 +176,20 @@ func (in *SubnetRouter) DeepCopy() *SubnetRouter {
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SubnetRouterStatus) DeepCopyInto(out *SubnetRouterStatus) {
*out = *in
func (in Tags) DeepCopyInto(out *Tags) {
{
in := &in
*out = make(Tags, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubnetRouterStatus.
func (in *SubnetRouterStatus) DeepCopy() *SubnetRouterStatus {
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Tags.
func (in Tags) DeepCopy() Tags {
if in == nil {
return nil
}
out := new(SubnetRouterStatus)
out := new(Tags)
in.DeepCopyInto(out)
return out
return *out
}