@ -29,6 +29,7 @@ import (
"sigs.k8s.io/yaml"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
kubeutils "tailscale.com/k8s-operator"
tsoperator "tailscale.com/k8s-operator"
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
"tailscale.com/net/netutil"
@ -92,10 +93,6 @@ const (
podAnnotationLastSetTailnetTargetFQDN = "tailscale.com/operator-last-set-ts-tailnet-target-fqdn"
// podAnnotationLastSetConfigFileHash is sha256 hash of the current tailscaled configuration contents.
podAnnotationLastSetConfigFileHash = "tailscale.com/operator-last-set-config-file-hash"
// tailscaledConfigKey is the name of the key in proxy Secret Data that
// holds the tailscaled config contents.
tailscaledConfigKey = "tailscaled"
)
var (
@ -174,11 +171,11 @@ func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.Suga
return nil , fmt . Errorf ( "failed to reconcile headless service: %w" , err )
}
secretName , tsConfigHash , err := a . createOrGetSecret ( ctx , logger , sts , hsvc )
secretName , tsConfigHash , configs, err := a . createOrGetSecret ( ctx , logger , sts , hsvc )
if err != nil {
return nil , fmt . Errorf ( "failed to create or get API key secret: %w" , err )
}
_ , err = a . reconcileSTS ( ctx , logger , sts , hsvc , secretName , tsConfigHash )
_ , err = a . reconcileSTS ( ctx , logger , sts , hsvc , secretName , tsConfigHash , configs )
if err != nil {
return nil , fmt . Errorf ( "failed to reconcile statefulset: %w" , err )
}
@ -291,7 +288,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
return createOrUpdate ( ctx , a . Client , a . operatorNamespace , hsvc , func ( svc * corev1 . Service ) { svc . Spec = hsvc . Spec } )
}
func ( a * tailscaleSTSReconciler ) createOrGetSecret ( ctx context . Context , logger * zap . SugaredLogger , stsC * tailscaleSTSConfig , hsvc * corev1 . Service ) ( string , string , error ) {
func ( a * tailscaleSTSReconciler ) createOrGetSecret ( ctx context . Context , logger * zap . SugaredLogger , stsC * tailscaleSTSConfig , hsvc * corev1 . Service ) ( secretName , hash string , configs tailscaleConfigs , _ error ) {
secret := & corev1 . Secret {
ObjectMeta : metav1 . ObjectMeta {
// Hardcode a -0 suffix so that in future, if we support
@ -307,25 +304,23 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
logger . Debugf ( "secret %s/%s already exists" , secret . GetNamespace ( ) , secret . GetName ( ) )
orig = secret . DeepCopy ( )
} else if ! apierrors . IsNotFound ( err ) {
return "" , "" , err
return "" , "" , nil , err
}
var (
authKey , hash string
)
var authKey string
if orig == nil {
// Initially it contains only tailscaled config, but when the
// proxy starts, it will also store there the state, certs and
// ACME account key.
sts , err := getSingleObject [ appsv1 . StatefulSet ] ( ctx , a . Client , a . operatorNamespace , stsC . ChildResourceLabels )
if err != nil {
return "" , "" , err
return "" , "" , nil , err
}
if sts != nil {
// StatefulSet exists, so we have already created the secret.
// If the secret is missing, they should delete the StatefulSet.
logger . Errorf ( "Tailscale proxy secret doesn't exist, but the corresponding StatefulSet %s/%s already does. Something is wrong, please delete the StatefulSet." , sts . GetNamespace ( ) , sts . GetName ( ) )
return "" , "" , nil
return "" , "" , nil , nil
}
// Create API Key secret which is going to be used by the statefulset
// to authenticate with Tailscale.
@ -336,45 +331,58 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
}
authKey , err = a . newAuthKey ( ctx , tags )
if err != nil {
return "" , "" , err
return "" , "" , nil , err
}
}
configs , err := tailscaledConfig ( stsC , authKey , orig )
if err != nil {
return "" , "" , nil , fmt . Errorf ( "error creating tailscaled config: %w" , err )
}
confFileBytes , h , err := tailscaledConfig ( stsC , authKey , orig )
hash, err = tailscaledConfigHash ( configs )
if err != nil {
return "" , "" , fmt . Errorf ( "error creating tailscaled config: %w" , err )
return "" , "" , nil , fmt . Errorf ( "error calculating hash of tailscaled configs: %w" , err )
}
latest := tailcfg . CapabilityVersion ( - 1 )
var latestConfig ipn . ConfigVAlpha
for key , val := range configs {
fn := kubeutils . TailscaledConfigFileNameForCap ( key )
b , err := json . Marshal ( val )
if err != nil {
return "" , "" , nil , fmt . Errorf ( "error marshalling tailscaled config: %w" , err )
}
mak . Set ( & secret . StringData , fn , string ( b ) )
if key > latest {
latest = key
latestConfig = val
}
}
hash = h
mak . Set ( & secret . StringData , tailscaledConfigKey , string ( confFileBytes ) )
if stsC . ServeConfig != nil {
j , err := json . Marshal ( stsC . ServeConfig )
if err != nil {
return "" , "" , err
return "" , "" , nil , err
}
mak . Set ( & secret . StringData , "serve-config" , string ( j ) )
}
if orig != nil {
logger . Debugf ( "patching the existing proxy Secret with tailscaled config %s" , sanitizeConfigBytes ( secret . Data [ tailscaledConfigKey ] ) )
logger . Debugf ( "patching the existing proxy Secret with tailscaled config %s" , sanitizeConfigBytes ( latestConfig ) )
if err := a . Patch ( ctx , secret , client . MergeFrom ( orig ) ) ; err != nil {
return "" , "" , err
return "" , "" , nil , err
}
} else {
logger . Debugf ( "creating a new Secret for the proxy with tailscaled config %s" , sanitizeConfigBytes ( [ ] byte ( secret . StringData [ tailscaledConfigKey ] ) ) )
logger . Debugf ( "creating a new Secret for the proxy with tailscaled config %s" , sanitizeConfigBytes ( latestConfig ) )
if err := a . Create ( ctx , secret ) ; err != nil {
return "" , "" , err
return "" , "" , nil , err
}
}
return secret . Name , hash , nil
return secret . Name , hash , configs , nil
}
// sanitizeConfigBytes returns ipn.ConfigVAlpha in string form with redacted
// auth key.
func sanitizeConfigBytes ( bs [ ] byte ) string {
c := & ipn . ConfigVAlpha { }
if err := json . Unmarshal ( bs , c ) ; err != nil {
return "invalid config"
}
func sanitizeConfigBytes ( c ipn . ConfigVAlpha ) string {
if c . AuthKey != nil {
c . AuthKey = ptr . To ( "**redacted**" )
}
@ -437,7 +445,7 @@ var proxyYaml []byte
//go:embed deploy/manifests/userspace-proxy.yaml
var userspaceProxyYaml [ ] byte
func ( a * tailscaleSTSReconciler ) reconcileSTS ( ctx context . Context , logger * zap . SugaredLogger , sts * tailscaleSTSConfig , headlessSvc * corev1 . Service , proxySecret , tsConfigHash string ) ( * appsv1 . StatefulSet , error ) {
func ( a * tailscaleSTSReconciler ) reconcileSTS ( ctx context . Context , logger * zap . SugaredLogger , sts * tailscaleSTSConfig , headlessSvc * corev1 . Service , proxySecret , tsConfigHash string , configs map [ tailcfg . CapabilityVersion ] ipn . ConfigVAlpha ) ( * appsv1 . StatefulSet , error ) {
ss := new ( appsv1 . StatefulSet )
if sts . ServeConfig != nil && sts . ForwardClusterTrafficViaL7IngressProxy != true { // If forwarding cluster traffic via is required we need non-userspace + NET_ADMIN + forwarding
if err := yaml . Unmarshal ( userspaceProxyYaml , & ss ) ; err != nil {
@ -493,9 +501,15 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
Value : proxySecret ,
} ,
corev1 . EnvVar {
// Old tailscaled config key is still used for backwards compatibility.
Name : "EXPERIMENTAL_TS_CONFIGFILE_PATH" ,
Value : "/etc/tsconfig/tailscaled" ,
} ,
corev1 . EnvVar {
// New style is in the form of cap-<capability-version>.hujson.
Name : "TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR" ,
Value : "/etc/tsconfig" ,
} ,
)
if sts . ForwardClusterTrafficViaL7IngressProxy {
container . Env = append ( container . Env , corev1 . EnvVar {
@ -505,18 +519,16 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
}
// Configure containeboot to run tailscaled with a configfile read from the state Secret.
mak . Set ( & ss . Spec . Template . Annotations , podAnnotationLastSetConfigFileHash , tsConfigHash )
pod . Spec . Volumes = append ( ss . Spec . Template . Spec . Volumes , corev1 . Volume {
configVolume := corev1 . Volume {
Name : "tailscaledconfig" ,
VolumeSource : corev1 . VolumeSource {
Secret : & corev1 . SecretVolumeSource {
SecretName : proxySecret ,
Items : [ ] corev1 . KeyToPath { {
Key : tailscaledConfigKey ,
Path : tailscaledConfigKey ,
} } ,
} ,
} ,
} )
}
pod . Spec . Volumes = append ( ss . Spec . Template . Spec . Volumes , configVolume )
container . VolumeMounts = append ( container . VolumeMounts , corev1 . VolumeMount {
Name : "tailscaledconfig" ,
ReadOnly : true ,
@ -571,10 +583,7 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
VolumeSource : corev1 . VolumeSource {
Secret : & corev1 . SecretVolumeSource {
SecretName : proxySecret ,
Items : [ ] corev1 . KeyToPath { {
Key : "serve-config" ,
Path : "serve-config" ,
} } ,
Items : [ ] corev1 . KeyToPath { { Key : "serve-config" , Path : "serve-config" } } ,
} ,
} ,
} )
@ -716,42 +725,82 @@ func enableMetrics(ss *appsv1.StatefulSet, pc *tsapi.ProxyClass) {
}
}
func readAuthKey ( secret * corev1 . Secret , key string ) ( * string , error ) {
origConf := & ipn . ConfigVAlpha { }
if err := json . Unmarshal ( [ ] byte ( secret . Data [ key ] ) , origConf ) ; err != nil {
return nil , fmt . Errorf ( "error unmarshaling previous tailscaled config in %q: %w" , key , err )
}
return origConf . AuthKey , nil
}
// tailscaledConfig takes a proxy config, a newly generated auth key if
// generated and a Secret with the previous proxy state and auth key and
// produces returns tailscaled configuration and a hash of that configuration.
func tailscaledConfig ( stsC * tailscaleSTSConfig , newAuthkey string , oldSecret * corev1 . Secret ) ( [ ] byte , string , error ) {
conf := ipn . ConfigVAlpha {
// returns tailscaled configuration and a hash of that configuration.
//
// As of 2024-05-09 it also returns legacy tailscaled config without the
// later added NoStatefulFilter field to support proxies older than cap95.
// TODO (irbekrm): remove the legacy config once we no longer need to support
// versions older than cap94,
// https://tailscale.com/kb/1236/kubernetes-operator#operator-and-proxies
func tailscaledConfig ( stsC * tailscaleSTSConfig , newAuthkey string , oldSecret * corev1 . Secret ) ( tailscaleConfigs , error ) {
conf := & ipn . ConfigVAlpha {
Version : "alpha0" ,
AcceptDNS : "false" ,
AcceptRoutes : "false" , // AcceptRoutes defaults to true
Locked : "false" ,
Hostname : & stsC . Hostname ,
NoStatefulFiltering : "false" ,
}
// For egress proxies only, we need to ensure that stateful filtering is
// not in place so that traffic from cluster can be forwarded via
// Tailscale IPs.
if stsC . TailnetTargetFQDN != "" || stsC . TailnetTargetIP != "" {
conf . NoStatefulFiltering = "true"
}
if stsC . Connector != nil {
routes , err := netutil . CalcAdvertiseRoutes ( stsC . Connector . routes , stsC . Connector . isExitNode )
if err != nil {
return nil , "" , fmt . Errorf ( "error calculating routes: %w" , err )
return nil , fmt . Errorf ( "error calculating routes: %w" , err )
}
conf . AdvertiseRoutes = routes
}
if newAuthkey != "" {
conf . AuthKey = & newAuthkey
} else if oldSecret != nil && len ( oldSecret . Data [ tailscaledConfigKey ] ) > 0 { // write to StringData, read from Data as StringData is write-only
origConf := & ipn . ConfigVAlpha { }
if err := json . Unmarshal ( [ ] byte ( oldSecret . Data [ tailscaledConfigKey ] ) , origConf ) ; err != nil {
return nil , "" , fmt . Errorf ( "error unmarshaling previous tailscaled config: %w" , err )
} else if oldSecret != nil {
var err error
latest := tailcfg . CapabilityVersion ( - 1 )
latestStr := ""
for k , data := range oldSecret . Data {
// write to StringData, read from Data as StringData is write-only
if len ( data ) == 0 {
continue
}
v , err := kubeutils . CapVerFromFileName ( k )
if err != nil {
continue
}
conf . AuthKey = origConf . AuthKey
if v > latest {
latestStr = k
latest = v
}
confFileBytes , err := json . Marshal ( conf )
if err != nil {
return nil , "" , fmt . Errorf ( "error marshaling tailscaled config : %w" , err )
}
hash , err := hashBytes ( confFileBytes )
// Allow for configs that don't contain an auth key. Perhaps
// users have some mechanisms to delete them. Auth key is
// normally not needed after the initial login.
if latestStr != "" {
conf . AuthKey , err = readAuthKey ( oldSecret , latestStr )
if err != nil {
return nil , "" , fmt . Errorf ( "error calculating config hash: %w" , err )
return nil , err
}
}
return confFileBytes , hash , nil
}
capVerConfigs := make ( map [ tailcfg . CapabilityVersion ] ipn . ConfigVAlpha )
capVerConfigs [ 95 ] = * conf
// legacy config should not contain NoStatefulFiltering field.
conf . NoStatefulFiltering . Clear ( )
capVerConfigs [ 94 ] = * conf
return capVerConfigs , nil
}
// ptrObject is a type constraint for pointer types that implement
@ -761,7 +810,9 @@ type ptrObject[T any] interface {
* T
}
// hashBytes produces a hash for the provided bytes that is the same across
type tailscaleConfigs map [ tailcfg . CapabilityVersion ] ipn . ConfigVAlpha
// hashBytes produces a hash for the provided tailscaled config that is the same across
// different invocations of this code. We do not use the
// tailscale.com/deephash.Hash here because that produces a different hash for
// the same value in different tailscale builds. The hash we are producing here
@ -770,10 +821,13 @@ type ptrObject[T any] interface {
// thing that changed is operator version (the hash is also exposed to users via
// an annotation and might be confusing if it changes without the config having
// changed).
func hashBytes ( b [ ] byte ) ( string , error ) {
h := sha256 . New ( )
_ , err := h . Write ( b )
func tailscaledConfigHash ( c tailscaleConfigs ) ( string , error ) {
b , err := json . Marshal ( c )
if err != nil {
return "" , fmt . Errorf ( "error marshalling tailscaled configs: %w" , err )
}
h := sha256 . New ( )
if _ , err = h . Write ( b ) ; err != nil {
return "" , fmt . Errorf ( "error calculating hash: %w" , err )
}
return fmt . Sprintf ( "%x" , h . Sum ( nil ) ) , nil