Compare commits
33 Commits
b25920dfc0
..
webnet
| Author | SHA1 | Date | |
|---|---|---|---|
| cada6936b9 | |||
| 78c4511a3d | |||
| 3a9f6f463a | |||
| 7bfc64c379 | |||
| e7270026f7 | |||
| 4618ee1496 | |||
| 915dca44fe | |||
| 6e83d5291b | |||
| 21d0f11d85 | |||
| 0df765eb60 | |||
| 52cae45f81 | |||
| 7fd2507611 | |||
| 8514045909 | |||
| 7f5983eaab | |||
| 143581c955 | |||
| d9efc3bae2 | |||
| 9e36a7f27f | |||
| 8277fc0f1d | |||
| e32520659d | |||
| e8eb9d71c2 | |||
| c4ff4c4835 | |||
| 68ecc4b033 | |||
| 9f96b7434c | |||
| b04b4f7751 | |||
| f961db8925 | |||
| fde5f11895 | |||
| 756ba1d5ec | |||
| 68670f938b | |||
| 03c3551ee5 | |||
| 6b7caaf7ee | |||
| 27e6fed0c1 | |||
| dca1d8eea1 | |||
| 85d6ba9473 |
@@ -136,7 +136,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
"tailscale.com/client/tailscale"
|
|
||||||
"tailscale.com/health"
|
"tailscale.com/health"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/conffile"
|
"tailscale.com/ipn/conffile"
|
||||||
@@ -173,7 +173,6 @@ func main() {
|
|||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
log.SetPrefix("boot: ")
|
log.SetPrefix("boot: ")
|
||||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
|
||||||
|
|
||||||
cfg, err := configFromEnv()
|
cfg, err := configFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/k8s-proxy/conf"
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -51,7 +52,7 @@ type KubeAPIServerTSServiceReconciler struct {
|
|||||||
client.Client
|
client.Client
|
||||||
recorder record.EventRecorder
|
recorder record.EventRecorder
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
tsClient tsClient
|
clients ClientProvider
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
defaultTags []string
|
defaultTags []string
|
||||||
operatorID string // stableID of the operator's Tailscale device
|
operatorID string // stableID of the operator's Tailscale device
|
||||||
@@ -77,15 +78,14 @@ func (r *KubeAPIServerTSServiceReconciler) Reconcile(ctx context.Context, req re
|
|||||||
|
|
||||||
serviceName := serviceNameForAPIServerProxy(pg)
|
serviceName := serviceNameForAPIServerProxy(pg)
|
||||||
logger = logger.With("Tailscale Service", serviceName)
|
logger = logger.With("Tailscale Service", serviceName)
|
||||||
|
tsClient, err := r.clients.For(pg.Spec.Tailnet)
|
||||||
tailscaleClient, err := r.getClient(ctx, pg.Spec.Tailnet)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if markedForDeletion(pg) {
|
if markedForDeletion(pg) {
|
||||||
logger.Debugf("ProxyGroup is being deleted, ensuring any created resources are cleaned up")
|
logger.Debugf("ProxyGroup is being deleted, ensuring any created resources are cleaned up")
|
||||||
if err = r.maybeCleanup(ctx, serviceName, pg, logger, tailscaleClient); err != nil && strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
if err = r.maybeCleanup(ctx, serviceName, pg, logger, tsClient); err != nil && strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||||
logger.Infof("optimistic lock error, retrying: %s", err)
|
logger.Infof("optimistic lock error, retrying: %s", err)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func (r *KubeAPIServerTSServiceReconciler) Reconcile(ctx context.Context, req re
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = r.maybeProvision(ctx, serviceName, pg, logger, tailscaleClient)
|
err = r.maybeProvision(ctx, serviceName, pg, logger, tsClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||||
logger.Infof("optimistic lock error, retrying: %s", err)
|
logger.Infof("optimistic lock error, retrying: %s", err)
|
||||||
@@ -105,31 +105,15 @@ func (r *KubeAPIServerTSServiceReconciler) Reconcile(ctx context.Context, req re
|
|||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClient returns the appropriate Tailscale client for the given tailnet.
|
|
||||||
// If no tailnet is specified, returns the default client.
|
|
||||||
func (r *KubeAPIServerTSServiceReconciler) getClient(ctx context.Context, tailnetName string) (tsClient,
|
|
||||||
error) {
|
|
||||||
if tailnetName == "" {
|
|
||||||
return r.tsClient, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tc, _, err := clientForTailnet(ctx, r.Client, r.tsNamespace, tailnetName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybeProvision ensures that a Tailscale Service for this ProxyGroup exists
|
// maybeProvision ensures that a Tailscale Service for this ProxyGroup exists
|
||||||
// and is up to date.
|
// and is up to date.
|
||||||
//
|
//
|
||||||
// Returns true if the operation resulted in a Tailscale Service update.
|
// Returns true if the operation resulted in a Tailscale Service update.
|
||||||
func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, serviceName tailcfg.ServiceName, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsClient) (err error) {
|
func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, serviceName tailcfg.ServiceName, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsclient.Client) (err error) {
|
||||||
var dnsName string
|
var dnsName string
|
||||||
oldPGStatus := pg.Status.DeepCopy()
|
oldPGStatus := pg.Status.DeepCopy()
|
||||||
defer func() {
|
defer func() {
|
||||||
podsAdvertising, podsErr := numberPodsAdvertising(ctx, r.Client, r.tsNamespace, pg.Name, serviceName)
|
podsAdvertising, podsErr := numberPodsAdvertising(ctx, r.Client, r.tsNamespace, pg.Name, serviceName.String())
|
||||||
if podsErr != nil {
|
if podsErr != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("failed to get number of advertised Pods: %w", podsErr))
|
err = errors.Join(err, fmt.Errorf("failed to get number of advertised Pods: %w", podsErr))
|
||||||
// Continue, updating the status with the best available information.
|
// Continue, updating the status with the best available information.
|
||||||
@@ -177,8 +161,8 @@ func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, s
|
|||||||
|
|
||||||
// 1. Check there isn't a Tailscale Service with the same hostname
|
// 1. Check there isn't a Tailscale Service with the same hostname
|
||||||
// already created and not owned by this ProxyGroup.
|
// already created and not owned by this ProxyGroup.
|
||||||
existingTSSvc, err := tsClient.GetVIPService(ctx, serviceName)
|
existingTSSvc, err := tsClient.VIPServices().Get(ctx, serviceName.String())
|
||||||
if err != nil && !isErrorTailscaleServiceNotFound(err) {
|
if err != nil && !tailscale.IsNotFound(err) {
|
||||||
return fmt.Errorf("error getting Tailscale Service %q: %w", serviceName, err)
|
return fmt.Errorf("error getting Tailscale Service %q: %w", serviceName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +186,8 @@ func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, s
|
|||||||
serviceTags = pg.Spec.Tags.Stringify()
|
serviceTags = pg.Spec.Tags.Stringify()
|
||||||
}
|
}
|
||||||
|
|
||||||
tsSvc := &tailscale.VIPService{
|
tsSvc := tailscale.VIPService{
|
||||||
Name: serviceName,
|
Name: serviceName.String(),
|
||||||
Tags: serviceTags,
|
Tags: serviceTags,
|
||||||
Ports: []string{"tcp:443"},
|
Ports: []string{"tcp:443"},
|
||||||
Comment: managedTSServiceComment,
|
Comment: managedTSServiceComment,
|
||||||
@@ -216,10 +200,10 @@ func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, s
|
|||||||
// 2. Ensure the Tailscale Service exists and is up to date.
|
// 2. Ensure the Tailscale Service exists and is up to date.
|
||||||
if existingTSSvc == nil ||
|
if existingTSSvc == nil ||
|
||||||
!slices.Equal(tsSvc.Tags, existingTSSvc.Tags) ||
|
!slices.Equal(tsSvc.Tags, existingTSSvc.Tags) ||
|
||||||
!ownersAreSetAndEqual(tsSvc, existingTSSvc) ||
|
!ownersAreSetAndEqual(tsSvc, *existingTSSvc) ||
|
||||||
!slices.Equal(tsSvc.Ports, existingTSSvc.Ports) {
|
!slices.Equal(tsSvc.Ports, existingTSSvc.Ports) {
|
||||||
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
||||||
if err = tsClient.CreateOrUpdateVIPService(ctx, tsSvc); err != nil {
|
if err = tsClient.VIPServices().CreateOrUpdate(ctx, tsSvc); err != nil {
|
||||||
return fmt.Errorf("error creating Tailscale Service: %w", err)
|
return fmt.Errorf("error creating Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,10 +232,10 @@ func (r *KubeAPIServerTSServiceReconciler) maybeProvision(ctx context.Context, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// maybeCleanup ensures that any resources, such as a Tailscale Service created for this Service, are cleaned up when the
|
// maybeCleanup ensures that any resources, such as a Tailscale Service created for this Service, are cleaned up when the
|
||||||
// Service is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
|
// Service is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup. The Tailscale Service is only
|
||||||
// deleted if it does not contain any other owner references. If it does, the cleanup only removes the owner reference
|
// deleted if it does not contain any other owner references. If it does, the cleanup only removes the owner reference
|
||||||
// corresponding to this Service.
|
// corresponding to this Service.
|
||||||
func (r *KubeAPIServerTSServiceReconciler) maybeCleanup(ctx context.Context, serviceName tailcfg.ServiceName, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsClient) (err error) {
|
func (r *KubeAPIServerTSServiceReconciler) maybeCleanup(ctx context.Context, serviceName tailcfg.ServiceName, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, client tsclient.Client) (err error) {
|
||||||
ix := slices.Index(pg.Finalizers, proxyPGFinalizerName)
|
ix := slices.Index(pg.Finalizers, proxyPGFinalizerName)
|
||||||
if ix < 0 {
|
if ix < 0 {
|
||||||
logger.Debugf("no finalizer, nothing to do")
|
logger.Debugf("no finalizer, nothing to do")
|
||||||
@@ -265,7 +249,7 @@ func (r *KubeAPIServerTSServiceReconciler) maybeCleanup(ctx context.Context, ser
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if _, err = cleanupTailscaleService(ctx, tsClient, serviceName, r.operatorID, logger); err != nil {
|
if _, err = cleanupTailscaleService(ctx, client, serviceName.String(), r.operatorID, logger); err != nil {
|
||||||
return fmt.Errorf("error deleting Tailscale Service: %w", err)
|
return fmt.Errorf("error deleting Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,16 +262,16 @@ func (r *KubeAPIServerTSServiceReconciler) maybeCleanup(ctx context.Context, ser
|
|||||||
|
|
||||||
// maybeDeleteStaleServices deletes Services that have previously been created for
|
// maybeDeleteStaleServices deletes Services that have previously been created for
|
||||||
// this ProxyGroup but are no longer needed.
|
// this ProxyGroup but are no longer needed.
|
||||||
func (r *KubeAPIServerTSServiceReconciler) maybeDeleteStaleServices(ctx context.Context, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsClient) error {
|
func (r *KubeAPIServerTSServiceReconciler) maybeDeleteStaleServices(ctx context.Context, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsclient.Client) error {
|
||||||
serviceName := serviceNameForAPIServerProxy(pg)
|
serviceName := serviceNameForAPIServerProxy(pg)
|
||||||
|
|
||||||
svcs, err := tsClient.ListVIPServices(ctx)
|
svcs, err := tsClient.VIPServices().List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error listing Tailscale Services: %w", err)
|
return fmt.Errorf("error listing Tailscale Services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range svcs.VIPServices {
|
for _, svc := range svcs {
|
||||||
if svc.Name == serviceName {
|
if svc.Name == serviceName.String() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,11 +290,11 @@ func (r *KubeAPIServerTSServiceReconciler) maybeDeleteStaleServices(ctx context.
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("Deleting Tailscale Service %s", svc.Name)
|
logger.Infof("Deleting Tailscale Service %s", svc.Name)
|
||||||
if err = tsClient.DeleteVIPService(ctx, svc.Name); err != nil && !isErrorTailscaleServiceNotFound(err) {
|
if err = tsClient.VIPServices().Delete(ctx, svc.Name); err != nil && !tailscale.IsNotFound(err) {
|
||||||
return fmt.Errorf("error deleting Tailscale Service %s: %w", svc.Name, err)
|
return fmt.Errorf("error deleting Tailscale Service %s: %w", svc.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = cleanupCertResources(ctx, r.Client, r.tsNamespace, svc.Name, pg); err != nil {
|
if err = cleanupCertResources(ctx, r.Client, r.tsNamespace, tailcfg.ServiceName(svc.Name), pg); err != nil {
|
||||||
return fmt.Errorf("failed to clean up cert resources: %w", err)
|
return fmt.Errorf("failed to clean up cert resources: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import (
|
|||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/k8s-proxy/conf"
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -93,8 +94,10 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
expectEqual(t, fc, pgCfgSecret)
|
expectEqual(t, fc, pgCfgSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
ft := &fakeTSClient{}
|
ft := &fakeTSClient{
|
||||||
ingressTSSvc := &tailscale.VIPService{
|
vipServices: make(map[string]tailscale.VIPService),
|
||||||
|
}
|
||||||
|
ingressTSSvc := tailscale.VIPService{
|
||||||
Name: "svc:some-ingress-hostname",
|
Name: "svc:some-ingress-hostname",
|
||||||
Comment: managedTSServiceComment,
|
Comment: managedTSServiceComment,
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
@@ -105,11 +108,11 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
Tags: []string{"tag:k8s"},
|
Tags: []string{"tag:k8s"},
|
||||||
Addrs: []string{"5.6.7.8"},
|
Addrs: []string{"5.6.7.8"},
|
||||||
}
|
}
|
||||||
ft.CreateOrUpdateVIPService(t.Context(), ingressTSSvc)
|
ft.VIPServices().CreateOrUpdate(t.Context(), ingressTSSvc)
|
||||||
|
|
||||||
r := &KubeAPIServerTSServiceReconciler{
|
r := &KubeAPIServerTSServiceReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
tsNamespace: ns,
|
tsNamespace: ns,
|
||||||
logger: zap.Must(zap.NewDevelopment()).Sugar(),
|
logger: zap.Must(zap.NewDevelopment()).Sugar(),
|
||||||
@@ -119,7 +122,7 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a Tailscale Service that will conflict with the initial config.
|
// Create a Tailscale Service that will conflict with the initial config.
|
||||||
if err := ft.CreateOrUpdateVIPService(t.Context(), &tailscale.VIPService{
|
if err := ft.VIPServices().CreateOrUpdate(t.Context(), tailscale.VIPService{
|
||||||
Name: "svc:" + pgName,
|
Name: "svc:" + pgName,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("creating initial Tailscale Service: %v", err)
|
t.Fatalf("creating initial Tailscale Service: %v", err)
|
||||||
@@ -135,7 +138,7 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
expectEqual(t, fc, pgCfgSecret) // Unchanged.
|
expectEqual(t, fc, pgCfgSecret) // Unchanged.
|
||||||
|
|
||||||
// Delete Tailscale Service; should see Service created and valid condition updated to true.
|
// Delete Tailscale Service; should see Service created and valid condition updated to true.
|
||||||
if err := ft.DeleteVIPService(t.Context(), "svc:"+pgName); err != nil {
|
if err := ft.VIPServices().Delete(t.Context(), "svc:"+pgName); err != nil {
|
||||||
t.Fatalf("deleting initial Tailscale Service: %v", err)
|
t.Fatalf("deleting initial Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
|
|
||||||
expectReconciled(t, r, "", pgName)
|
expectReconciled(t, r, "", pgName)
|
||||||
|
|
||||||
tsSvc, err := ft.GetVIPService(t.Context(), "svc:"+pgName)
|
tsSvc, err := ft.VIPServices().Get(t.Context(), "svc:"+pgName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service: %v", err)
|
t.Fatalf("getting Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
@@ -223,15 +226,15 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
p.Spec.KubeAPIServer = pg.Spec.KubeAPIServer
|
p.Spec.KubeAPIServer = pg.Spec.KubeAPIServer
|
||||||
})
|
})
|
||||||
expectReconciled(t, r, "", pgName)
|
expectReconciled(t, r, "", pgName)
|
||||||
_, err = ft.GetVIPService(t.Context(), "svc:"+pgName)
|
_, err = ft.VIPServices().Get(t.Context(), "svc:"+pgName)
|
||||||
if !isErrorTailscaleServiceNotFound(err) {
|
if !tailscale.IsNotFound(err) {
|
||||||
t.Fatalf("Expected 404, got: %v", err)
|
t.Fatalf("Expected 404, got: %v", err)
|
||||||
}
|
}
|
||||||
tsSvc, err = ft.GetVIPService(t.Context(), updatedServiceName)
|
tsSvc, err = ft.VIPServices().Get(t.Context(), updatedServiceName.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Expected renamed svc, got error: %v", err)
|
t.Fatalf("Expected renamed svc, got error: %v", err)
|
||||||
}
|
}
|
||||||
expectedTSSvc.Name = updatedServiceName
|
expectedTSSvc.Name = updatedServiceName.String()
|
||||||
if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
|
if !reflect.DeepEqual(tsSvc, expectedTSSvc) {
|
||||||
t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
|
t.Fatalf("expected Tailscale Service to be %+v, got %+v", expectedTSSvc, tsSvc)
|
||||||
}
|
}
|
||||||
@@ -269,17 +272,17 @@ func TestAPIServerProxyReconciler(t *testing.T) {
|
|||||||
expectMissing[corev1.Secret](t, fc, ns, updatedDomain)
|
expectMissing[corev1.Secret](t, fc, ns, updatedDomain)
|
||||||
expectMissing[rbacv1.Role](t, fc, ns, updatedDomain)
|
expectMissing[rbacv1.Role](t, fc, ns, updatedDomain)
|
||||||
expectMissing[rbacv1.RoleBinding](t, fc, ns, updatedDomain)
|
expectMissing[rbacv1.RoleBinding](t, fc, ns, updatedDomain)
|
||||||
_, err = ft.GetVIPService(t.Context(), updatedServiceName)
|
_, err = ft.VIPServices().Get(t.Context(), updatedServiceName.String())
|
||||||
if !isErrorTailscaleServiceNotFound(err) {
|
if !tailscale.IsNotFound(err) {
|
||||||
t.Fatalf("Expected 404, got: %v", err)
|
t.Fatalf("Expected 404, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingress Tailscale Service should not be affected.
|
// Ingress Tailscale Service should not be affected.
|
||||||
svc, err := ft.GetVIPService(t.Context(), ingressTSSvc.Name)
|
svc, err := ft.VIPServices().Get(t.Context(), ingressTSSvc.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting ingress Tailscale Service: %v", err)
|
t.Fatalf("getting ingress Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(svc, ingressTSSvc) {
|
if !reflect.DeepEqual(svc, &ingressTSSvc) {
|
||||||
t.Fatalf("expected ingress Tailscale Service to be unmodified %+v, got %+v", ingressTSSvc, svc)
|
t.Fatalf("expected ingress Tailscale Service to be unmodified %+v, got %+v", ingressTSSvc, svc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +295,6 @@ func TestExclusiveOwnerAnnotations(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
const (
|
const (
|
||||||
selfOperatorID = "self-id"
|
|
||||||
pg1Owner = `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg1","uid":"pg1-uid"}}]}`
|
pg1Owner = `{"ownerRefs":[{"operatorID":"self-id","resource":{"kind":"ProxyGroup","name":"pg1","uid":"pg1-uid"}}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
@@ -62,7 +64,7 @@ func TestConnector(t *testing.T) {
|
|||||||
recorder: record.NewFakeRecorder(10),
|
recorder: record.NewFakeRecorder(10),
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -252,7 +254,7 @@ func TestConnectorWithProxyClass(t *testing.T) {
|
|||||||
clock: cl,
|
clock: cl,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -346,7 +348,7 @@ func TestConnectorWithAppConnector(t *testing.T) {
|
|||||||
clock: cl,
|
clock: cl,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -446,7 +448,7 @@ func TestConnectorWithMultipleReplicas(t *testing.T) {
|
|||||||
clock: cl,
|
clock: cl,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
|
|||||||
@@ -784,8 +784,9 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/appc from tailscale.com/ipn/ipnlocal
|
tailscale.com/appc from tailscale.com/ipn/ipnlocal
|
||||||
💣 tailscale.com/atomicfile from tailscale.com/ipn+
|
💣 tailscale.com/atomicfile from tailscale.com/ipn+
|
||||||
tailscale.com/client/local from tailscale.com/client/tailscale+
|
tailscale.com/client/local from tailscale.com/client/tailscale+
|
||||||
tailscale.com/client/tailscale from tailscale.com/cmd/k8s-operator+
|
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale
|
||||||
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
|
tailscale.com/client/tailscale/apitype from tailscale.com/client/tailscale+
|
||||||
|
tailscale.com/client/tailscale/v2 from tailscale.com/cmd/k8s-operator+
|
||||||
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
tailscale.com/control/controlbase from tailscale.com/control/controlhttp+
|
||||||
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
|
tailscale.com/control/controlclient from tailscale.com/ipn/ipnlocal+
|
||||||
@@ -816,7 +817,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/health from tailscale.com/control/controlclient+
|
tailscale.com/health from tailscale.com/control/controlclient+
|
||||||
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/hostinfo from tailscale.com/client/web+
|
tailscale.com/hostinfo from tailscale.com/client/web+
|
||||||
tailscale.com/internal/client/tailscale from tailscale.com/cmd/k8s-operator+
|
tailscale.com/internal/client/tailscale from tailscale.com/feature/identityfederation+
|
||||||
tailscale.com/ipn from tailscale.com/client/local+
|
tailscale.com/ipn from tailscale.com/client/local+
|
||||||
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
tailscale.com/ipn/conffile from tailscale.com/ipn/ipnlocal+
|
||||||
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
💣 tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnlocal+
|
||||||
@@ -839,6 +840,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
|||||||
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
|
tailscale.com/k8s-operator/sessionrecording/spdy from tailscale.com/k8s-operator/sessionrecording
|
||||||
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
|
tailscale.com/k8s-operator/sessionrecording/tsrecorder from tailscale.com/k8s-operator/sessionrecording+
|
||||||
tailscale.com/k8s-operator/sessionrecording/ws from tailscale.com/k8s-operator/sessionrecording
|
tailscale.com/k8s-operator/sessionrecording/ws from tailscale.com/k8s-operator/sessionrecording
|
||||||
|
tailscale.com/k8s-operator/tsclient from tailscale.com/cmd/k8s-operator+
|
||||||
tailscale.com/kube/egressservices from tailscale.com/cmd/k8s-operator
|
tailscale.com/kube/egressservices from tailscale.com/cmd/k8s-operator
|
||||||
tailscale.com/kube/ingressservices from tailscale.com/cmd/k8s-operator
|
tailscale.com/kube/ingressservices from tailscale.com/cmd/k8s-operator
|
||||||
tailscale.com/kube/k8s-proxy/conf from tailscale.com/cmd/k8s-operator
|
tailscale.com/kube/k8s-proxy/conf from tailscale.com/cmd/k8s-operator
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -53,12 +52,13 @@ import (
|
|||||||
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
|
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
|
||||||
"sigs.k8s.io/kind/pkg/cmd"
|
"sigs.k8s.io/kind/pkg/cmd"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/client/tailscale/v2"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
|
"tailscale.com/util/must"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -106,7 +106,8 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(tmp, 0755); err != nil {
|
|
||||||
|
if err = os.MkdirAll(tmp, 0755); err != nil {
|
||||||
return 0, fmt.Errorf("failed to create temp dir: %w", err)
|
return 0, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +123,12 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
kindProvider = cluster.NewProvider(
|
kindProvider = cluster.NewProvider(
|
||||||
cluster.ProviderWithLogger(cmd.NewLogger()),
|
cluster.ProviderWithLogger(cmd.NewLogger()),
|
||||||
)
|
)
|
||||||
|
|
||||||
clusters, err := kindProvider.List()
|
clusters, err := kindProvider.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to list kind clusters: %w", err)
|
return 0, fmt.Errorf("failed to list kind clusters: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(clusters, kindClusterName) {
|
if !slices.Contains(clusters, kindClusterName) {
|
||||||
if err := kindProvider.Create(kindClusterName,
|
if err := kindProvider.Create(kindClusterName,
|
||||||
cluster.CreateWithWaitForReady(5*time.Minute),
|
cluster.CreateWithWaitForReady(5*time.Minute),
|
||||||
@@ -147,6 +150,7 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("error loading kubeconfig: %w", err)
|
return 0, fmt.Errorf("error loading kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
kubeClient, err = client.NewWithWatch(restCfg, client.Options{Scheme: tsapi.GlobalScheme})
|
kubeClient, err = client.NewWithWatch(restCfg, client.Options{Scheme: tsapi.GlobalScheme})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("error creating Kubernetes client: %w", err)
|
return 0, fmt.Errorf("error creating Kubernetes client: %w", err)
|
||||||
@@ -157,24 +161,28 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
clientID, clientSecret string // OAuth client for the operator to use.
|
clientID, clientSecret string // OAuth client for the operator to use.
|
||||||
caPaths []string // Extra CA cert file paths to add to images.
|
caPaths []string // Extra CA cert file paths to add to images.
|
||||||
|
|
||||||
certsDir string = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
|
certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
|
||||||
)
|
)
|
||||||
if *fDevcontrol {
|
if *fDevcontrol {
|
||||||
// Deploy pebble and get its certs.
|
// Deploy pebble and get its certs.
|
||||||
if err := applyPebbleResources(ctx, kubeClient); err != nil {
|
if err = applyPebbleResources(ctx, kubeClient); err != nil {
|
||||||
return 0, fmt.Errorf("failed to apply pebble resources: %w", err)
|
return 0, fmt.Errorf("failed to apply pebble resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pebblePod, err := waitForPodReady(ctx, logger, kubeClient, ns, client.MatchingLabels{"app": "pebble"})
|
pebblePod, err := waitForPodReady(ctx, logger, kubeClient, ns, client.MatchingLabels{"app": "pebble"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("pebble pod not ready: %w", err)
|
return 0, fmt.Errorf("pebble pod not ready: %w", err)
|
||||||
}
|
}
|
||||||
if err := forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 15000); err != nil {
|
|
||||||
|
if err = forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 15000); err != nil {
|
||||||
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
testCAs = x509.NewCertPool()
|
testCAs = x509.NewCertPool()
|
||||||
if ok := testCAs.AppendCertsFromPEM(pebbleMiniCACert); !ok {
|
if ok := testCAs.AppendCertsFromPEM(pebbleMiniCACert); !ok {
|
||||||
return 0, fmt.Errorf("failed to parse pebble minica cert")
|
return 0, fmt.Errorf("failed to parse pebble minica cert")
|
||||||
}
|
}
|
||||||
|
|
||||||
var pebbleCAChain []byte
|
var pebbleCAChain []byte
|
||||||
for _, path := range []string{"/intermediates/0", "/roots/0"} {
|
for _, path := range []string{"/intermediates/0", "/roots/0"} {
|
||||||
pem, err := pebbleGet(ctx, 15000, path)
|
pem, err := pebbleGet(ctx, 15000, path)
|
||||||
@@ -183,20 +191,25 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
}
|
}
|
||||||
pebbleCAChain = append(pebbleCAChain, pem...)
|
pebbleCAChain = append(pebbleCAChain, pem...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := testCAs.AppendCertsFromPEM(pebbleCAChain); !ok {
|
if ok := testCAs.AppendCertsFromPEM(pebbleCAChain); !ok {
|
||||||
return 0, fmt.Errorf("failed to parse pebble ca chain cert")
|
return 0, fmt.Errorf("failed to parse pebble ca chain cert")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(certsDir, 0755); err != nil {
|
|
||||||
|
if err = os.MkdirAll(certsDir, 0755); err != nil {
|
||||||
return 0, fmt.Errorf("failed to create certs dir: %w", err)
|
return 0, fmt.Errorf("failed to create certs dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pebbleCAChainPath := filepath.Join(certsDir, "pebble-ca-chain.crt")
|
pebbleCAChainPath := filepath.Join(certsDir, "pebble-ca-chain.crt")
|
||||||
if err := os.WriteFile(pebbleCAChainPath, pebbleCAChain, 0644); err != nil {
|
if err = os.WriteFile(pebbleCAChainPath, pebbleCAChain, 0644); err != nil {
|
||||||
return 0, fmt.Errorf("failed to write pebble CA chain: %w", err)
|
return 0, fmt.Errorf("failed to write pebble CA chain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pebbleMiniCACertPath := filepath.Join(certsDir, "pebble.minica.crt")
|
pebbleMiniCACertPath := filepath.Join(certsDir, "pebble.minica.crt")
|
||||||
if err := os.WriteFile(pebbleMiniCACertPath, pebbleMiniCACert, 0644); err != nil {
|
if err = os.WriteFile(pebbleMiniCACertPath, pebbleMiniCACert, 0644); err != nil {
|
||||||
return 0, fmt.Errorf("failed to write pebble minica: %w", err)
|
return 0, fmt.Errorf("failed to write pebble minica: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
caPaths = []string{pebbleCAChainPath, pebbleMiniCACertPath}
|
caPaths = []string{pebbleCAChainPath, pebbleMiniCACertPath}
|
||||||
if !*fSkipCleanup {
|
if !*fSkipCleanup {
|
||||||
defer os.RemoveAll(certsDir)
|
defer os.RemoveAll(certsDir)
|
||||||
@@ -210,13 +223,15 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
// For Pods -> devcontrol (tailscale clients joining the tailnet):
|
// For Pods -> devcontrol (tailscale clients joining the tailnet):
|
||||||
// * Create ssh-server Deployment in cluster.
|
// * Create ssh-server Deployment in cluster.
|
||||||
// * Create reverse ssh tunnel that goes from ssh-server port 31544 to localhost:31544.
|
// * Create reverse ssh tunnel that goes from ssh-server port 31544 to localhost:31544.
|
||||||
if err := forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 8055); err != nil {
|
if err = forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 8055); err != nil {
|
||||||
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
privateKey, publicKey, err := readOrGenerateSSHKey(tmp)
|
privateKey, publicKey, err := readOrGenerateSSHKey(tmp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to read or generate SSH key: %w", err)
|
return 0, fmt.Errorf("failed to read or generate SSH key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*fSkipCleanup {
|
if !*fSkipCleanup {
|
||||||
defer os.Remove(privateKeyPath)
|
defer os.Remove(privateKeyPath)
|
||||||
}
|
}
|
||||||
@@ -225,6 +240,7 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to set up cluster->devcontrol connection: %w", err)
|
return 0, fmt.Errorf("failed to set up cluster->devcontrol connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !*fSkipCleanup {
|
if !*fSkipCleanup {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := cleanupSSHResources(context.Background(), kubeClient); err != nil {
|
if err := cleanupSSHResources(context.Background(), kubeClient); err != nil {
|
||||||
@@ -245,7 +261,7 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
var apiKeyData struct {
|
var apiKeyData struct {
|
||||||
APIKey string `json:"apiKey"`
|
APIKey string `json:"apiKey"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, &apiKeyData); err != nil {
|
if err = json.Unmarshal(b, &apiKeyData); err != nil {
|
||||||
return 0, fmt.Errorf("failed to parse api-key.json: %w", err)
|
return 0, fmt.Errorf("failed to parse api-key.json: %w", err)
|
||||||
}
|
}
|
||||||
if apiKeyData.APIKey == "" {
|
if apiKeyData.APIKey == "" {
|
||||||
@@ -253,48 +269,27 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finish setting up tsClient.
|
// Finish setting up tsClient.
|
||||||
tsClient = tailscale.NewClient("-", tailscale.APIKey(apiKeyData.APIKey))
|
tsClient = &tailscale.Client{
|
||||||
tsClient.BaseURL = "http://localhost:31544"
|
APIKey: apiKeyData.APIKey,
|
||||||
|
BaseURL: must.Get(url.Parse("http://localhost:31544")),
|
||||||
|
}
|
||||||
|
|
||||||
// Set ACLs and create OAuth client.
|
// Set ACLs and create OAuth client.
|
||||||
req, _ := http.NewRequest("POST", tsClient.BuildTailnetURL("acl"), bytes.NewReader(requiredACLs))
|
if err = tsClient.PolicyFile().Set(ctx, string(requiredACLs), ""); err != nil {
|
||||||
resp, err := tsClient.Do(req)
|
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to set ACLs: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return 0, fmt.Errorf("HTTP %d setting ACLs: %s", resp.StatusCode, string(b))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("ACLs configured")
|
logger.Infof("ACLs configured")
|
||||||
|
|
||||||
reqBody, err := json.Marshal(map[string]any{
|
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
||||||
"keyType": "client",
|
Scopes: []string{"auth_keys", "devices:core", "services"},
|
||||||
"scopes": []string{"auth_keys", "devices:core", "services"},
|
Tags: []string{"tag:k8s-operator"},
|
||||||
"tags": []string{"tag:k8s-operator"},
|
Description: "k8s-operator client for e2e tests",
|
||||||
"description": "k8s-operator client for e2e tests",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to marshal OAuth client creation request: %w", err)
|
return 0, fmt.Errorf("failed to marshal OAuth client creation request: %w", err)
|
||||||
}
|
}
|
||||||
req, _ = http.NewRequest("POST", tsClient.BuildTailnetURL("keys"), bytes.NewReader(reqBody))
|
|
||||||
resp, err = tsClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to create OAuth client: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
return 0, fmt.Errorf("HTTP %d creating OAuth client: %s", resp.StatusCode, string(b))
|
|
||||||
}
|
|
||||||
var key struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&key); err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to decode OAuth client creation response: %w", err)
|
|
||||||
}
|
|
||||||
clientID = key.ID
|
clientID = key.ID
|
||||||
clientSecret = key.Key
|
clientSecret = key.Key
|
||||||
} else {
|
} else {
|
||||||
@@ -320,7 +315,9 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
}
|
}
|
||||||
// An access token will last for an hour which is plenty of time for
|
// An access token will last for an hour which is plenty of time for
|
||||||
// the tests to run. No need for token refresh logic.
|
// the tests to run. No need for token refresh logic.
|
||||||
tsClient = tailscale.NewClient("-", tailscale.APIKey(tk.AccessToken))
|
tsClient = &tailscale.Client{
|
||||||
|
APIKey: tk.AccessToken,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ossTag string
|
var ossTag string
|
||||||
@@ -447,18 +444,18 @@ func runTests(m *testing.M) (int, error) {
|
|||||||
caps.Devices.Create.Ephemeral = true
|
caps.Devices.Create.Ephemeral = true
|
||||||
caps.Devices.Create.Tags = []string{"tag:k8s"}
|
caps.Devices.Create.Tags = []string{"tag:k8s"}
|
||||||
|
|
||||||
authKey, authKeyMeta, err := tsClient.CreateKey(ctx, caps)
|
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer tsClient.DeleteKey(context.Background(), authKeyMeta.ID)
|
defer tsClient.Keys().Delete(context.Background(), authKey.ID)
|
||||||
|
|
||||||
tnClient = &tsnet.Server{
|
tnClient = &tsnet.Server{
|
||||||
ControlURL: tsClient.BaseURL,
|
ControlURL: tsClient.BaseURL.String(),
|
||||||
Hostname: "test-proxy",
|
Hostname: "test-proxy",
|
||||||
Ephemeral: true,
|
Ephemeral: true,
|
||||||
Store: &mem.Store{},
|
Store: &mem.Store{},
|
||||||
AuthKey: authKey,
|
AuthKey: authKey.Key,
|
||||||
}
|
}
|
||||||
_, err = tnClient.Up(ctx)
|
_, err = tnClient.Up(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ func (esr *egressSvcsReconciler) clusterIPSvcForEgress(crl map[string]string) *c
|
|||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Type: corev1.ServiceTypeClusterIP,
|
Type: corev1.ServiceTypeClusterIP,
|
||||||
|
IPFamilyPolicy: new(corev1.IPFamilyPolicyPreferDualStack),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func clusterIPSvc(name string, extNSvc *corev1.Service) *corev1.Service {
|
|||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
Type: corev1.ServiceTypeClusterIP,
|
Type: corev1.ServiceTypeClusterIP,
|
||||||
|
IPFamilyPolicy: new(corev1.IPFamilyPolicyPreferDualStack),
|
||||||
Ports: ports,
|
Ports: ports,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/http"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,11 +29,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
@@ -64,7 +64,7 @@ type HAIngressReconciler struct {
|
|||||||
|
|
||||||
recorder record.EventRecorder
|
recorder record.EventRecorder
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
tsClient tsClient
|
clients ClientProvider
|
||||||
tsnetServer tsnetServer
|
tsnetServer tsnetServer
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
defaultTags []string
|
defaultTags []string
|
||||||
@@ -127,7 +127,7 @@ func (r *HAIngressReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
return res, fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
|
return res, fmt.Errorf("getting ProxyGroup %q: %w", pgName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleClient, err := clientFromProxyGroup(ctx, r.Client, pg, r.tsNamespace, r.tsClient)
|
tsClient, err := r.clients.For(pg.Spec.Tailnet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
||||||
}
|
}
|
||||||
@@ -139,9 +139,9 @@ func (r *HAIngressReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
// resulted in another actor overwriting our Tailscale Service update.
|
// resulted in another actor overwriting our Tailscale Service update.
|
||||||
needsRequeue := false
|
needsRequeue := false
|
||||||
if !ing.DeletionTimestamp.IsZero() || !r.shouldExpose(ing) {
|
if !ing.DeletionTimestamp.IsZero() || !r.shouldExpose(ing) {
|
||||||
needsRequeue, err = r.maybeCleanup(ctx, hostname, ing, logger, tailscaleClient, pg)
|
needsRequeue, err = r.maybeCleanup(ctx, hostname, ing, logger, tsClient, pg)
|
||||||
} else {
|
} else {
|
||||||
needsRequeue, err = r.maybeProvision(ctx, hostname, ing, logger, tailscaleClient, pg)
|
needsRequeue, err = r.maybeProvision(ctx, hostname, ing, logger, tsClient, pg)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, err
|
return res, err
|
||||||
@@ -160,12 +160,12 @@ func (r *HAIngressReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
// If a Tailscale Service exists, but does not have an owner reference from any operator, we error
|
// If a Tailscale Service exists, but does not have an owner reference from any operator, we error
|
||||||
// out assuming that this is an owner reference created by an unknown actor.
|
// out assuming that this is an owner reference created by an unknown actor.
|
||||||
// Returns true if the operation resulted in a Tailscale Service update.
|
// Returns true if the operation resulted in a Tailscale Service update.
|
||||||
func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger, tsClient tsClient, pg *tsapi.ProxyGroup) (svcsChanged bool, err error) {
|
func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger, tsClient tsclient.Client, pg *tsapi.ProxyGroup) (svcsChanged bool, err error) {
|
||||||
// Currently (2025-05) Tailscale Services are behind an alpha feature flag that
|
// Currently (2025-05) Tailscale Services are behind an alpha feature flag that
|
||||||
// needs to be explicitly enabled for a tailnet to be able to use them.
|
// needs to be explicitly enabled for a tailnet to be able to use them.
|
||||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||||
existingTSSvc, err := tsClient.GetVIPService(ctx, serviceName)
|
existingTSSvc, err := tsClient.VIPServices().Get(ctx, serviceName.String())
|
||||||
if err != nil && !isErrorTailscaleServiceNotFound(err) {
|
if err != nil && !tailscale.IsNotFound(err) {
|
||||||
return false, fmt.Errorf("error getting Tailscale Service %q: %w", hostname, err)
|
return false, fmt.Errorf("error getting Tailscale Service %q: %w", hostname, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +341,8 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
tsSvcPorts = append(tsSvcPorts, "tcp:80")
|
tsSvcPorts = append(tsSvcPorts, "tcp:80")
|
||||||
}
|
}
|
||||||
|
|
||||||
tsSvc := &tailscale.VIPService{
|
tsSvc := tailscale.VIPService{
|
||||||
Name: serviceName,
|
Name: serviceName.String(),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Ports: tsSvcPorts,
|
Ports: tsSvcPorts,
|
||||||
Comment: managedTSServiceComment,
|
Comment: managedTSServiceComment,
|
||||||
@@ -357,9 +357,9 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
if existingTSSvc == nil ||
|
if existingTSSvc == nil ||
|
||||||
!reflect.DeepEqual(tsSvc.Tags, existingTSSvc.Tags) ||
|
!reflect.DeepEqual(tsSvc.Tags, existingTSSvc.Tags) ||
|
||||||
!reflect.DeepEqual(tsSvc.Ports, existingTSSvc.Ports) ||
|
!reflect.DeepEqual(tsSvc.Ports, existingTSSvc.Ports) ||
|
||||||
!ownersAreSetAndEqual(tsSvc, existingTSSvc) {
|
!ownersAreSetAndEqual(tsSvc, *existingTSSvc) {
|
||||||
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
||||||
if err := tsClient.CreateOrUpdateVIPService(ctx, tsSvc); err != nil {
|
if err := tsClient.VIPServices().CreateOrUpdate(ctx, tsSvc); err != nil {
|
||||||
return false, fmt.Errorf("error creating Tailscale Service: %w", err)
|
return false, fmt.Errorf("error creating Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +375,7 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Update Ingress status if ProxyGroup Pods are ready.
|
// 6. Update Ingress status if ProxyGroup Pods are ready.
|
||||||
count, err := numberPodsAdvertising(ctx, r.Client, r.tsNamespace, pg.Name, serviceName)
|
count, err := numberPodsAdvertising(ctx, r.Client, r.tsNamespace, pg.Name, serviceName.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to check if any Pods are configured: %w", err)
|
return false, fmt.Errorf("failed to check if any Pods are configured: %w", err)
|
||||||
}
|
}
|
||||||
@@ -440,7 +440,7 @@ func (r *HAIngressReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
// operator instances, else the owner reference is cleaned up. Returns true if
|
// operator instances, else the owner reference is cleaned up. Returns true if
|
||||||
// the operation resulted in an existing Tailscale Service updates (owner
|
// the operation resulted in an existing Tailscale Service updates (owner
|
||||||
// reference removal).
|
// reference removal).
|
||||||
func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger *zap.SugaredLogger, tsClient tsClient, pg *tsapi.ProxyGroup) (svcsChanged bool, err error) {
|
func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger *zap.SugaredLogger, tsClient tsclient.Client, pg *tsapi.ProxyGroup) (svcsChanged bool, err error) {
|
||||||
// Get serve config for the ProxyGroup
|
// Get serve config for the ProxyGroup
|
||||||
cm, cfg, err := r.proxyGroupServeConfig(ctx, pg.Name)
|
cm, cfg, err := r.proxyGroupServeConfig(ctx, pg.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -470,11 +470,11 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
|
|||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
logger.Infof("Tailscale Service %q is not owned by any Ingress, cleaning up", tsSvcName)
|
logger.Infof("Tailscale Service %q is not owned by any Ingress, cleaning up", tsSvcName)
|
||||||
tsService, err := tsClient.GetVIPService(ctx, tsSvcName)
|
tsService, err := tsClient.VIPServices().Get(ctx, tsSvcName.String())
|
||||||
if isErrorTailscaleServiceNotFound(err) {
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
case err != nil:
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("getting Tailscale Service %q: %w", tsSvcName, err)
|
return false, fmt.Errorf("getting Tailscale Service %q: %w", tsSvcName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,17 +519,19 @@ func (r *HAIngressReconciler) maybeCleanupProxyGroup(ctx context.Context, logger
|
|||||||
// Ingress is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
|
// Ingress is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
|
||||||
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
|
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
|
||||||
// corresponding to this Ingress.
|
// corresponding to this Ingress.
|
||||||
func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger, tsClient tsClient, pg *tsapi.ProxyGroup) (svcChanged bool, err error) {
|
func (r *HAIngressReconciler) maybeCleanup(ctx context.Context, hostname string, ing *networkingv1.Ingress, logger *zap.SugaredLogger, tsClient tsclient.Client, pg *tsapi.ProxyGroup) (svcChanged bool, err error) {
|
||||||
logger.Debugf("Ensuring any resources for Ingress are cleaned up")
|
logger.Debugf("Ensuring any resources for Ingress are cleaned up")
|
||||||
ix := slices.Index(ing.Finalizers, FinalizerNamePG)
|
ix := slices.Index(ing.Finalizers, FinalizerNamePG)
|
||||||
if ix < 0 {
|
if ix < 0 {
|
||||||
logger.Debugf("no finalizer, nothing to do")
|
logger.Debugf("no finalizer, nothing to do")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("Ensuring that Tailscale Service %q configuration is cleaned up", hostname)
|
logger.Infof("Ensuring that Tailscale Service %q configuration is cleaned up", hostname)
|
||||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||||
svc, err := tsClient.GetVIPService(ctx, serviceName)
|
|
||||||
if err != nil && !isErrorTailscaleServiceNotFound(err) {
|
svc, err := tsClient.VIPServices().Get(ctx, serviceName.String())
|
||||||
|
if err != nil && !tailscale.IsNotFound(err) {
|
||||||
return false, fmt.Errorf("error getting Tailscale Service: %w", err)
|
return false, fmt.Errorf("error getting Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,10 +700,7 @@ func (r *HAIngressReconciler) validateIngress(ctx context.Context, ing *networki
|
|||||||
// If a Tailscale Service is found, but contains other owner references, only removes this operator's owner reference.
|
// If a Tailscale Service is found, but contains other owner references, only removes this operator's owner reference.
|
||||||
// If a Tailscale Service by the given name is not found or does not contain this operator's owner reference, do nothing.
|
// If a Tailscale Service by the given name is not found or does not contain this operator's owner reference, do nothing.
|
||||||
// It returns true if an existing Tailscale Service was updated to remove owner reference, as well as any error that occurred.
|
// It returns true if an existing Tailscale Service was updated to remove owner reference, as well as any error that occurred.
|
||||||
func (r *HAIngressReconciler) cleanupTailscaleService(ctx context.Context, svc *tailscale.VIPService, logger *zap.SugaredLogger, tsClient tsClient) (updated bool, _ error) {
|
func (r *HAIngressReconciler) cleanupTailscaleService(ctx context.Context, svc *tailscale.VIPService, logger *zap.SugaredLogger, tsClient tsclient.Client) (updated bool, _ error) {
|
||||||
if svc == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
o, err := parseOwnerAnnotation(svc)
|
o, err := parseOwnerAnnotation(svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error parsing Tailscale Service's owner annotation")
|
return false, fmt.Errorf("error parsing Tailscale Service's owner annotation")
|
||||||
@@ -721,7 +720,7 @@ func (r *HAIngressReconciler) cleanupTailscaleService(ctx context.Context, svc *
|
|||||||
}
|
}
|
||||||
if len(o.OwnerRefs) == 1 {
|
if len(o.OwnerRefs) == 1 {
|
||||||
logger.Infof("Deleting Tailscale Service %q", svc.Name)
|
logger.Infof("Deleting Tailscale Service %q", svc.Name)
|
||||||
if err = tsClient.DeleteVIPService(ctx, svc.Name); err != nil && !isErrorTailscaleServiceNotFound(err) {
|
if err = tsClient.VIPServices().Delete(ctx, svc.Name); err != nil && !tailscale.IsNotFound(err) {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +734,7 @@ func (r *HAIngressReconciler) cleanupTailscaleService(ctx context.Context, svc *
|
|||||||
return false, fmt.Errorf("error marshalling updated Tailscale Service owner reference: %w", err)
|
return false, fmt.Errorf("error marshalling updated Tailscale Service owner reference: %w", err)
|
||||||
}
|
}
|
||||||
svc.Annotations[ownerAnnotation] = string(json)
|
svc.Annotations[ownerAnnotation] = string(json)
|
||||||
return true, tsClient.CreateOrUpdateVIPService(ctx, svc)
|
return true, tsClient.VIPServices().CreateOrUpdate(ctx, *svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isHTTPEndpointEnabled returns true if the Ingress has been configured to expose an HTTP endpoint to tailnet.
|
// isHTTPEndpointEnabled returns true if the Ingress has been configured to expose an HTTP endpoint to tailnet.
|
||||||
@@ -819,7 +818,7 @@ func (r *HAIngressReconciler) maybeUpdateAdvertiseServicesConfig(ctx context.Con
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func numberPodsAdvertising(ctx context.Context, cl client.Client, tsNamespace, pgName string, serviceName tailcfg.ServiceName) (int, error) {
|
func numberPodsAdvertising(ctx context.Context, cl client.Client, tsNamespace, pgName string, serviceName string) (int, error) {
|
||||||
// Get all state Secrets for this ProxyGroup.
|
// Get all state Secrets for this ProxyGroup.
|
||||||
secrets := &corev1.SecretList{}
|
secrets := &corev1.SecretList{}
|
||||||
if err := cl.List(ctx, secrets, client.InNamespace(tsNamespace), client.MatchingLabels(pgSecretLabels(pgName, kubetypes.LabelSecretTypeState))); err != nil {
|
if err := cl.List(ctx, secrets, client.InNamespace(tsNamespace), client.MatchingLabels(pgSecretLabels(pgName, kubetypes.LabelSecretTypeState))); err != nil {
|
||||||
@@ -835,7 +834,7 @@ func numberPodsAdvertising(ctx context.Context, cl client.Client, tsNamespace, p
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(prefs.AdvertiseServices, serviceName.String()) {
|
if slices.Contains(prefs.AdvertiseServices, serviceName) {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -912,6 +911,10 @@ func ownerAnnotations(operatorID string, svc *tailscale.VIPService) (map[string]
|
|||||||
|
|
||||||
// parseOwnerAnnotation returns nil if no valid owner found.
|
// parseOwnerAnnotation returns nil if no valid owner found.
|
||||||
func parseOwnerAnnotation(tsSvc *tailscale.VIPService) (*ownerAnnotationValue, error) {
|
func parseOwnerAnnotation(tsSvc *tailscale.VIPService) (*ownerAnnotationValue, error) {
|
||||||
|
if tsSvc == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
if tsSvc.Annotations == nil || tsSvc.Annotations[ownerAnnotation] == "" {
|
if tsSvc.Annotations == nil || tsSvc.Annotations[ownerAnnotation] == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -922,9 +925,8 @@ func parseOwnerAnnotation(tsSvc *tailscale.VIPService) (*ownerAnnotationValue, e
|
|||||||
return o, nil
|
return o, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ownersAreSetAndEqual(a, b *tailscale.VIPService) bool {
|
func ownersAreSetAndEqual(a, b tailscale.VIPService) bool {
|
||||||
return a != nil && b != nil &&
|
return a.Annotations != nil && b.Annotations != nil &&
|
||||||
a.Annotations != nil && b.Annotations != nil &&
|
|
||||||
a.Annotations[ownerAnnotation] != "" &&
|
a.Annotations[ownerAnnotation] != "" &&
|
||||||
b.Annotations[ownerAnnotation] != "" &&
|
b.Annotations[ownerAnnotation] != "" &&
|
||||||
strings.EqualFold(a.Annotations[ownerAnnotation], b.Annotations[ownerAnnotation])
|
strings.EqualFold(a.Annotations[ownerAnnotation], b.Annotations[ownerAnnotation])
|
||||||
@@ -1107,11 +1109,6 @@ func hasCerts(ctx context.Context, cl client.Client, ns string, svc tailcfg.Serv
|
|||||||
return len(cert) > 0 && len(key) > 0, nil
|
return len(cert) > 0 && len(key) > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isErrorTailscaleServiceNotFound(err error) bool {
|
|
||||||
errResp, ok := errors.AsType[tailscale.ErrResponse](err)
|
|
||||||
return ok && errResp.Status == http.StatusNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func tagViolations(obj client.Object) []string {
|
func tagViolations(obj client.Object) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
if obj == nil {
|
if obj == nil {
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
@@ -88,7 +89,7 @@ func TestIngressPGReconciler(t *testing.T) {
|
|||||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||||
|
|
||||||
// Verify Tailscale Service uses custom tags
|
// Verify Tailscale Service uses custom tags
|
||||||
tsSvc, err := ft.GetVIPService(t.Context(), "svc:my-svc")
|
tsSvc, err := ft.VIPServices().Get(t.Context(), "svc:my-svc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service: %v", err)
|
t.Fatalf("getting Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
@@ -259,7 +260,7 @@ func TestIngressPGReconciler(t *testing.T) {
|
|||||||
expectReconciled(t, ingPGR, ing3.Namespace, ing3.Name)
|
expectReconciled(t, ingPGR, ing3.Namespace, ing3.Name)
|
||||||
|
|
||||||
// Delete the service from "control"
|
// Delete the service from "control"
|
||||||
ft.vipServices = make(map[tailcfg.ServiceName]*tailscale.VIPService)
|
ft.vipServices = make(map[string]tailscale.VIPService)
|
||||||
|
|
||||||
// Delete the ingress and confirm we don't get stuck due to the VIP service not existing.
|
// Delete the ingress and confirm we don't get stuck due to the VIP service not existing.
|
||||||
if err = fc.Delete(t.Context(), ing3); err != nil {
|
if err = fc.Delete(t.Context(), ing3); err != nil {
|
||||||
@@ -319,11 +320,11 @@ func TestIngressPGReconciler_UpdateIngressHostname(t *testing.T) {
|
|||||||
verifyTailscaleService(t, ft, "svc:updated-svc", []string{"tcp:443"})
|
verifyTailscaleService(t, ft, "svc:updated-svc", []string{"tcp:443"})
|
||||||
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:updated-svc"})
|
verifyTailscaledConfig(t, fc, "test-pg", []string{"svc:updated-svc"})
|
||||||
|
|
||||||
_, err := ft.GetVIPService(context.Background(), "svc:my-svc")
|
_, err := ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("svc:my-svc not cleaned up")
|
t.Fatalf("svc:my-svc not cleaned up")
|
||||||
}
|
}
|
||||||
if !isErrorTailscaleServiceNotFound(err) {
|
if !tailscale.IsNotFound(err) {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,20 +878,18 @@ func TestIngressPGReconciler_MultiCluster(t *testing.T) {
|
|||||||
mustCreate(t, fc, ing)
|
mustCreate(t, fc, ing)
|
||||||
|
|
||||||
// Simulate existing Tailscale Service from another cluster
|
// Simulate existing Tailscale Service from another cluster
|
||||||
existingVIPSvc := &tailscale.VIPService{
|
existingVIPSvc := tailscale.VIPService{
|
||||||
Name: "svc:my-svc",
|
Name: "svc:my-svc",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
ownerAnnotation: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
|
ownerAnnotation: `{"ownerrefs":[{"operatorID":"operator-2"}]}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ft.vipServices = map[tailcfg.ServiceName]*tailscale.VIPService{
|
ft.VIPServices().CreateOrUpdate(t.Context(), existingVIPSvc)
|
||||||
"svc:my-svc": existingVIPSvc,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reconciliation adds our operator reference
|
// Verify reconciliation adds our operator reference
|
||||||
expectReconciled(t, ingPGR, "default", "test-ingress")
|
expectReconciled(t, ingPGR, "default", "test-ingress")
|
||||||
|
|
||||||
tsSvc, err := ft.GetVIPService(context.Background(), "svc:my-svc")
|
tsSvc, err := ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service: %v", err)
|
t.Fatalf("getting Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
@@ -917,7 +916,7 @@ func TestIngressPGReconciler_MultiCluster(t *testing.T) {
|
|||||||
}
|
}
|
||||||
expectRequeue(t, ingPGR, "default", "test-ingress")
|
expectRequeue(t, ingPGR, "default", "test-ingress")
|
||||||
|
|
||||||
tsSvc, err = ft.GetVIPService(context.Background(), "svc:my-svc")
|
tsSvc, err = ft.VIPServices().Get(context.Background(), "svc:my-svc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service after deletion: %v", err)
|
t.Fatalf("getting Tailscale Service after deletion: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1024,7 +1023,7 @@ func populateTLSSecret(t *testing.T, c client.Client, pgName, domain string) {
|
|||||||
|
|
||||||
func verifyTailscaleService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
|
func verifyTailscaleService(t *testing.T, ft *fakeTSClient, serviceName string, wantPorts []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tsSvc, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(serviceName))
|
tsSvc, err := ft.VIPServices().Get(context.Background(), serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service %q: %v", serviceName, err)
|
t.Fatalf("getting Tailscale Service %q: %v", serviceName, err)
|
||||||
}
|
}
|
||||||
@@ -1203,7 +1202,9 @@ func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeT
|
|||||||
|
|
||||||
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
||||||
|
|
||||||
ft := &fakeTSClient{}
|
ft := &fakeTSClient{
|
||||||
|
vipServices: make(map[string]tailscale.VIPService),
|
||||||
|
}
|
||||||
zl, err := zap.NewDevelopment()
|
zl, err := zap.NewDevelopment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -1211,7 +1212,7 @@ func setupIngressTest(t *testing.T) (*HAIngressReconciler, client.Client, *fakeT
|
|||||||
|
|
||||||
ingPGR := &HAIngressReconciler{
|
ingPGR := &HAIngressReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
tsNamespace: "operator-ns",
|
tsNamespace: "operator-ns",
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
@@ -30,7 +33,9 @@ import (
|
|||||||
|
|
||||||
func TestTailscaleIngress(t *testing.T) {
|
func TestTailscaleIngress(t *testing.T) {
|
||||||
fc := fake.NewFakeClient(ingressClass())
|
fc := fake.NewFakeClient(ingressClass())
|
||||||
ft := &fakeTSClient{}
|
ft := &fakeTSClient{
|
||||||
|
vipServices: make(map[string]tailscale.VIPService),
|
||||||
|
}
|
||||||
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
fakeTsnetServer := &fakeTSNetServer{certDomains: []string{"foo.com"}}
|
||||||
zl, err := zap.NewDevelopment()
|
zl, err := zap.NewDevelopment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,7 +46,7 @@ func TestTailscaleIngress(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -130,7 +135,7 @@ func TestTailscaleIngressHostname(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -269,7 +274,7 @@ func TestTailscaleIngressWithProxyClass(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -378,7 +383,7 @@ func TestTailscaleIngressWithServiceMonitor(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -530,7 +535,7 @@ func TestIngressProxyClassAnnotation(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
||||||
defaultTags: []string{"tag:test"},
|
defaultTags: []string{"tag:test"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -601,7 +606,7 @@ func TestIngressLetsEncryptStaging(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
tsnetServer: &fakeTSNetServer{certDomains: []string{"test-host"}},
|
||||||
defaultTags: []string{"tag:test"},
|
defaultTags: []string{"tag:test"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -710,7 +715,7 @@ func TestEmptyPath(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
@@ -853,7 +858,7 @@ func TestTailscaleIngressWithHTTPRedirect(t *testing.T) {
|
|||||||
ingressClassName: "tailscale",
|
ingressClassName: "tailscale",
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
tsnetServer: fakeTsnetServer,
|
tsnetServer: fakeTsnetServer,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
|
"sigs.k8s.io/controller-runtime/pkg/manager/signals"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/client/tailscale"
|
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/hostinfo"
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
@@ -57,6 +57,7 @@ import (
|
|||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/k8s-operator/reconciler/proxygrouppolicy"
|
"tailscale.com/k8s-operator/reconciler/proxygrouppolicy"
|
||||||
"tailscale.com/k8s-operator/reconciler/tailnet"
|
"tailscale.com/k8s-operator/reconciler/tailnet"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
@@ -84,10 +85,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Required to use our client API. We're fine with the instability since the
|
|
||||||
// client lives in the same repo as this code.
|
|
||||||
tailscale.I_Acknowledge_This_API_Is_Unstable = true
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
|
tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "")
|
||||||
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
|
tslogging = defaultEnv("OPERATOR_LOGGING", "info")
|
||||||
@@ -155,7 +152,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
rOpts := reconcilerOpts{
|
runReconcilers(reconcilerOpts{
|
||||||
log: zlog,
|
log: zlog,
|
||||||
tsServer: s,
|
tsServer: s,
|
||||||
tsClient: tsc,
|
tsClient: tsc,
|
||||||
@@ -170,15 +167,14 @@ func main() {
|
|||||||
defaultProxyClass: defaultProxyClass,
|
defaultProxyClass: defaultProxyClass,
|
||||||
loginServer: loginServer,
|
loginServer: loginServer,
|
||||||
ingressClassName: ingressClassName,
|
ingressClassName: ingressClassName,
|
||||||
}
|
})
|
||||||
runReconcilers(rOpts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. If CLIENT_ID
|
// initTSNet initializes the tsnet.Server and logs in to Tailscale. If CLIENT_ID
|
||||||
// is set, it authenticates to the Tailscale API using the federated OIDC workload
|
// is set, it authenticates to the Tailscale API using the federated OIDC workload
|
||||||
// identity flow. Otherwise, it uses the CLIENT_ID_FILE and CLIENT_SECRET_FILE
|
// identity flow. Otherwise, it uses the CLIENT_ID_FILE and CLIENT_SECRET_FILE
|
||||||
// environment variables to authenticate with static credentials.
|
// environment variables to authenticate with static credentials.
|
||||||
func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsClient) {
|
func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, *tailscale.Client) {
|
||||||
var (
|
var (
|
||||||
clientID = defaultEnv("CLIENT_ID", "") // Used for workload identity federation.
|
clientID = defaultEnv("CLIENT_ID", "") // Used for workload identity federation.
|
||||||
clientIDPath = defaultEnv("CLIENT_ID_FILE", "") // Used for static client credentials.
|
clientIDPath = defaultEnv("CLIENT_ID_FILE", "") // Used for static client credentials.
|
||||||
@@ -187,19 +183,23 @@ func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsCl
|
|||||||
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
kubeSecret = defaultEnv("OPERATOR_SECRET", "")
|
||||||
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator")
|
||||||
)
|
)
|
||||||
|
|
||||||
startlog := zlog.Named("startup")
|
startlog := zlog.Named("startup")
|
||||||
if clientID == "" && (clientIDPath == "" || clientSecretPath == "") {
|
if clientID == "" && (clientIDPath == "" || clientSecretPath == "") {
|
||||||
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set") // TODO(tomhjp): error message can mention WIF once it's publicly available.
|
startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set") // TODO(tomhjp): error message can mention WIF once it's publicly available.
|
||||||
}
|
}
|
||||||
|
|
||||||
tsc, err := newTSClient(zlog.Named("ts-api-client"), clientID, clientIDPath, clientSecretPath, loginServer)
|
tsc, err := newTSClient(zlog.Named("ts-api-client"), clientID, clientIDPath, clientSecretPath, loginServer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startlog.Fatalf("error creating Tailscale client: %v", err)
|
startlog.Fatalf("error creating Tailscale client: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &tsnet.Server{
|
s := &tsnet.Server{
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
Logf: zlog.Named("tailscaled").Debugf,
|
Logf: zlog.Named("tailscaled").Debugf,
|
||||||
ControlURL: loginServer,
|
ControlURL: loginServer,
|
||||||
}
|
}
|
||||||
|
|
||||||
if p := os.Getenv("TS_PORT"); p != "" {
|
if p := os.Getenv("TS_PORT"); p != "" {
|
||||||
port, err := strconv.ParseUint(p, 10, 16)
|
port, err := strconv.ParseUint(p, 10, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,6 +207,7 @@ func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsCl
|
|||||||
}
|
}
|
||||||
s.Port = uint16(port)
|
s.Port = uint16(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if kubeSecret != "" {
|
if kubeSecret != "" {
|
||||||
st, err := kubestore.New(logger.Discard, kubeSecret)
|
st, err := kubestore.New(logger.Discard, kubeSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,6 +215,7 @@ func initTSNet(zlog *zap.SugaredLogger, loginServer string) (*tsnet.Server, tsCl
|
|||||||
}
|
}
|
||||||
s.Store = st
|
s.Store = st
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Start(); err != nil {
|
if err := s.Start(); err != nil {
|
||||||
startlog.Fatalf("starting tailscale server: %v", err)
|
startlog.Fatalf("starting tailscale server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -239,27 +241,29 @@ waitOnline:
|
|||||||
if loginDone {
|
if loginDone {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
caps := tailscale.KeyCapabilities{
|
|
||||||
Devices: tailscale.KeyDeviceCapabilities{
|
var caps tailscale.KeyCapabilities
|
||||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
caps.Devices.Create.Reusable = false
|
||||||
Reusable: false,
|
caps.Devices.Create.Preauthorized = true
|
||||||
Preauthorized: true,
|
caps.Devices.Create.Tags = strings.Split(operatorTags, ",")
|
||||||
Tags: strings.Split(operatorTags, ","),
|
|
||||||
},
|
authKey, err := tsc.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||||
},
|
|
||||||
}
|
|
||||||
authkey, _, err := tsc.CreateKey(ctx, caps)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startlog.Fatalf("creating operator authkey: %v", err)
|
startlog.Fatalf("creating operator authkey: %v", err)
|
||||||
}
|
}
|
||||||
if err := lc.Start(ctx, ipn.Options{
|
|
||||||
AuthKey: authkey,
|
opts := ipn.Options{
|
||||||
}); err != nil {
|
AuthKey: authKey.Key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = lc.Start(ctx, opts); err != nil {
|
||||||
startlog.Fatalf("starting tailscale: %v", err)
|
startlog.Fatalf("starting tailscale: %v", err)
|
||||||
}
|
}
|
||||||
if err := lc.StartLoginInteractive(ctx); err != nil {
|
|
||||||
|
if err = lc.StartLoginInteractive(ctx); err != nil {
|
||||||
startlog.Fatalf("starting login: %v", err)
|
startlog.Fatalf("starting login: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
startlog.Debugf("requested login by authkey")
|
startlog.Debugf("requested login by authkey")
|
||||||
loginDone = true
|
loginDone = true
|
||||||
case "NeedsMachineAuth":
|
case "NeedsMachineAuth":
|
||||||
@@ -286,6 +290,12 @@ func serviceManagedResourceFilterPredicate() predicate.Predicate {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
ClientProvider interface {
|
||||||
|
For(tailnet string) (tsclient.Client, error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// runReconcilers starts the controller-runtime manager and registers the
|
// runReconcilers starts the controller-runtime manager and registers the
|
||||||
// ServiceReconciler. It blocks forever.
|
// ServiceReconciler. It blocks forever.
|
||||||
func runReconcilers(opts reconcilerOpts) {
|
func runReconcilers(opts reconcilerOpts) {
|
||||||
@@ -334,11 +344,14 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
startlog.Fatalf("could not create manager: %v", err)
|
startlog.Fatalf("could not create manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clients := tsclient.NewProvider(tsclient.Wrap(opts.tsClient))
|
||||||
|
|
||||||
tailnetOptions := tailnet.ReconcilerOptions{
|
tailnetOptions := tailnet.ReconcilerOptions{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
TailscaleNamespace: opts.tailscaleNamespace,
|
TailscaleNamespace: opts.tailscaleNamespace,
|
||||||
Clock: tstime.DefaultClock{},
|
Clock: tstime.DefaultClock{},
|
||||||
Logger: opts.log,
|
Logger: opts.log,
|
||||||
|
Registry: clients,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = tailnet.NewReconciler(tailnetOptions).Register(mgr); err != nil {
|
if err = tailnet.NewReconciler(tailnetOptions).Register(mgr); err != nil {
|
||||||
@@ -368,7 +381,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
ssr := &tailscaleSTSReconciler{
|
ssr := &tailscaleSTSReconciler{
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
tsnetServer: opts.tsServer,
|
tsnetServer: opts.tsServer,
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||||
operatorNamespace: opts.tailscaleNamespace,
|
operatorNamespace: opts.tailscaleNamespace,
|
||||||
proxyImage: opts.proxyImage,
|
proxyImage: opts.proxyImage,
|
||||||
@@ -460,7 +473,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
|
Watches(&tsapi.ProxyGroup{}, ingressProxyGroupFilter).
|
||||||
Complete(&HAIngressReconciler{
|
Complete(&HAIngressReconciler{
|
||||||
recorder: eventRecorder,
|
recorder: eventRecorder,
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
tsnetServer: opts.tsServer,
|
tsnetServer: opts.tsServer,
|
||||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
@@ -486,7 +499,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
Watches(&discoveryv1.EndpointSlice{}, ingressSvcFromEpsFilter).
|
Watches(&discoveryv1.EndpointSlice{}, ingressSvcFromEpsFilter).
|
||||||
Complete(&HAServiceReconciler{
|
Complete(&HAServiceReconciler{
|
||||||
recorder: eventRecorder,
|
recorder: eventRecorder,
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
logger: opts.log.Named("service-pg-reconciler"),
|
logger: opts.log.Named("service-pg-reconciler"),
|
||||||
@@ -684,8 +697,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
log: opts.log.Named("recorder-reconciler"),
|
log: opts.log.Named("recorder-reconciler"),
|
||||||
clock: tstime.DefaultClock{},
|
clock: tstime.DefaultClock{},
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
loginServer: opts.loginServer,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
startlog.Fatalf("could not create Recorder reconciler: %v", err)
|
||||||
@@ -706,7 +718,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
recorder: eventRecorder,
|
recorder: eventRecorder,
|
||||||
logger: opts.log.Named("kube-apiserver-ts-service-reconciler"),
|
logger: opts.log.Named("kube-apiserver-ts-service-reconciler"),
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
tsNamespace: opts.tailscaleNamespace,
|
tsNamespace: opts.tailscaleNamespace,
|
||||||
defaultTags: strings.Split(opts.proxyTags, ","),
|
defaultTags: strings.Split(opts.proxyTags, ","),
|
||||||
operatorID: id,
|
operatorID: id,
|
||||||
@@ -738,7 +750,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
Client: mgr.GetClient(),
|
Client: mgr.GetClient(),
|
||||||
log: opts.log.Named("proxygroup-reconciler"),
|
log: opts.log.Named("proxygroup-reconciler"),
|
||||||
clock: tstime.DefaultClock{},
|
clock: tstime.DefaultClock{},
|
||||||
tsClient: opts.tsClient,
|
clients: clients,
|
||||||
|
|
||||||
tsNamespace: opts.tailscaleNamespace,
|
tsNamespace: opts.tailscaleNamespace,
|
||||||
tsProxyImage: opts.proxyImage,
|
tsProxyImage: opts.proxyImage,
|
||||||
@@ -763,7 +775,7 @@ func runReconcilers(opts reconcilerOpts) {
|
|||||||
type reconcilerOpts struct {
|
type reconcilerOpts struct {
|
||||||
log *zap.SugaredLogger
|
log *zap.SugaredLogger
|
||||||
tsServer *tsnet.Server
|
tsServer *tsnet.Server
|
||||||
tsClient tsClient
|
tsClient *tailscale.Client
|
||||||
tailscaleNamespace string // namespace in which operator resources will be deployed
|
tailscaleNamespace string // namespace in which operator resources will be deployed
|
||||||
restConfig *rest.Config // config for connecting to the kube API server
|
restConfig *rest.Config // config for connecting to the kube API server
|
||||||
proxyImage string // <proxy-image-repo>:<proxy-image-tag>
|
proxyImage string // <proxy-image-repo>:<proxy-image-tag>
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
"tailscale.com/k8s-operator/apis/v1alpha1"
|
"tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/net/dns/resolvconffile"
|
"tailscale.com/net/dns/resolvconffile"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
@@ -43,7 +45,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -62,7 +64,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
AnnotationTailnetTargetFQDN: "invalid.example.com",
|
||||||
},
|
},
|
||||||
@@ -203,7 +205,7 @@ func TestLoadBalancerClass(t *testing.T) {
|
|||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -223,7 +225,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -241,7 +243,7 @@ func TestTailnetTargetFQDNAnnotation(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
|
AnnotationTailnetTargetFQDN: tailnetTargetFQDN,
|
||||||
},
|
},
|
||||||
@@ -333,7 +335,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -351,7 +353,7 @@ func TestTailnetTargetIPAnnotation(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||||
},
|
},
|
||||||
@@ -442,7 +444,7 @@ func TestTailnetTargetIPAnnotation_IPCouldNotBeParsed(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -457,7 +459,7 @@ func TestTailnetTargetIPAnnotation_IPCouldNotBeParsed(t *testing.T) {
|
|||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
|
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||||
},
|
},
|
||||||
@@ -510,7 +512,7 @@ func TestTailnetTargetIPAnnotation_InvalidIP(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -525,7 +527,7 @@ func TestTailnetTargetIPAnnotation_InvalidIP(t *testing.T) {
|
|||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
|
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationTailnetTargetIP: tailnetTargetIP,
|
AnnotationTailnetTargetIP: tailnetTargetIP,
|
||||||
},
|
},
|
||||||
@@ -578,7 +580,7 @@ func TestAnnotations(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -596,7 +598,7 @@ func TestAnnotations(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
},
|
},
|
||||||
@@ -663,7 +665,7 @@ func TestAnnotations(t *testing.T) {
|
|||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -682,7 +684,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -700,7 +702,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
},
|
},
|
||||||
@@ -779,7 +781,7 @@ func TestAnnotationIntoLB(t *testing.T) {
|
|||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Finalizers: []string{"tailscale.com/finalizer"},
|
Finalizers: []string{"tailscale.com/finalizer"},
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -812,7 +814,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -830,7 +832,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -925,7 +927,7 @@ func TestLBIntoAnnotation(t *testing.T) {
|
|||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
},
|
},
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -947,7 +949,7 @@ func TestCustomHostname(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -965,7 +967,7 @@ func TestCustomHostname(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
"tailscale.com/hostname": "reindeer-flotilla",
|
"tailscale.com/hostname": "reindeer-flotilla",
|
||||||
@@ -1034,7 +1036,7 @@ func TestCustomHostname(t *testing.T) {
|
|||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/hostname": "reindeer-flotilla",
|
"tailscale.com/hostname": "reindeer-flotilla",
|
||||||
},
|
},
|
||||||
@@ -1056,7 +1058,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1075,7 +1077,7 @@ func TestCustomPriorityClassName(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/expose": "true",
|
"tailscale.com/expose": "true",
|
||||||
"tailscale.com/hostname": "tailscale-critical",
|
"tailscale.com/hostname": "tailscale-critical",
|
||||||
@@ -1212,7 +1214,7 @@ func TestServiceProxyClassAnnotation(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1308,7 +1310,7 @@ func TestProxyClassForService(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1326,7 +1328,7 @@ func TestProxyClassForService(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -1397,7 +1399,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1416,7 +1418,7 @@ func TestDefaultLoadBalancer(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -1451,7 +1453,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1471,7 +1473,7 @@ func TestProxyFirewallMode(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -1545,7 +1547,7 @@ func Test_HeadlessService(t *testing.T) {
|
|||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
|
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationExpose: "true",
|
AnnotationExpose: "true",
|
||||||
},
|
},
|
||||||
@@ -1829,7 +1831,7 @@ func Test_authKeyRemoval(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1842,7 +1844,7 @@ func Test_authKeyRemoval(t *testing.T) {
|
|||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
ClusterIP: "10.20.30.40",
|
ClusterIP: "10.20.30.40",
|
||||||
@@ -1894,7 +1896,7 @@ func Test_externalNameService(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -1912,7 +1914,7 @@ func Test_externalNameService(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
AnnotationExpose: "true",
|
AnnotationExpose: "true",
|
||||||
},
|
},
|
||||||
@@ -1988,7 +1990,7 @@ func Test_metricsResourceCreation(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
},
|
},
|
||||||
logger: zl.Sugar(),
|
logger: zl.Sugar(),
|
||||||
@@ -2059,7 +2061,7 @@ func TestIgnorePGService(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
@@ -2077,7 +2079,7 @@ func TestIgnorePGService(t *testing.T) {
|
|||||||
// The apiserver is supposed to set the UID, but the fake client
|
// The apiserver is supposed to set the UID, but the fake client
|
||||||
// doesn't. So, set it explicitly because other code later depends
|
// doesn't. So, set it explicitly because other code later depends
|
||||||
// on it being set.
|
// on it being set.
|
||||||
UID: types.UID("1234-UID"),
|
UID: "1234-UID",
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"tailscale.com/proxygroup": "test-pg",
|
"tailscale.com/proxygroup": "test-pg",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -33,11 +32,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/egressservices"
|
"tailscale.com/kube/egressservices"
|
||||||
"tailscale.com/kube/k8s-proxy/conf"
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
@@ -85,7 +85,7 @@ type ProxyGroupReconciler struct {
|
|||||||
log *zap.SugaredLogger
|
log *zap.SugaredLogger
|
||||||
recorder record.EventRecorder
|
recorder record.EventRecorder
|
||||||
clock tstime.Clock
|
clock tstime.Clock
|
||||||
tsClient tsClient
|
clients ClientProvider
|
||||||
|
|
||||||
// User-specified defaults from the helm installation.
|
// User-specified defaults from the helm installation.
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
@@ -122,7 +122,7 @@ func (r *ProxyGroupReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
|||||||
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyGroup: %w", err)
|
return reconcile.Result{}, fmt.Errorf("failed to get tailscale.com ProxyGroup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleClient, loginUrl, err := r.getClientAndLoginURL(ctx, pg.Spec.Tailnet)
|
tsClient, err := r.clients.For(pg.Spec.Tailnet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
oldPGStatus := pg.Status.DeepCopy()
|
oldPGStatus := pg.Status.DeepCopy()
|
||||||
nrr := ¬ReadyReason{
|
nrr := ¬ReadyReason{
|
||||||
@@ -141,7 +141,7 @@ func (r *ProxyGroupReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
|||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if done, err := r.maybeCleanup(ctx, tailscaleClient, pg); err != nil {
|
if done, err := r.maybeCleanup(ctx, tsClient, pg); err != nil {
|
||||||
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||||
logger.Infof("optimistic lock error, retrying: %s", err)
|
logger.Infof("optimistic lock error, retrying: %s", err)
|
||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
@@ -160,7 +160,7 @@ func (r *ProxyGroupReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
oldPGStatus := pg.Status.DeepCopy()
|
oldPGStatus := pg.Status.DeepCopy()
|
||||||
staticEndpoints, nrr, err := r.reconcilePG(ctx, tailscaleClient, loginUrl, pg, logger)
|
staticEndpoints, nrr, err := r.reconcilePG(ctx, tsClient, pg, logger)
|
||||||
return reconcile.Result{}, errors.Join(err, r.maybeUpdateStatus(ctx, logger, pg, oldPGStatus, nrr, staticEndpoints))
|
return reconcile.Result{}, errors.Join(err, r.maybeUpdateStatus(ctx, logger, pg, oldPGStatus, nrr, staticEndpoints))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ func (r *ProxyGroupReconciler) Reconcile(ctx context.Context, req reconcile.Requ
|
|||||||
// for deletion. It is separated out from Reconcile to make a clear separation
|
// for deletion. It is separated out from Reconcile to make a clear separation
|
||||||
// between reconciling the ProxyGroup, and posting the status of its created
|
// between reconciling the ProxyGroup, and posting the status of its created
|
||||||
// resources onto the ProxyGroup status field.
|
// resources onto the ProxyGroup status field.
|
||||||
func (r *ProxyGroupReconciler) reconcilePG(ctx context.Context, tailscaleClient tsClient, loginUrl string, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger) (map[string][]netip.AddrPort, *notReadyReason, error) {
|
func (r *ProxyGroupReconciler) reconcilePG(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger) (map[string][]netip.AddrPort, *notReadyReason, error) {
|
||||||
if !slices.Contains(pg.Finalizers, FinalizerName) {
|
if !slices.Contains(pg.Finalizers, FinalizerName) {
|
||||||
// This log line is printed exactly once during initial provisioning,
|
// This log line is printed exactly once during initial provisioning,
|
||||||
// because once the finalizer is in place this block gets skipped. So,
|
// because once the finalizer is in place this block gets skipped. So,
|
||||||
@@ -209,7 +209,7 @@ func (r *ProxyGroupReconciler) reconcilePG(ctx context.Context, tailscaleClient
|
|||||||
return notReady(reasonProxyGroupInvalid, fmt.Sprintf("invalid ProxyGroup spec: %v", err))
|
return notReady(reasonProxyGroupInvalid, fmt.Sprintf("invalid ProxyGroup spec: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
staticEndpoints, nrr, err := r.maybeProvision(ctx, tailscaleClient, loginUrl, pg, proxyClass)
|
staticEndpoints, nrr, err := r.maybeProvision(ctx, tsClient, pg, proxyClass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nrr, err
|
return nil, nrr, err
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ func (r *ProxyGroupReconciler) validate(ctx context.Context, pg *tsapi.ProxyGrou
|
|||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tailscaleClient tsClient, loginUrl string, pg *tsapi.ProxyGroup, proxyClass *tsapi.ProxyClass) (map[string][]netip.AddrPort, *notReadyReason, error) {
|
func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup, proxyClass *tsapi.ProxyClass) (map[string][]netip.AddrPort, *notReadyReason, error) {
|
||||||
logger := r.logger(pg.Name)
|
logger := r.logger(pg.Name)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.ensureStateAddedForProxyGroup(pg)
|
r.ensureStateAddedForProxyGroup(pg)
|
||||||
@@ -317,7 +317,7 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tailscaleClie
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
staticEndpoints, err := r.ensureConfigSecretsCreated(ctx, tailscaleClient, loginUrl, pg, proxyClass, svcToNodePorts)
|
staticEndpoints, err := r.ensureConfigSecretsCreated(ctx, tsClient, pg, proxyClass, svcToNodePorts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := errors.AsType[*FindStaticEndpointErr](err); ok {
|
if _, ok := errors.AsType[*FindStaticEndpointErr](err); ok {
|
||||||
reason := reasonProxyGroupCreationFailed
|
reason := reasonProxyGroupCreationFailed
|
||||||
@@ -428,7 +428,7 @@ func (r *ProxyGroupReconciler) maybeProvision(ctx context.Context, tailscaleClie
|
|||||||
return r.notReadyErrf(pg, logger, "error reconciling metrics resources: %w", err)
|
return r.notReadyErrf(pg, logger, "error reconciling metrics resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.cleanupDanglingResources(ctx, tailscaleClient, pg, proxyClass); err != nil {
|
if err := r.cleanupDanglingResources(ctx, tsClient, pg, proxyClass); err != nil {
|
||||||
return r.notReadyErrf(pg, logger, "error cleaning up dangling resources: %w", err)
|
return r.notReadyErrf(pg, logger, "error cleaning up dangling resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +625,7 @@ func (r *ProxyGroupReconciler) ensureNodePortServiceCreated(ctx context.Context,
|
|||||||
|
|
||||||
// cleanupDanglingResources ensures we don't leak config secrets, state secrets, and
|
// cleanupDanglingResources ensures we don't leak config secrets, state secrets, and
|
||||||
// tailnet devices when the number of replicas specified is reduced.
|
// tailnet devices when the number of replicas specified is reduced.
|
||||||
func (r *ProxyGroupReconciler) cleanupDanglingResources(ctx context.Context, tailscaleClient tsClient, pg *tsapi.ProxyGroup, pc *tsapi.ProxyClass) error {
|
func (r *ProxyGroupReconciler) cleanupDanglingResources(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup, pc *tsapi.ProxyClass) error {
|
||||||
logger := r.logger(pg.Name)
|
logger := r.logger(pg.Name)
|
||||||
metadata, err := getNodeMetadata(ctx, pg, r.Client, r.tsNamespace)
|
metadata, err := getNodeMetadata(ctx, pg, r.Client, r.tsNamespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -639,7 +639,7 @@ func (r *ProxyGroupReconciler) cleanupDanglingResources(ctx context.Context, tai
|
|||||||
|
|
||||||
// Dangling resource, delete the config + state Secrets, as well as
|
// Dangling resource, delete the config + state Secrets, as well as
|
||||||
// deleting the device from the tailnet.
|
// deleting the device from the tailnet.
|
||||||
if err := r.ensureDeviceDeleted(ctx, tailscaleClient, m.tsID, logger); err != nil {
|
if err := r.ensureDeviceDeleted(ctx, tsClient, m.tsID, logger); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := r.Delete(ctx, m.stateSecret); err != nil && !apierrors.IsNotFound(err) {
|
if err := r.Delete(ctx, m.stateSecret); err != nil && !apierrors.IsNotFound(err) {
|
||||||
@@ -682,7 +682,7 @@ func (r *ProxyGroupReconciler) cleanupDanglingResources(ctx context.Context, tai
|
|||||||
// maybeCleanup just deletes the device from the tailnet. All the kubernetes
|
// maybeCleanup just deletes the device from the tailnet. All the kubernetes
|
||||||
// resources linked to a ProxyGroup will get cleaned up via owner references
|
// resources linked to a ProxyGroup will get cleaned up via owner references
|
||||||
// (which we can use because they are all in the same namespace).
|
// (which we can use because they are all in the same namespace).
|
||||||
func (r *ProxyGroupReconciler) maybeCleanup(ctx context.Context, tailscaleClient tsClient, pg *tsapi.ProxyGroup) (bool, error) {
|
func (r *ProxyGroupReconciler) maybeCleanup(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup) (bool, error) {
|
||||||
logger := r.logger(pg.Name)
|
logger := r.logger(pg.Name)
|
||||||
|
|
||||||
metadata, err := getNodeMetadata(ctx, pg, r.Client, r.tsNamespace)
|
metadata, err := getNodeMetadata(ctx, pg, r.Client, r.tsNamespace)
|
||||||
@@ -691,7 +691,7 @@ func (r *ProxyGroupReconciler) maybeCleanup(ctx context.Context, tailscaleClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range metadata {
|
for _, m := range metadata {
|
||||||
if err := r.ensureDeviceDeleted(ctx, tailscaleClient, m.tsID, logger); err != nil {
|
if err := r.ensureDeviceDeleted(ctx, tsClient, m.tsID, logger); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,25 +712,23 @@ func (r *ProxyGroupReconciler) maybeCleanup(ctx context.Context, tailscaleClient
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProxyGroupReconciler) ensureDeviceDeleted(ctx context.Context, tailscaleClient tsClient, id tailcfg.StableNodeID, logger *zap.SugaredLogger) error {
|
func (r *ProxyGroupReconciler) ensureDeviceDeleted(ctx context.Context, tsClient tsclient.Client, id tailcfg.StableNodeID, logger *zap.SugaredLogger) error {
|
||||||
logger.Debugf("deleting device %s from control", string(id))
|
logger.Debugf("deleting device %s from control", string(id))
|
||||||
if err := tailscaleClient.DeleteDevice(ctx, string(id)); err != nil {
|
err := tsClient.Devices().Delete(ctx, string(id))
|
||||||
if errResp, ok := errors.AsType[tailscale.ErrResponse](err); ok && errResp.Status == http.StatusNotFound {
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
|
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(id))
|
||||||
} else {
|
case err != nil:
|
||||||
return fmt.Errorf("error deleting device: %w", err)
|
return fmt.Errorf("error deleting device: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.Debugf("device %s deleted from control", string(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
logger.Debugf("device %s deleted from control", string(id))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tailscaleClient tsClient,
|
tsClient tsclient.Client,
|
||||||
loginUrl string,
|
|
||||||
pg *tsapi.ProxyGroup,
|
pg *tsapi.ProxyGroup,
|
||||||
proxyClass *tsapi.ProxyClass,
|
proxyClass *tsapi.ProxyClass,
|
||||||
svcToNodePorts map[string]uint16,
|
svcToNodePorts map[string]uint16,
|
||||||
@@ -756,7 +754,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
authKey, err := r.getAuthKey(ctx, tailscaleClient, pg, existingCfgSecret, i, logger)
|
authKey, err := r.getAuthKey(ctx, tsClient, pg, existingCfgSecret, i, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -838,8 +836,8 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginUrl != "" {
|
if tsClient.LoginURL() != "" {
|
||||||
cfg.ServerURL = new(loginUrl)
|
cfg.ServerURL = new(tsClient.LoginURL())
|
||||||
}
|
}
|
||||||
|
|
||||||
if proxyClass != nil && proxyClass.Spec.TailscaleConfig != nil {
|
if proxyClass != nil && proxyClass.Spec.TailscaleConfig != nil {
|
||||||
@@ -867,7 +865,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
configs, err := pgTailscaledConfig(pg, loginUrl, proxyClass, i, authKey, endpoints[nodePortSvcName], existingAdvertiseServices)
|
configs, err := pgTailscaledConfig(pg, tsClient.LoginURL(), proxyClass, i, authKey, endpoints[nodePortSvcName], existingAdvertiseServices)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error creating tailscaled config: %w", err)
|
return nil, fmt.Errorf("error creating tailscaled config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -904,7 +902,7 @@ func (r *ProxyGroupReconciler) ensureConfigSecretsCreated(
|
|||||||
// A new key is created if the config Secret doesn't exist yet, or if the
|
// A new key is created if the config Secret doesn't exist yet, or if the
|
||||||
// proxy has requested a reissue via its state Secret. An existing key is
|
// proxy has requested a reissue via its state Secret. An existing key is
|
||||||
// retained while the device hasn't authed or a reissue is in progress.
|
// retained while the device hasn't authed or a reissue is in progress.
|
||||||
func (r *ProxyGroupReconciler) getAuthKey(ctx context.Context, tailscaleClient tsClient, pg *tsapi.ProxyGroup, existingCfgSecret *corev1.Secret, ordinal int32, logger *zap.SugaredLogger) (*string, error) {
|
func (r *ProxyGroupReconciler) getAuthKey(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup, existingCfgSecret *corev1.Secret, ordinal int32, logger *zap.SugaredLogger) (*string, error) {
|
||||||
// Get state Secret to check if it's already authed or has requested
|
// Get state Secret to check if it's already authed or has requested
|
||||||
// a fresh auth key.
|
// a fresh auth key.
|
||||||
stateSecret := &corev1.Secret{
|
stateSecret := &corev1.Secret{
|
||||||
@@ -931,7 +929,7 @@ func (r *ProxyGroupReconciler) getAuthKey(ctx context.Context, tailscaleClient t
|
|||||||
|
|
||||||
if !createAuthKey {
|
if !createAuthKey {
|
||||||
var err error
|
var err error
|
||||||
createAuthKey, err = r.shouldReissueAuthKey(ctx, tailscaleClient, pg, stateSecret, cfgAuthKey)
|
createAuthKey, err = r.shouldReissueAuthKey(ctx, tsClient, pg, stateSecret, cfgAuthKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -945,7 +943,7 @@ func (r *ProxyGroupReconciler) getAuthKey(ctx context.Context, tailscaleClient t
|
|||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
tags = r.defaultTags
|
tags = r.defaultTags
|
||||||
}
|
}
|
||||||
key, err := newAuthKey(ctx, tailscaleClient, tags)
|
key, err := newAuthKey(ctx, tsClient, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -965,7 +963,7 @@ func (r *ProxyGroupReconciler) getAuthKey(ctx context.Context, tailscaleClient t
|
|||||||
// shouldReissueAuthKey returns true if the proxy needs a new auth key. It
|
// shouldReissueAuthKey returns true if the proxy needs a new auth key. It
|
||||||
// tracks in-flight reissues via authKeyReissuing to avoid duplicate API calls
|
// tracks in-flight reissues via authKeyReissuing to avoid duplicate API calls
|
||||||
// across reconciles.
|
// across reconciles.
|
||||||
func (r *ProxyGroupReconciler) shouldReissueAuthKey(ctx context.Context, tailscaleClient tsClient, pg *tsapi.ProxyGroup, stateSecret *corev1.Secret, cfgAuthKey *string) (shouldReissue bool, err error) {
|
func (r *ProxyGroupReconciler) shouldReissueAuthKey(ctx context.Context, tsClient tsclient.Client, pg *tsapi.ProxyGroup, stateSecret *corev1.Secret, cfgAuthKey *string) (shouldReissue bool, err error) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
reissuing := r.authKeyReissuing[stateSecret.Name]
|
reissuing := r.authKeyReissuing[stateSecret.Name]
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
@@ -1017,7 +1015,7 @@ func (r *ProxyGroupReconciler) shouldReissueAuthKey(ctx context.Context, tailsca
|
|||||||
r.log.Infof("Proxy failing to auth; attempting cleanup and new key")
|
r.log.Infof("Proxy failing to auth; attempting cleanup and new key")
|
||||||
if tsID := stateSecret.Data[kubetypes.KeyDeviceID]; len(tsID) > 0 {
|
if tsID := stateSecret.Data[kubetypes.KeyDeviceID]; len(tsID) > 0 {
|
||||||
id := tailcfg.StableNodeID(tsID)
|
id := tailcfg.StableNodeID(tsID)
|
||||||
if err := r.ensureDeviceDeleted(ctx, tailscaleClient, id, r.log); err != nil {
|
if err = r.ensureDeviceDeleted(ctx, tsClient, id, r.log); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1305,29 +1303,6 @@ func (r *ProxyGroupReconciler) getRunningProxies(ctx context.Context, pg *tsapi.
|
|||||||
return devices, nil
|
return devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientAndLoginURL returns the appropriate Tailscale client and resolved login URL
|
|
||||||
// for the given tailnet name. If no tailnet is specified, returns the default client
|
|
||||||
// and login server. Applies fallback to the operator's login server if the tailnet
|
|
||||||
// doesn't specify a custom login URL.
|
|
||||||
func (r *ProxyGroupReconciler) getClientAndLoginURL(ctx context.Context, tailnetName string) (tsClient,
|
|
||||||
string, error) {
|
|
||||||
if tailnetName == "" {
|
|
||||||
return r.tsClient, r.loginServer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tc, loginUrl, err := clientForTailnet(ctx, r.Client, r.tsNamespace, tailnetName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fallback if tailnet doesn't specify custom login URL
|
|
||||||
if loginUrl == "" {
|
|
||||||
loginUrl = r.loginServer
|
|
||||||
}
|
|
||||||
|
|
||||||
return tc, loginUrl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type nodeMetadata struct {
|
type nodeMetadata struct {
|
||||||
ordinal int32
|
ordinal int32
|
||||||
stateSecret *corev1.Secret
|
stateSecret *corev1.Secret
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
"tailscale.com/client/tailscale"
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/k8s-proxy/conf"
|
"tailscale.com/kube/k8s-proxy/conf"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -43,7 +45,6 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
testProxyImage = "tailscale/tailscale:test"
|
testProxyImage = "tailscale/tailscale:test"
|
||||||
initialCfgHash = "6632726be70cf224049580deb4d317bba065915b5fd415461d60ed621c91b196"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -641,7 +642,7 @@ func TestProxyGroupWithStaticEndpoints(t *testing.T) {
|
|||||||
defaultProxyClass: "default-pc",
|
defaultProxyClass: "default-pc",
|
||||||
|
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: tsClient,
|
clients: tsclient.NewProvider(tsClient),
|
||||||
recorder: fr,
|
recorder: fr,
|
||||||
clock: cl,
|
clock: cl,
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
@@ -649,7 +650,7 @@ func TestProxyGroupWithStaticEndpoints(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, r := range tt.reconciles {
|
for i, r := range tt.reconciles {
|
||||||
createdNodes := []corev1.Node{}
|
var createdNodes []corev1.Node
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
for _, n := range r.nodes {
|
for _, n := range r.nodes {
|
||||||
no := &corev1.Node{
|
no := &corev1.Node{
|
||||||
@@ -786,7 +787,7 @@ func TestProxyGroupWithStaticEndpoints(t *testing.T) {
|
|||||||
defaultProxyClass: "default-pc",
|
defaultProxyClass: "default-pc",
|
||||||
|
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: tsClient,
|
clients: tsclient.NewProvider(tsClient),
|
||||||
recorder: fr,
|
recorder: fr,
|
||||||
log: zl.Sugar().With("TestName", tt.name).With("Reconcile", "cleanup"),
|
log: zl.Sugar().With("TestName", tt.name).With("Reconcile", "cleanup"),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
@@ -849,7 +850,7 @@ func TestProxyGroup(t *testing.T) {
|
|||||||
defaultProxyClass: "default-pc",
|
defaultProxyClass: "default-pc",
|
||||||
|
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: tsClient,
|
clients: tsclient.NewProvider(tsClient),
|
||||||
recorder: fr,
|
recorder: fr,
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
@@ -908,17 +909,13 @@ func TestProxyGroup(t *testing.T) {
|
|||||||
t.Fatalf("expected %d egress ProxyGroups, got %d", expected, reconciler.egressProxyGroups.Len())
|
t.Fatalf("expected %d egress ProxyGroups, got %d", expected, reconciler.egressProxyGroups.Len())
|
||||||
}
|
}
|
||||||
expectProxyGroupResources(t, fc, pg, true, pc)
|
expectProxyGroupResources(t, fc, pg, true, pc)
|
||||||
keyReq := tailscale.KeyCapabilities{
|
var keyReq tailscale.KeyCapabilities
|
||||||
Devices: tailscale.KeyDeviceCapabilities{
|
keyReq.Devices.Create.Reusable = false
|
||||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
keyReq.Devices.Create.Ephemeral = false
|
||||||
Reusable: false,
|
keyReq.Devices.Create.Preauthorized = true
|
||||||
Ephemeral: false,
|
keyReq.Devices.Create.Tags = []string{"tag:test-tag"}
|
||||||
Preauthorized: true,
|
|
||||||
Tags: []string{"tag:test-tag"},
|
if diff := cmp.Diff(tsClient.keyRequests, []tailscale.KeyCapabilities{keyReq, keyReq}); diff != "" {
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if diff := cmp.Diff(tsClient.KeyRequests(), []tailscale.KeyCapabilities{keyReq, keyReq}); diff != "" {
|
|
||||||
t.Fatalf("unexpected secrets (-got +want):\n%s", diff)
|
t.Fatalf("unexpected secrets (-got +want):\n%s", diff)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1059,7 +1056,7 @@ func TestProxyGroupTypes(t *testing.T) {
|
|||||||
tsProxyImage: testProxyImage,
|
tsProxyImage: testProxyImage,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
clock: tstest.NewClock(tstest.ClockOpts{}),
|
clock: tstest.NewClock(tstest.ClockOpts{}),
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
authKeyReissuing: make(map[string]bool),
|
authKeyReissuing: make(map[string]bool),
|
||||||
@@ -1301,7 +1298,7 @@ func TestKubeAPIServerStatusConditionFlow(t *testing.T) {
|
|||||||
tsProxyImage: testProxyImage,
|
tsProxyImage: testProxyImage,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
clock: tstest.NewClock(tstest.ClockOpts{}),
|
clock: tstest.NewClock(tstest.ClockOpts{}),
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
authKeyReissuing: make(map[string]bool),
|
authKeyReissuing: make(map[string]bool),
|
||||||
@@ -1356,7 +1353,7 @@ func TestKubeAPIServerType_DoesNotOverwriteServicesConfig(t *testing.T) {
|
|||||||
tsProxyImage: testProxyImage,
|
tsProxyImage: testProxyImage,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
clock: tstest.NewClock(tstest.ClockOpts{}),
|
clock: tstest.NewClock(tstest.ClockOpts{}),
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
authKeyReissuing: make(map[string]bool),
|
authKeyReissuing: make(map[string]bool),
|
||||||
@@ -1443,7 +1440,7 @@ func TestIngressAdvertiseServicesConfigPreserved(t *testing.T) {
|
|||||||
tsProxyImage: testProxyImage,
|
tsProxyImage: testProxyImage,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
log: zap.Must(zap.NewDevelopment()).Sugar(),
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
clock: tstest.NewClock(tstest.ClockOpts{}),
|
clock: tstest.NewClock(tstest.ClockOpts{}),
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
authKeyReissuing: make(map[string]bool),
|
authKeyReissuing: make(map[string]bool),
|
||||||
@@ -1713,7 +1710,7 @@ func TestProxyGroupGetAuthKey(t *testing.T) {
|
|||||||
tsFirewallMode: "auto",
|
tsFirewallMode: "auto",
|
||||||
|
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: tsClient,
|
clients: tsclient.NewProvider(tsClient),
|
||||||
recorder: fr,
|
recorder: fr,
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
@@ -2109,7 +2106,7 @@ func TestProxyGroupLetsEncryptStaging(t *testing.T) {
|
|||||||
defaultTags: []string{"tag:test"},
|
defaultTags: []string{"tag:test"},
|
||||||
defaultProxyClass: tt.defaultProxyClass,
|
defaultProxyClass: tt.defaultProxyClass,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: &fakeTSClient{},
|
clients: tsclient.NewProvider(&fakeTSClient{}),
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
authKeyRateLimits: make(map[string]*rate.Limiter),
|
authKeyRateLimits: make(map[string]*rate.Limiter),
|
||||||
|
|||||||
+73
-102
@@ -12,7 +12,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -30,11 +29,12 @@ import (
|
|||||||
"k8s.io/apiserver/pkg/storage/names"
|
"k8s.io/apiserver/pkg/storage/names"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -174,7 +174,7 @@ type tsnetServer interface {
|
|||||||
type tailscaleSTSReconciler struct {
|
type tailscaleSTSReconciler struct {
|
||||||
client.Client
|
client.Client
|
||||||
tsnetServer tsnetServer
|
tsnetServer tsnetServer
|
||||||
tsClient tsClient
|
clients ClientProvider
|
||||||
defaultTags []string
|
defaultTags []string
|
||||||
operatorNamespace string
|
operatorNamespace string
|
||||||
proxyImage string
|
proxyImage string
|
||||||
@@ -183,9 +183,9 @@ type tailscaleSTSReconciler struct {
|
|||||||
loginServer string
|
loginServer string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sts tailscaleSTSReconciler) validate() error {
|
func (r *tailscaleSTSReconciler) validate() error {
|
||||||
if sts.tsFirewallMode != "" && !isValidFirewallMode(sts.tsFirewallMode) {
|
if r.tsFirewallMode != "" && !isValidFirewallMode(r.tsFirewallMode) {
|
||||||
return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", sts.tsFirewallMode)
|
return fmt.Errorf("invalid proxy firewall mode %s, valid modes are iptables, nftables or unset", r.tsFirewallMode)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -197,22 +197,17 @@ func IsHTTPSEnabledOnTailnet(tsnetServer tsnetServer) bool {
|
|||||||
|
|
||||||
// Provision ensures that the StatefulSet for the given service is running and
|
// Provision ensures that the StatefulSet for the given service is running and
|
||||||
// up to date.
|
// up to date.
|
||||||
func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
func (r *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
||||||
tailscaleClient, loginUrl, err := a.getClientAndLoginURL(ctx, sts.Tailnet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get tailscale client and loginUrl: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do full reconcile.
|
// Do full reconcile.
|
||||||
// TODO (don't create Service for the Connector)
|
// TODO (don't create Service for the Connector)
|
||||||
hsvc, err := a.reconcileHeadlessService(ctx, logger, sts)
|
hsvc, err := r.reconcileHeadlessService(ctx, logger, sts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
|
return nil, fmt.Errorf("failed to reconcile headless service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyClass := new(tsapi.ProxyClass)
|
proxyClass := new(tsapi.ProxyClass)
|
||||||
if sts.ProxyClassName != "" {
|
if sts.ProxyClassName != "" {
|
||||||
if err := a.Get(ctx, types.NamespacedName{Name: sts.ProxyClassName}, proxyClass); err != nil {
|
if err := r.Get(ctx, types.NamespacedName{Name: sts.ProxyClassName}, proxyClass); err != nil {
|
||||||
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
|
return nil, fmt.Errorf("failed to get ProxyClass: %w", err)
|
||||||
}
|
}
|
||||||
if !tsoperator.ProxyClassIsReady(proxyClass) {
|
if !tsoperator.ProxyClassIsReady(proxyClass) {
|
||||||
@@ -222,12 +217,17 @@ func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.Suga
|
|||||||
}
|
}
|
||||||
sts.ProxyClass = proxyClass
|
sts.ProxyClass = proxyClass
|
||||||
|
|
||||||
secretNames, err := a.provisionSecrets(ctx, tailscaleClient, loginUrl, sts, hsvc, logger)
|
tsClient, err := r.clients.For(sts.Tailnet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get tailscale client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretNames, err := r.provisionSecrets(ctx, tsClient, sts, hsvc, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
|
return nil, fmt.Errorf("failed to create or get API key secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = a.reconcileSTS(ctx, logger, sts, hsvc, secretNames)
|
_, err = r.reconcileSTS(ctx, logger, sts, hsvc, secretNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
|
return nil, fmt.Errorf("failed to reconcile statefulset: %w", err)
|
||||||
}
|
}
|
||||||
@@ -237,57 +237,29 @@ func (a *tailscaleSTSReconciler) Provision(ctx context.Context, logger *zap.Suga
|
|||||||
proxyLabels: hsvc.Labels,
|
proxyLabels: hsvc.Labels,
|
||||||
proxyType: sts.proxyType,
|
proxyType: sts.proxyType,
|
||||||
}
|
}
|
||||||
if err = reconcileMetricsResources(ctx, logger, mo, sts.ProxyClass, a.Client); err != nil {
|
if err = reconcileMetricsResources(ctx, logger, mo, sts.ProxyClass, r.Client); err != nil {
|
||||||
return nil, fmt.Errorf("failed to ensure metrics resources: %w", err)
|
return nil, fmt.Errorf("failed to ensure metrics resources: %w", err)
|
||||||
}
|
}
|
||||||
return hsvc, nil
|
return hsvc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientAndLoginURL returns the appropriate Tailscale client and resolved login URL
|
|
||||||
// for the given tailnet name. If no tailnet is specified, returns the default client
|
|
||||||
// and login server. Applies fallback to the operator's login server if the tailnet
|
|
||||||
// doesn't specify a custom login URL.
|
|
||||||
func (a *tailscaleSTSReconciler) getClientAndLoginURL(ctx context.Context, tailnetName string) (tsClient,
|
|
||||||
string, error) {
|
|
||||||
if tailnetName == "" {
|
|
||||||
return a.tsClient, a.loginServer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tc, loginUrl, err := clientForTailnet(ctx, a.Client, a.operatorNamespace, tailnetName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fallback if tailnet doesn't specify custom login URL
|
|
||||||
if loginUrl == "" {
|
|
||||||
loginUrl = a.loginServer
|
|
||||||
}
|
|
||||||
|
|
||||||
return tc, loginUrl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup removes all resources associated that were created by Provision with
|
// Cleanup removes all resources associated that were created by Provision with
|
||||||
// the given labels. It returns true when all resources have been removed,
|
// the given labels. It returns true when all resources have been removed,
|
||||||
// otherwise it returns false and the caller should retry later.
|
// otherwise it returns false and the caller should retry later.
|
||||||
func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, tailnet string, logger *zap.SugaredLogger, labels map[string]string, typ string) (done bool, _ error) {
|
func (r *tailscaleSTSReconciler) Cleanup(ctx context.Context, tailnet string, logger *zap.SugaredLogger, labels map[string]string, typ string) (done bool, _ error) {
|
||||||
tailscaleClient := a.tsClient
|
tsClient, err := r.clients.For(tailnet)
|
||||||
if tailnet != "" {
|
|
||||||
tc, _, err := clientForTailnet(ctx, a.Client, a.operatorNamespace, tailnet)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to get tailscale client: %v", err)
|
logger.Errorf("failed to get tailscale client: %v", err)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleClient = tc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to delete the StatefulSet first, and delete it with foreground
|
// Need to delete the StatefulSet first, and delete it with foreground
|
||||||
// cascading deletion. That way, the pod that's writing to the Secret will
|
// cascading deletion. That way, the pod that's writing to the Secret will
|
||||||
// stop running before we start looking at the Secret's contents, and
|
// stop running before we start looking at the Secret's contents, and
|
||||||
// assuming k8s ordering semantics don't mess with us, that should avoid
|
// assuming k8s ordering semantics don't mess with us, that should avoid
|
||||||
// tailscale device deletion races where we fail to notice a device that
|
// tailscale device deletion races where we fail to notice a device that
|
||||||
// should be removed.
|
// should be removed.
|
||||||
sts, err := getSingleObject[appsv1.StatefulSet](ctx, a.Client, a.operatorNamespace, labels)
|
sts, err := getSingleObject[appsv1.StatefulSet](ctx, r.Client, r.operatorNamespace, labels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("getting statefulset: %w", err)
|
return false, fmt.Errorf("getting statefulset: %w", err)
|
||||||
}
|
}
|
||||||
@@ -301,12 +273,12 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, tailnet string, lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
options := []client.DeleteAllOfOption{
|
options := []client.DeleteAllOfOption{
|
||||||
client.InNamespace(a.operatorNamespace),
|
client.InNamespace(r.operatorNamespace),
|
||||||
client.MatchingLabels(labels),
|
client.MatchingLabels(labels),
|
||||||
client.PropagationPolicy(metav1.DeletePropagationForeground),
|
client.PropagationPolicy(metav1.DeletePropagationForeground),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = a.DeleteAllOf(ctx, &appsv1.StatefulSet{}, options...); err != nil {
|
if err = r.DeleteAllOf(ctx, &appsv1.StatefulSet{}, options...); err != nil {
|
||||||
return false, fmt.Errorf("deleting statefulset: %w", err)
|
return false, fmt.Errorf("deleting statefulset: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +286,7 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, tailnet string, lo
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
devices, err := a.DeviceInfo(ctx, labels, logger)
|
devices, err := r.DeviceInfo(ctx, labels, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("getting device info: %w", err)
|
return false, fmt.Errorf("getting device info: %w", err)
|
||||||
}
|
}
|
||||||
@@ -322,33 +294,36 @@ func (a *tailscaleSTSReconciler) Cleanup(ctx context.Context, tailnet string, lo
|
|||||||
for _, dev := range devices {
|
for _, dev := range devices {
|
||||||
if dev.id != "" {
|
if dev.id != "" {
|
||||||
logger.Debugf("deleting device %s from control", string(dev.id))
|
logger.Debugf("deleting device %s from control", string(dev.id))
|
||||||
if err = tailscaleClient.DeleteDevice(ctx, string(dev.id)); err != nil {
|
err = tsClient.Devices().Delete(ctx, string(dev.id))
|
||||||
if errResp, ok := errors.AsType[tailscale.ErrResponse](err); ok && errResp.Status == http.StatusNotFound {
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(dev.id))
|
logger.Debugf("device %s not found, likely because it has already been deleted from control", string(dev.id))
|
||||||
} else {
|
case err != nil:
|
||||||
return false, fmt.Errorf("deleting device: %w", err)
|
return false, fmt.Errorf("deleting device: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.Debugf("device %s deleted from control", string(dev.id))
|
logger.Debugf("device %s deleted from control", string(dev.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
types := []client.Object{
|
resourceTypes := []client.Object{
|
||||||
&corev1.Service{},
|
&corev1.Service{},
|
||||||
&corev1.Secret{},
|
&corev1.Secret{},
|
||||||
}
|
}
|
||||||
for _, typ := range types {
|
|
||||||
if err := a.DeleteAllOf(ctx, typ, client.InNamespace(a.operatorNamespace), client.MatchingLabels(labels)); err != nil {
|
for _, resourceType := range resourceTypes {
|
||||||
|
if err = r.DeleteAllOf(ctx, resourceType, client.InNamespace(r.operatorNamespace), client.MatchingLabels(labels)); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mo := &metricsOpts{
|
mo := &metricsOpts{
|
||||||
proxyLabels: labels,
|
proxyLabels: labels,
|
||||||
tsNamespace: a.operatorNamespace,
|
tsNamespace: r.operatorNamespace,
|
||||||
proxyType: typ,
|
proxyType: typ,
|
||||||
}
|
}
|
||||||
if err = maybeCleanupMetricsResources(ctx, mo, a.Client); err != nil {
|
|
||||||
|
if err = maybeCleanupMetricsResources(ctx, mo, r.Client); err != nil {
|
||||||
return false, fmt.Errorf("error cleaning up metrics resources: %w", err)
|
return false, fmt.Errorf("error cleaning up metrics resources: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,12 +357,12 @@ func statefulSetNameBase(parent string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
func (r *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
||||||
nameBase := statefulSetNameBase(sts.ParentResourceName)
|
nameBase := statefulSetNameBase(sts.ParentResourceName)
|
||||||
hsvc := &corev1.Service{
|
hsvc := &corev1.Service{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
GenerateName: nameBase,
|
GenerateName: nameBase,
|
||||||
Namespace: a.operatorNamespace,
|
Namespace: r.operatorNamespace,
|
||||||
Labels: sts.ChildResourceLabels,
|
Labels: sts.ChildResourceLabels,
|
||||||
},
|
},
|
||||||
Spec: corev1.ServiceSpec{
|
Spec: corev1.ServiceSpec{
|
||||||
@@ -399,10 +374,10 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
logger.Debugf("reconciling headless service for StatefulSet")
|
logger.Debugf("reconciling headless service for StatefulSet")
|
||||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
|
return createOrUpdate(ctx, r.Client, r.operatorNamespace, hsvc, func(svc *corev1.Service) { svc.Spec = hsvc.Spec })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscaleClient tsClient, loginUrl string, stsC *tailscaleSTSConfig, hsvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) {
|
func (r *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tsClient tsclient.Client, stsC *tailscaleSTSConfig, hsvc *corev1.Service, logger *zap.SugaredLogger) ([]string, error) {
|
||||||
secretNames := make([]string, stsC.Replicas)
|
secretNames := make([]string, stsC.Replicas)
|
||||||
|
|
||||||
// Start by ensuring we have Secrets for the desired number of replicas. This will handle both creating and scaling
|
// Start by ensuring we have Secrets for the desired number of replicas. This will handle both creating and scaling
|
||||||
@@ -411,7 +386,7 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
secret := &corev1.Secret{
|
secret := &corev1.Secret{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
Name: fmt.Sprintf("%s-%d", hsvc.Name, i),
|
Name: fmt.Sprintf("%s-%d", hsvc.Name, i),
|
||||||
Namespace: a.operatorNamespace,
|
Namespace: r.operatorNamespace,
|
||||||
Labels: stsC.ChildResourceLabels,
|
Labels: stsC.ChildResourceLabels,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -426,7 +401,7 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
secretNames[i] = secret.Name
|
secretNames[i] = secret.Name
|
||||||
|
|
||||||
var orig *corev1.Secret // unmodified copy of secret
|
var orig *corev1.Secret // unmodified copy of secret
|
||||||
if err := a.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
|
if err := r.Get(ctx, client.ObjectKeyFromObject(secret), secret); err == nil {
|
||||||
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
|
logger.Debugf("secret %s/%s already exists", secret.GetNamespace(), secret.GetName())
|
||||||
orig = secret.DeepCopy()
|
orig = secret.DeepCopy()
|
||||||
} else if !apierrors.IsNotFound(err) {
|
} else if !apierrors.IsNotFound(err) {
|
||||||
@@ -437,21 +412,23 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
authKey string
|
authKey string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
if orig == nil {
|
if orig == nil {
|
||||||
// Create API Key secret which is going to be used by the statefulset
|
// Create API Key secret which is going to be used by the statefulset
|
||||||
// to authenticate with Tailscale.
|
// to authenticate with Tailscale.
|
||||||
logger.Debugf("creating authkey for new tailscale proxy")
|
logger.Debugf("creating authkey for new tailscale proxy")
|
||||||
tags := stsC.Tags
|
tags := stsC.Tags
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
tags = a.defaultTags
|
tags = r.defaultTags
|
||||||
}
|
}
|
||||||
authKey, err = newAuthKey(ctx, tailscaleClient, tags)
|
|
||||||
|
authKey, err = newAuthKey(ctx, tsClient, tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
configs, err := tailscaledConfig(stsC, loginUrl, authKey, orig, hostname)
|
configs, err := tailscaledConfig(stsC, tsClient.LoginURL(), authKey, orig, hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error creating tailscaled config: %w", err)
|
return nil, fmt.Errorf("error creating tailscaled config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -483,12 +460,12 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
|
|
||||||
if orig != nil && !apiequality.Semantic.DeepEqual(latest, orig) {
|
if orig != nil && !apiequality.Semantic.DeepEqual(latest, orig) {
|
||||||
logger.With("config", sanitizeConfig(latestConfig)).Debugf("patching the existing proxy Secret")
|
logger.With("config", sanitizeConfig(latestConfig)).Debugf("patching the existing proxy Secret")
|
||||||
if err = a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
|
if err = r.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.With("config", sanitizeConfig(latestConfig)).Debugf("creating a new Secret for the proxy")
|
logger.With("config", sanitizeConfig(latestConfig)).Debugf("creating a new Secret for the proxy")
|
||||||
if err = a.Create(ctx, secret); err != nil {
|
if err = r.Create(ctx, secret); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,7 +474,7 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
// Next, we check if we have additional secrets and remove them and their associated device. This happens when we
|
// Next, we check if we have additional secrets and remove them and their associated device. This happens when we
|
||||||
// scale an StatefulSet down.
|
// scale an StatefulSet down.
|
||||||
var secrets corev1.SecretList
|
var secrets corev1.SecretList
|
||||||
if err := a.List(ctx, &secrets, client.InNamespace(a.operatorNamespace), client.MatchingLabels(stsC.ChildResourceLabels)); err != nil {
|
if err := r.List(ctx, &secrets, client.InNamespace(r.operatorNamespace), client.MatchingLabels(stsC.ChildResourceLabels)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,16 +494,14 @@ func (a *tailscaleSTSReconciler) provisionSecrets(ctx context.Context, tailscale
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dev != nil && dev.id != "" {
|
if dev != nil && dev.id != "" {
|
||||||
err = tailscaleClient.DeleteDevice(ctx, string(dev.id))
|
// If we get a not found error then this device has possibly already been deleted in the admin console.
|
||||||
if errResp, ok := errors.AsType[*tailscale.ErrResponse](err); ok && errResp.Status == http.StatusNotFound {
|
// So we can ignore this and move on to removing the secret.
|
||||||
// This device has possibly already been deleted in the admin console. So we can ignore this
|
if err = tsClient.Devices().Delete(ctx, string(dev.id)); err != nil && !tailscale.IsNotFound(err) {
|
||||||
// and move on to removing the secret.
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = a.Delete(ctx, &secret); err != nil {
|
if err = r.Delete(ctx, &secret); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,9 +525,9 @@ func sanitizeConfig(c ipn.ConfigVAlpha) ipn.ConfigVAlpha {
|
|||||||
// It retrieves info from a Kubernetes Secret labeled with the provided labels. Capver is cross-validated against the
|
// It retrieves info from a Kubernetes Secret labeled with the provided labels. Capver is cross-validated against the
|
||||||
// Pod to ensure that it is the currently running Pod that set the capver. If the Pod or the Secret does not exist, the
|
// Pod to ensure that it is the currently running Pod that set the capver. If the Pod or the Secret does not exist, the
|
||||||
// returned capver is -1. Either of device ID, hostname and IPs can be empty string if not found in the Secret.
|
// returned capver is -1. Either of device ID, hostname and IPs can be empty string if not found in the Secret.
|
||||||
func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string, logger *zap.SugaredLogger) ([]*device, error) {
|
func (r *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map[string]string, logger *zap.SugaredLogger) ([]*device, error) {
|
||||||
var secrets corev1.SecretList
|
var secrets corev1.SecretList
|
||||||
if err := a.List(ctx, &secrets, client.InNamespace(a.operatorNamespace), client.MatchingLabels(childLabels)); err != nil {
|
if err := r.List(ctx, &secrets, client.InNamespace(r.operatorNamespace), client.MatchingLabels(childLabels)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,7 +535,7 @@ func (a *tailscaleSTSReconciler) DeviceInfo(ctx context.Context, childLabels map
|
|||||||
for _, sec := range secrets.Items {
|
for _, sec := range secrets.Items {
|
||||||
podUID := ""
|
podUID := ""
|
||||||
pod := new(corev1.Pod)
|
pod := new(corev1.Pod)
|
||||||
err := a.Get(ctx, types.NamespacedName{Namespace: sec.Namespace, Name: sec.Name}, pod)
|
err := r.Get(ctx, types.NamespacedName{Namespace: sec.Namespace, Name: sec.Name}, pod)
|
||||||
switch {
|
switch {
|
||||||
case apierrors.IsNotFound(err):
|
case apierrors.IsNotFound(err):
|
||||||
// If the Pod is not found, we won't have its UID. We can still get the device information but the
|
// If the Pod is not found, we won't have its UID. We can still get the device information but the
|
||||||
@@ -633,22 +608,18 @@ func deviceInfo(sec *corev1.Secret, podUID string, log *zap.SugaredLogger) (dev
|
|||||||
return dev, nil
|
return dev, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuthKey(ctx context.Context, tsClient tsClient, tags []string) (string, error) {
|
func newAuthKey(ctx context.Context, client tsclient.Client, tags []string) (string, error) {
|
||||||
caps := tailscale.KeyCapabilities{
|
var caps tailscale.KeyCapabilities
|
||||||
Devices: tailscale.KeyDeviceCapabilities{
|
caps.Devices.Create.Reusable = false
|
||||||
Create: tailscale.KeyDeviceCreateCapabilities{
|
caps.Devices.Create.Preauthorized = true
|
||||||
Reusable: false,
|
caps.Devices.Create.Tags = tags
|
||||||
Preauthorized: true,
|
|
||||||
Tags: tags,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
key, _, err := tsClient.CreateKey(ctx, caps)
|
key, err := client.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return key, nil
|
|
||||||
|
return key.Key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed deploy/manifests/proxy.yaml
|
//go:embed deploy/manifests/proxy.yaml
|
||||||
@@ -657,7 +628,7 @@ var proxyYaml []byte
|
|||||||
//go:embed deploy/manifests/userspace-proxy.yaml
|
//go:embed deploy/manifests/userspace-proxy.yaml
|
||||||
var userspaceProxyYaml []byte
|
var userspaceProxyYaml []byte
|
||||||
|
|
||||||
func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecrets []string) (*appsv1.StatefulSet, error) {
|
func (r *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig, headlessSvc *corev1.Service, proxySecrets []string) (*appsv1.StatefulSet, error) {
|
||||||
ss := new(appsv1.StatefulSet)
|
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 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 {
|
if err := yaml.Unmarshal(userspaceProxyYaml, &ss); err != nil {
|
||||||
@@ -670,17 +641,17 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
|||||||
for i := range ss.Spec.Template.Spec.InitContainers {
|
for i := range ss.Spec.Template.Spec.InitContainers {
|
||||||
c := &ss.Spec.Template.Spec.InitContainers[i]
|
c := &ss.Spec.Template.Spec.InitContainers[i]
|
||||||
if c.Name == "sysctler" {
|
if c.Name == "sysctler" {
|
||||||
c.Image = a.proxyImage
|
c.Image = r.proxyImage
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pod := &ss.Spec.Template
|
pod := &ss.Spec.Template
|
||||||
container := &pod.Spec.Containers[0]
|
container := &pod.Spec.Containers[0]
|
||||||
container.Image = a.proxyImage
|
container.Image = r.proxyImage
|
||||||
ss.ObjectMeta = metav1.ObjectMeta{
|
ss.ObjectMeta = metav1.ObjectMeta{
|
||||||
Name: headlessSvc.Name,
|
Name: headlessSvc.Name,
|
||||||
Namespace: a.operatorNamespace,
|
Namespace: r.operatorNamespace,
|
||||||
}
|
}
|
||||||
for key, val := range sts.ChildResourceLabels {
|
for key, val := range sts.ChildResourceLabels {
|
||||||
mak.Set(&ss.ObjectMeta.Labels, key, val)
|
mak.Set(&ss.ObjectMeta.Labels, key, val)
|
||||||
@@ -748,13 +719,13 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.tsFirewallMode != "" {
|
if r.tsFirewallMode != "" {
|
||||||
container.Env = append(container.Env, corev1.EnvVar{
|
container.Env = append(container.Env, corev1.EnvVar{
|
||||||
Name: "TS_DEBUG_FIREWALL_MODE",
|
Name: "TS_DEBUG_FIREWALL_MODE",
|
||||||
Value: a.tsFirewallMode,
|
Value: r.tsFirewallMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
pod.Spec.PriorityClassName = a.proxyPriorityClassName
|
pod.Spec.PriorityClassName = r.proxyPriorityClassName
|
||||||
|
|
||||||
// Ingress/egress proxy configuration options.
|
// Ingress/egress proxy configuration options.
|
||||||
if sts.ClusterTargetIP != "" {
|
if sts.ClusterTargetIP != "" {
|
||||||
@@ -829,7 +800,7 @@ func (a *tailscaleSTSReconciler) reconcileSTS(ctx context.Context, logger *zap.S
|
|||||||
s.ObjectMeta.Labels = ss.Labels
|
s.ObjectMeta.Labels = ss.Labels
|
||||||
s.ObjectMeta.Annotations = ss.Annotations
|
s.ObjectMeta.Annotations = ss.Annotations
|
||||||
}
|
}
|
||||||
return createOrUpdate(ctx, a.Client, a.operatorNamespace, ss, updateSS)
|
return createOrUpdate(ctx, r.Client, r.operatorNamespace, ss, updateSS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func appInfoForProxy(cfg *tailscaleSTSConfig) (string, error) {
|
func appInfoForProxy(cfg *tailscaleSTSConfig) (string, error) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -27,11 +26,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/ingressservices"
|
"tailscale.com/kube/ingressservices"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@@ -57,7 +57,7 @@ type HAServiceReconciler struct {
|
|||||||
isDefaultLoadBalancer bool
|
isDefaultLoadBalancer bool
|
||||||
recorder record.EventRecorder
|
recorder record.EventRecorder
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
tsClient tsClient
|
clients ClientProvider
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
defaultTags []string
|
defaultTags []string
|
||||||
operatorID string // stableID of the operator's Tailscale device
|
operatorID string // stableID of the operator's Tailscale device
|
||||||
@@ -121,7 +121,7 @@ func (r *HAServiceReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleClient, err := clientFromProxyGroup(ctx, r.Client, pg, r.tsNamespace, r.tsClient)
|
tsClient, err := r.clients.For(pg.Spec.Tailnet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
return res, fmt.Errorf("failed to get tailscale client: %w", err)
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ func (r *HAServiceReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
|
|
||||||
if !svc.DeletionTimestamp.IsZero() || !r.isTailscaleService(svc) {
|
if !svc.DeletionTimestamp.IsZero() || !r.isTailscaleService(svc) {
|
||||||
logger.Debugf("Service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
|
logger.Debugf("Service is being deleted or is (no longer) referring to Tailscale ingress/egress, ensuring any created resources are cleaned up")
|
||||||
_, err = r.maybeCleanup(ctx, hostname, svc, logger, tailscaleClient)
|
_, err = r.maybeCleanup(ctx, hostname, svc, logger, tsClient)
|
||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ func (r *HAServiceReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
// is the case, we reconcile the Ingress one more time to ensure that concurrent updates to the Tailscale Service in a
|
// is the case, we reconcile the Ingress one more time to ensure that concurrent updates to the Tailscale Service in a
|
||||||
// multi-cluster Ingress setup have not resulted in another actor overwriting our Tailscale Service update.
|
// multi-cluster Ingress setup have not resulted in another actor overwriting our Tailscale Service update.
|
||||||
needsRequeue := false
|
needsRequeue := false
|
||||||
needsRequeue, err = r.maybeProvision(ctx, hostname, svc, pg, logger, tailscaleClient)
|
needsRequeue, err = r.maybeProvision(ctx, hostname, svc, pg, logger, tsClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||||
logger.Infof("optimistic lock error, retrying: %s", err)
|
logger.Infof("optimistic lock error, retrying: %s", err)
|
||||||
@@ -162,7 +162,7 @@ func (r *HAServiceReconciler) Reconcile(ctx context.Context, req reconcile.Reque
|
|||||||
// If a Tailscale Service exists, but does not have an owner reference from any operator, we error
|
// If a Tailscale Service exists, but does not have an owner reference from any operator, we error
|
||||||
// out assuming that this is an owner reference created by an unknown actor.
|
// out assuming that this is an owner reference created by an unknown actor.
|
||||||
// Returns true if the operation resulted in a Tailscale Service update.
|
// Returns true if the operation resulted in a Tailscale Service update.
|
||||||
func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname string, svc *corev1.Service, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsClient) (svcsChanged bool, err error) {
|
func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname string, svc *corev1.Service, pg *tsapi.ProxyGroup, logger *zap.SugaredLogger, tsClient tsclient.Client) (svcsChanged bool, err error) {
|
||||||
oldSvcStatus := svc.Status.DeepCopy()
|
oldSvcStatus := svc.Status.DeepCopy()
|
||||||
defer func() {
|
defer func() {
|
||||||
if !apiequality.Semantic.DeepEqual(oldSvcStatus, &svc.Status) {
|
if !apiequality.Semantic.DeepEqual(oldSvcStatus, &svc.Status) {
|
||||||
@@ -209,8 +209,8 @@ func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
// 2. Ensure that there isn't a Tailscale Service with the same hostname
|
// 2. Ensure that there isn't a Tailscale Service with the same hostname
|
||||||
// already created and not owned by this Service.
|
// already created and not owned by this Service.
|
||||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||||
existingTSSvc, err := tsClient.GetVIPService(ctx, serviceName)
|
existingTSSvc, err := tsClient.VIPServices().Get(ctx, serviceName.String())
|
||||||
if err != nil && !isErrorTailscaleServiceNotFound(err) {
|
if err != nil && !tailscale.IsNotFound(err) {
|
||||||
return false, fmt.Errorf("error getting Tailscale Service %q: %w", hostname, err)
|
return false, fmt.Errorf("error getting Tailscale Service %q: %w", hostname, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +233,8 @@ func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
tags = strings.Split(tstr, ",")
|
tags = strings.Split(tstr, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
tsSvc := &tailscale.VIPService{
|
tsSvc := tailscale.VIPService{
|
||||||
Name: serviceName,
|
Name: serviceName.String(),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
Ports: []string{"do-not-validate"}, // we don't want to validate ports
|
Ports: []string{"do-not-validate"}, // we don't want to validate ports
|
||||||
Comment: managedTSServiceComment,
|
Comment: managedTSServiceComment,
|
||||||
@@ -249,12 +249,13 @@ func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
// with the same generation number has been reconciled ~more than N times and stop attempting to apply updates.
|
// with the same generation number has been reconciled ~more than N times and stop attempting to apply updates.
|
||||||
if existingTSSvc == nil ||
|
if existingTSSvc == nil ||
|
||||||
!reflect.DeepEqual(tsSvc.Tags, existingTSSvc.Tags) ||
|
!reflect.DeepEqual(tsSvc.Tags, existingTSSvc.Tags) ||
|
||||||
!ownersAreSetAndEqual(tsSvc, existingTSSvc) {
|
!ownersAreSetAndEqual(tsSvc, *existingTSSvc) {
|
||||||
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
logger.Infof("Ensuring Tailscale Service exists and is up to date")
|
||||||
if err := tsClient.CreateOrUpdateVIPService(ctx, tsSvc); err != nil {
|
if err = tsClient.VIPServices().CreateOrUpdate(ctx, tsSvc); err != nil {
|
||||||
return false, fmt.Errorf("error creating Tailscale Service: %w", err)
|
return false, fmt.Errorf("error creating Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
existingTSSvc = tsSvc
|
|
||||||
|
existingTSSvc = &tsSvc
|
||||||
}
|
}
|
||||||
|
|
||||||
cm, cfgs, err := ingressSvcsConfigs(ctx, r.Client, pg.Name, r.tsNamespace)
|
cm, cfgs, err := ingressSvcsConfigs(ctx, r.Client, pg.Name, r.tsNamespace)
|
||||||
@@ -266,12 +267,12 @@ func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingTSSvc.Addrs == nil {
|
if len(existingTSSvc.Addrs) == 0 {
|
||||||
existingTSSvc, err = tsClient.GetVIPService(ctx, tsSvc.Name)
|
existingTSSvc, err = tsClient.VIPServices().Get(ctx, tsSvc.Name)
|
||||||
if err != nil {
|
switch {
|
||||||
|
case err != nil:
|
||||||
return false, fmt.Errorf("error getting Tailscale Service: %w", err)
|
return false, fmt.Errorf("error getting Tailscale Service: %w", err)
|
||||||
}
|
case len(existingTSSvc.Addrs) == 0:
|
||||||
if existingTSSvc.Addrs == nil {
|
|
||||||
// TODO(irbekrm): this should be a retry
|
// TODO(irbekrm): this should be a retry
|
||||||
return false, fmt.Errorf("unexpected: Tailscale Service addresses not populated")
|
return false, fmt.Errorf("unexpected: Tailscale Service addresses not populated")
|
||||||
}
|
}
|
||||||
@@ -374,7 +375,7 @@ func (r *HAServiceReconciler) maybeProvision(ctx context.Context, hostname strin
|
|||||||
// Service is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
|
// Service is being deleted or is unexposed. The cleanup is safe for a multi-cluster setup- the Tailscale Service is only
|
||||||
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
|
// deleted if it does not contain any other owner references. If it does the cleanup only removes the owner reference
|
||||||
// corresponding to this Service.
|
// corresponding to this Service.
|
||||||
func (r *HAServiceReconciler) maybeCleanup(ctx context.Context, hostname string, svc *corev1.Service, logger *zap.SugaredLogger, tsClient tsClient) (svcChanged bool, err error) {
|
func (r *HAServiceReconciler) maybeCleanup(ctx context.Context, hostname string, svc *corev1.Service, logger *zap.SugaredLogger, tsClient tsclient.Client) (svcChanged bool, err error) {
|
||||||
logger.Debugf("Ensuring any resources for Service are cleaned up")
|
logger.Debugf("Ensuring any resources for Service are cleaned up")
|
||||||
ix := slices.Index(svc.Finalizers, svcPGFinalizerName)
|
ix := slices.Index(svc.Finalizers, svcPGFinalizerName)
|
||||||
if ix < 0 {
|
if ix < 0 {
|
||||||
@@ -392,7 +393,7 @@ func (r *HAServiceReconciler) maybeCleanup(ctx context.Context, hostname string,
|
|||||||
|
|
||||||
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
serviceName := tailcfg.ServiceName("svc:" + hostname)
|
||||||
// 1. Clean up the Tailscale Service.
|
// 1. Clean up the Tailscale Service.
|
||||||
svcChanged, err = cleanupTailscaleService(ctx, tsClient, serviceName, r.operatorID, logger)
|
svcChanged, err = cleanupTailscaleService(ctx, tsClient, serviceName.String(), r.operatorID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error deleting Tailscale Service: %w", err)
|
return false, fmt.Errorf("error deleting Tailscale Service: %w", err)
|
||||||
}
|
}
|
||||||
@@ -425,7 +426,7 @@ func (r *HAServiceReconciler) maybeCleanup(ctx context.Context, hostname string,
|
|||||||
|
|
||||||
// Tailscale Services that are associated with the provided ProxyGroup and no longer managed this operator's instance are deleted, if not owned by other operator instances, else the owner reference is cleaned up.
|
// Tailscale Services that are associated with the provided ProxyGroup and no longer managed this operator's instance are deleted, if not owned by other operator instances, else the owner reference is cleaned up.
|
||||||
// Returns true if the operation resulted in existing Tailscale Service updates (owner reference removal).
|
// Returns true if the operation resulted in existing Tailscale Service updates (owner reference removal).
|
||||||
func (r *HAServiceReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyGroupName string, logger *zap.SugaredLogger, tsClient tsClient) (svcsChanged bool, err error) {
|
func (r *HAServiceReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyGroupName string, logger *zap.SugaredLogger, tsClient tsclient.Client) (svcsChanged bool, err error) {
|
||||||
cm, config, err := ingressSvcsConfigs(ctx, r.Client, proxyGroupName, r.tsNamespace)
|
cm, config, err := ingressSvcsConfigs(ctx, r.Client, proxyGroupName, r.tsNamespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to get ingress service config: %s", err)
|
return false, fmt.Errorf("failed to get ingress service config: %s", err)
|
||||||
@@ -453,7 +454,7 @@ func (r *HAServiceReconciler) maybeCleanupProxyGroup(ctx context.Context, proxyG
|
|||||||
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
|
return false, fmt.Errorf("failed to update tailscaled config services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcsChanged, err = cleanupTailscaleService(ctx, tsClient, tailcfg.ServiceName(tsSvcName), r.operatorID, logger)
|
svcsChanged, err = cleanupTailscaleService(ctx, tsClient, tsSvcName, r.operatorID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("deleting Tailscale Service %q: %w", tsSvcName, err)
|
return false, fmt.Errorf("deleting Tailscale Service %q: %w", tsSvcName, err)
|
||||||
}
|
}
|
||||||
@@ -517,29 +518,28 @@ func (r *HAServiceReconciler) shouldExposeClusterIP(svc *corev1.Service) bool {
|
|||||||
// If a Tailscale Service is found, but contains other owner references, only removes this operator's owner reference.
|
// If a Tailscale Service is found, but contains other owner references, only removes this operator's owner reference.
|
||||||
// If a Tailscale Service by the given name is not found or does not contain this operator's owner reference, do nothing.
|
// If a Tailscale Service by the given name is not found or does not contain this operator's owner reference, do nothing.
|
||||||
// It returns true if an existing Tailscale Service was updated to remove owner reference, as well as any error that occurred.
|
// It returns true if an existing Tailscale Service was updated to remove owner reference, as well as any error that occurred.
|
||||||
func cleanupTailscaleService(ctx context.Context, tsClient tsClient, name tailcfg.ServiceName, operatorID string, logger *zap.SugaredLogger) (updated bool, err error) {
|
func cleanupTailscaleService(ctx context.Context, tsClient tsclient.Client, name string, operatorID string, logger *zap.SugaredLogger) (updated bool, err error) {
|
||||||
svc, err := tsClient.GetVIPService(ctx, name)
|
svc, err := tsClient.VIPServices().Get(ctx, name)
|
||||||
if err != nil {
|
switch {
|
||||||
errResp, ok := errors.AsType[tailscale.ErrResponse](err)
|
case tailscale.IsNotFound(err):
|
||||||
if ok && errResp.Status == http.StatusNotFound {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
case err != nil:
|
||||||
if !ok {
|
return false, fmt.Errorf("unexpected error getting Tailscale Service %q: %w", name, err)
|
||||||
return false, fmt.Errorf("unexpected error getting Tailscale Service %q: %w", name.String(), err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, fmt.Errorf("error getting Tailscale Service: %w", err)
|
|
||||||
}
|
|
||||||
if svc == nil {
|
if svc == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
o, err := parseOwnerAnnotation(svc)
|
o, err := parseOwnerAnnotation(svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error parsing Tailscale Service owner annotation: %w", err)
|
return false, fmt.Errorf("error parsing Tailscale Service owner annotation: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if o == nil || len(o.OwnerRefs) == 0 {
|
if o == nil || len(o.OwnerRefs) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comparing with the operatorID only means that we will not be able to
|
// Comparing with the operatorID only means that we will not be able to
|
||||||
// clean up Tailscale Services in cases where the operator was deleted from the
|
// clean up Tailscale Services in cases where the operator was deleted from the
|
||||||
// cluster before deleting the Ingress. Perhaps the comparison could be
|
// cluster before deleting the Ingress. Perhaps the comparison could be
|
||||||
@@ -550,18 +550,22 @@ func cleanupTailscaleService(ctx context.Context, tsClient tsClient, name tailcf
|
|||||||
if ix == -1 {
|
if ix == -1 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(o.OwnerRefs) == 1 {
|
if len(o.OwnerRefs) == 1 {
|
||||||
logger.Infof("Deleting Tailscale Service %q", name)
|
logger.Infof("Deleting Tailscale Service %q", name)
|
||||||
return false, tsClient.DeleteVIPService(ctx, name)
|
return false, tsClient.VIPServices().Delete(ctx, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
o.OwnerRefs = slices.Delete(o.OwnerRefs, ix, ix+1)
|
o.OwnerRefs = slices.Delete(o.OwnerRefs, ix, ix+1)
|
||||||
logger.Infof("Updating Tailscale Service %q", name)
|
logger.Infof("Updating Tailscale Service %q", name)
|
||||||
json, err := json.Marshal(o)
|
|
||||||
|
data, err := json.Marshal(o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error marshalling updated Tailscale Service owner reference: %w", err)
|
return false, fmt.Errorf("error marshalling updated Tailscale Service owner reference: %w", err)
|
||||||
}
|
}
|
||||||
svc.Annotations[ownerAnnotation] = string(json)
|
|
||||||
return true, tsClient.CreateOrUpdateVIPService(ctx, svc)
|
svc.Annotations[ownerAnnotation] = string(data)
|
||||||
|
return true, tsClient.VIPServices().CreateOrUpdate(ctx, *svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HAServiceReconciler) backendRoutesSetup(ctx context.Context, serviceName, replicaName string, wantsCfg *ingressservices.Config, logger *zap.SugaredLogger) (bool, error) {
|
func (r *HAServiceReconciler) backendRoutesSetup(ctx context.Context, serviceName, replicaName string, wantsCfg *ingressservices.Config, logger *zap.SugaredLogger) (bool, error) {
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/ingressservices"
|
"tailscale.com/kube/ingressservices"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestServicePGReconciler(t *testing.T) {
|
func TestServicePGReconciler(t *testing.T) {
|
||||||
@@ -102,11 +102,11 @@ func TestServicePGReconciler_UpdateHostname(t *testing.T) {
|
|||||||
verifyTailscaleService(t, ft, fmt.Sprintf("svc:%s", hostname), []string{"do-not-validate"})
|
verifyTailscaleService(t, ft, fmt.Sprintf("svc:%s", hostname), []string{"do-not-validate"})
|
||||||
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:%s", hostname)})
|
verifyTailscaledConfig(t, fc, "test-pg", []string{fmt.Sprintf("svc:%s", hostname)})
|
||||||
|
|
||||||
_, err := ft.GetVIPService(context.Background(), tailcfg.ServiceName(fmt.Sprintf("svc:default-%s", svc.Name)))
|
_, err := ft.VIPServices().Get(context.Background(), fmt.Sprintf("svc:default-%s", svc.Name))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("svc:default-%s not cleaned up", svc.Name)
|
t.Fatalf("svc:default-%s not cleaned up", svc.Name)
|
||||||
}
|
}
|
||||||
if !isErrorTailscaleServiceNotFound(err) {
|
if !tailscale.IsNotFound(err) {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,9 @@ func setupServiceTest(t *testing.T) (*HAServiceReconciler, *corev1.Secret, clien
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ft := &fakeTSClient{}
|
ft := &fakeTSClient{
|
||||||
|
vipServices: make(map[string]tailscale.VIPService),
|
||||||
|
}
|
||||||
zl, err := zap.NewDevelopment()
|
zl, err := zap.NewDevelopment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -197,7 +199,7 @@ func setupServiceTest(t *testing.T) (*HAServiceReconciler, *corev1.Secret, clien
|
|||||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||||
svcPGR := &HAServiceReconciler{
|
svcPGR := &HAServiceReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
tsNamespace: "operator-ns",
|
tsNamespace: "operator-ns",
|
||||||
@@ -275,22 +277,22 @@ func TestServicePGReconciler_MultiCluster(t *testing.T) {
|
|||||||
if i == 0 {
|
if i == 0 {
|
||||||
ft = fti
|
ft = fti
|
||||||
} else {
|
} else {
|
||||||
pgr.tsClient = ft
|
pgr.clients = tsclient.NewProvider(ft)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc, _ := setupTestService(t, "test-multi-cluster", "", "4.3.2.1", fc, stateSecret)
|
svc, _ := setupTestService(t, "test-multi-cluster", "", "4.3.2.1", fc, stateSecret)
|
||||||
expectReconciled(t, pgr, "default", svc.Name)
|
expectReconciled(t, pgr, "default", svc.Name)
|
||||||
|
|
||||||
tsSvcs, err := ft.ListVIPServices(context.Background())
|
tsSvcs, err := ft.VIPServices().List(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("getting Tailscale Service: %v", err)
|
t.Fatalf("getting Tailscale Service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tsSvcs.VIPServices) != 1 {
|
if len(tsSvcs) != 1 {
|
||||||
t.Fatalf("unexpected number of Tailscale Services (%d)", len(tsSvcs.VIPServices))
|
t.Fatalf("unexpected number of Tailscale Services (%d)", len(tsSvcs))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, svc := range tsSvcs.VIPServices {
|
for _, svc := range tsSvcs {
|
||||||
t.Logf("found Tailscale Service with name %q", svc.Name)
|
t.Logf("found Tailscale Service with name %q", svc.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,9 +324,9 @@ func TestIgnoreRegularService(t *testing.T) {
|
|||||||
|
|
||||||
verifyTailscaledConfig(t, fc, "test-pg", nil)
|
verifyTailscaledConfig(t, fc, "test-pg", nil)
|
||||||
|
|
||||||
tsSvcs, err := ft.ListVIPServices(context.Background())
|
tsSvcs, err := ft.VIPServices().List(t.Context())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if len(tsSvcs.VIPServices) > 0 {
|
if len(tsSvcs) > 0 {
|
||||||
t.Fatal("unexpected Tailscale Services found")
|
t.Fatal("unexpected Tailscale Services found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
)
|
)
|
||||||
@@ -47,7 +49,7 @@ func TestService_DefaultProxyClassInitiallyNotReady(t *testing.T) {
|
|||||||
Client: fc,
|
Client: fc,
|
||||||
ssr: &tailscaleSTSReconciler{
|
ssr: &tailscaleSTSReconciler{
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: ft,
|
clients: tsclient.NewProvider(ft),
|
||||||
defaultTags: []string{"tag:k8s"},
|
defaultTags: []string{"tag:k8s"},
|
||||||
operatorNamespace: "operator-ns",
|
operatorNamespace: "operator-ns",
|
||||||
proxyImage: "tailscale/tailscale",
|
proxyImage: "tailscale/tailscale",
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build !plan9
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
|
||||||
operatorutils "tailscale.com/k8s-operator"
|
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
||||||
)
|
|
||||||
|
|
||||||
func clientForTailnet(ctx context.Context, cl client.Client, namespace, name string) (tsClient, string, error) {
|
|
||||||
var tn tsapi.Tailnet
|
|
||||||
if err := cl.Get(ctx, client.ObjectKey{Name: name}, &tn); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to get tailnet %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !operatorutils.TailnetIsReady(&tn) {
|
|
||||||
return nil, "", fmt.Errorf("tailnet %q is not ready", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
var secret corev1.Secret
|
|
||||||
if err := cl.Get(ctx, client.ObjectKey{Name: tn.Spec.Credentials.SecretName, Namespace: namespace}, &secret); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to get Secret %q in namespace %q: %w", tn.Spec.Credentials.SecretName, namespace, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURL := ipn.DefaultControlURL
|
|
||||||
if tn.Spec.LoginURL != "" {
|
|
||||||
baseURL = tn.Spec.LoginURL
|
|
||||||
}
|
|
||||||
|
|
||||||
credentials := clientcredentials.Config{
|
|
||||||
ClientID: string(secret.Data["client_id"]),
|
|
||||||
ClientSecret: string(secret.Data["client_secret"]),
|
|
||||||
TokenURL: baseURL + "/api/v2/oauth/token",
|
|
||||||
}
|
|
||||||
|
|
||||||
source := credentials.TokenSource(ctx)
|
|
||||||
httpClient := oauth2.NewClient(ctx, source)
|
|
||||||
|
|
||||||
ts := tailscale.NewClient(defaultTailnet, nil)
|
|
||||||
ts.UserAgent = "tailscale-k8s-operator"
|
|
||||||
ts.HTTPClient = httpClient
|
|
||||||
ts.BaseURL = baseURL
|
|
||||||
|
|
||||||
return ts, baseURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientFromProxyGroup(ctx context.Context, cl client.Client, pg *tsapi.ProxyGroup, namespace string, def tsClient) (tsClient, error) {
|
|
||||||
if pg.Spec.Tailnet == "" {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tailscaleClient, _, err := clientForTailnet(ctx, cl, namespace, pg.Spec.Tailnet)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return tailscaleClient, nil
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path"
|
"path"
|
||||||
@@ -31,12 +32,12 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -836,12 +837,131 @@ func expectEvents(t *testing.T, rec *record.FakeRecorder, wantsEvents []string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeTSClient struct {
|
type (
|
||||||
|
fakeTSClient struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
|
loginURL string
|
||||||
keyRequests []tailscale.KeyCapabilities
|
keyRequests []tailscale.KeyCapabilities
|
||||||
deleted []string
|
deleted []string
|
||||||
vipServices map[tailcfg.ServiceName]*tailscale.VIPService
|
devices []tailscale.Device
|
||||||
|
vipServices map[string]tailscale.VIPService
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeVIPServices struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
vipServices map[string]tailscale.VIPService
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeKeys struct {
|
||||||
|
keyRequests *[]tailscale.KeyCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
fakeDevices struct {
|
||||||
|
deleted *[]string
|
||||||
|
devices *[]tailscale.Device
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *fakeTSClient) VIPServices() tsclient.VIPServiceResource {
|
||||||
|
return &fakeVIPServices{
|
||||||
|
vipServices: c.vipServices,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *fakeVIPServices) List(_ context.Context) ([]tailscale.VIPService, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(m.vipServices) == 0 {
|
||||||
|
return nil, tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Collect(maps.Values(m.vipServices)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeVIPServices) Delete(_ context.Context, name string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := m.vipServices[name]; !ok {
|
||||||
|
return tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(m.vipServices, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeVIPServices) Get(_ context.Context, name string) (*tailscale.VIPService, error) {
|
||||||
|
if svc, ok := m.vipServices[name]; ok {
|
||||||
|
return &svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeVIPServices) CreateOrUpdate(_ context.Context, svc tailscale.VIPService) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if svc.Addrs == nil {
|
||||||
|
svc.Addrs = []string{vipTestIP}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.vipServices[svc.Name] = svc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeTSClient) Devices() tsclient.DeviceResource {
|
||||||
|
return &fakeDevices{
|
||||||
|
deleted: &c.deleted,
|
||||||
|
devices: &c.devices,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeDevices) Delete(_ context.Context, id string) error {
|
||||||
|
*m.deleted = append(*m.deleted, id)
|
||||||
|
|
||||||
|
return tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeDevices) List(_ context.Context, _ ...tailscale.ListDevicesOptions) ([]tailscale.Device, error) {
|
||||||
|
return *m.devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeDevices) Get(_ context.Context, id string) (*tailscale.Device, error) {
|
||||||
|
if m.devices == nil {
|
||||||
|
return nil, tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dev := range *m.devices {
|
||||||
|
if dev.ID == id {
|
||||||
|
return &dev, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, tailscale.APIError{Status: http.StatusNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeTSClient) Keys() tsclient.KeyResource {
|
||||||
|
return &fakeKeys{
|
||||||
|
keyRequests: &c.keyRequests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeKeys) CreateAuthKey(_ context.Context, ckr tailscale.CreateKeyRequest) (*tailscale.Key, error) {
|
||||||
|
*m.keyRequests = append(*m.keyRequests, ckr.Capabilities)
|
||||||
|
|
||||||
|
return &tailscale.Key{Key: "new-authkey"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeKeys) List(_ context.Context, _ bool) ([]tailscale.Key, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeTSClient) LoginURL() string {
|
||||||
|
return c.loginURL
|
||||||
|
}
|
||||||
|
|
||||||
type fakeTSNetServer struct {
|
type fakeTSNetServer struct {
|
||||||
certDomains []string
|
certDomains []string
|
||||||
}
|
}
|
||||||
@@ -850,48 +970,6 @@ func (f *fakeTSNetServer) CertDomains() []string {
|
|||||||
return f.certDomains
|
return f.certDomains
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *fakeTSClient) CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.keyRequests = append(c.keyRequests, caps)
|
|
||||||
k := &tailscale.Key{
|
|
||||||
ID: "key",
|
|
||||||
Created: time.Now(),
|
|
||||||
Capabilities: caps,
|
|
||||||
}
|
|
||||||
return "new-authkey", k, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error) {
|
|
||||||
return &tailscale.Device{
|
|
||||||
DeviceID: deviceID,
|
|
||||||
Hostname: "hostname-" + deviceID,
|
|
||||||
Addresses: []string{
|
|
||||||
"1.2.3.4",
|
|
||||||
"::1",
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) DeleteDevice(ctx context.Context, deviceID string) error {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
c.deleted = append(c.deleted, deviceID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) KeyRequests() []tailscale.KeyCapabilities {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
return c.keyRequests
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) Deleted() []string {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
return c.deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeResourceReqs(sts *appsv1.StatefulSet) {
|
func removeResourceReqs(sts *appsv1.StatefulSet) {
|
||||||
if sts != nil {
|
if sts != nil {
|
||||||
sts.Spec.Template.Spec.Resources = nil
|
sts.Spec.Template.Spec.Resources = nil
|
||||||
@@ -935,53 +1013,3 @@ func removeAuthKeyIfExistsModifier(t *testing.T) func(s *corev1.Secret) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *fakeTSClient) GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.vipServices == nil {
|
|
||||||
return nil, tailscale.ErrResponse{Status: http.StatusNotFound}
|
|
||||||
}
|
|
||||||
svc, ok := c.vipServices[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, tailscale.ErrResponse{Status: http.StatusNotFound}
|
|
||||||
}
|
|
||||||
return svc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error) {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.vipServices == nil {
|
|
||||||
return nil, &tailscale.ErrResponse{Status: http.StatusNotFound}
|
|
||||||
}
|
|
||||||
result := &tailscale.VIPServiceList{}
|
|
||||||
for _, svc := range c.vipServices {
|
|
||||||
result.VIPServices = append(result.VIPServices, *svc)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.vipServices == nil {
|
|
||||||
c.vipServices = make(map[tailcfg.ServiceName]*tailscale.VIPService)
|
|
||||||
}
|
|
||||||
|
|
||||||
if svc.Addrs == nil {
|
|
||||||
svc.Addrs = []string{vipTestIP}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.vipServices[svc.Name] = svc
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fakeTSClient) DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error {
|
|
||||||
c.Lock()
|
|
||||||
defer c.Unlock()
|
|
||||||
if c.vipServices != nil {
|
|
||||||
delete(c.vipServices, name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,15 +16,12 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
"golang.org/x/oauth2/clientcredentials"
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultTailnet is a value that can be used in Tailscale API calls instead of tailnet name to indicate that the API
|
|
||||||
// call should be performed on the default tailnet for the provided credentials.
|
|
||||||
const (
|
const (
|
||||||
defaultTailnet = "-"
|
|
||||||
oidcJWTPath = "/var/run/secrets/tailscale/serviceaccount/token"
|
oidcJWTPath = "/var/run/secrets/tailscale/serviceaccount/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,24 +31,31 @@ func newTSClient(logger *zap.SugaredLogger, clientID, clientIDPath, clientSecret
|
|||||||
baseURL = loginServer
|
baseURL = loginServer
|
||||||
}
|
}
|
||||||
|
|
||||||
var httpClient *http.Client
|
base, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &tailscale.Client{
|
||||||
|
UserAgent: "tailscale-k8s-operator",
|
||||||
|
BaseURL: base,
|
||||||
|
}
|
||||||
|
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
// Use static client credentials mounted to disk.
|
// Use static client credentials mounted to disk.
|
||||||
id, err := os.ReadFile(clientIDPath)
|
clientIDBytes, err := os.ReadFile(clientIDPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
return nil, fmt.Errorf("error reading client ID %q: %w", clientIDPath, err)
|
||||||
}
|
}
|
||||||
secret, err := os.ReadFile(clientSecretPath)
|
clientSecretBytes, err := os.ReadFile(clientSecretPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
return nil, fmt.Errorf("reading client secret %q: %w", clientSecretPath, err)
|
||||||
}
|
}
|
||||||
credentials := clientcredentials.Config{
|
|
||||||
ClientID: string(id),
|
client.Auth = &tailscale.OAuth{
|
||||||
ClientSecret: string(secret),
|
ClientID: string(clientIDBytes),
|
||||||
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token"),
|
ClientSecret: string(clientSecretBytes),
|
||||||
}
|
}
|
||||||
tokenSrc := credentials.TokenSource(context.Background())
|
|
||||||
httpClient = oauth2.NewClient(context.Background(), tokenSrc)
|
|
||||||
} else {
|
} else {
|
||||||
// Use workload identity federation.
|
// Use workload identity federation.
|
||||||
tokenSrc := &jwtTokenSource{
|
tokenSrc := &jwtTokenSource{
|
||||||
@@ -62,34 +66,21 @@ func newTSClient(logger *zap.SugaredLogger, clientID, clientIDPath, clientSecret
|
|||||||
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token-exchange"),
|
TokenURL: fmt.Sprintf("%s%s", baseURL, "/api/v2/oauth/token-exchange"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
httpClient = &http.Client{
|
|
||||||
Transport: &oauth2.Transport{
|
client.Auth = &tailscale.IdentityFederation{
|
||||||
Source: tokenSrc,
|
ClientID: clientID,
|
||||||
|
IDTokenFunc: func() (string, error) {
|
||||||
|
token, err := tokenSrc.Token()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.AccessToken, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := tailscale.NewClient(defaultTailnet, nil)
|
return client, nil
|
||||||
c.UserAgent = "tailscale-k8s-operator"
|
|
||||||
c.HTTPClient = httpClient
|
|
||||||
if loginServer != "" {
|
|
||||||
c.BaseURL = loginServer
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type tsClient interface {
|
|
||||||
CreateKey(ctx context.Context, caps tailscale.KeyCapabilities) (string, *tailscale.Key, error)
|
|
||||||
Device(ctx context.Context, deviceID string, fields *tailscale.DeviceFieldsOpts) (*tailscale.Device, error)
|
|
||||||
DeleteDevice(ctx context.Context, nodeStableID string) error
|
|
||||||
// GetVIPService is a method for getting a Tailscale Service. VIPService is the original name for Tailscale Service.
|
|
||||||
GetVIPService(ctx context.Context, name tailcfg.ServiceName) (*tailscale.VIPService, error)
|
|
||||||
// ListVIPServices is a method for listing all Tailscale Services. VIPService is the original name for Tailscale Service.
|
|
||||||
ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error)
|
|
||||||
// CreateOrUpdateVIPService is a method for creating or updating a Tailscale Service.
|
|
||||||
CreateOrUpdateVIPService(ctx context.Context, svc *tailscale.VIPService) error
|
|
||||||
// DeleteVIPService is a method for deleting a Tailscale Service.
|
|
||||||
DeleteVIPService(ctx context.Context, name tailcfg.ServiceName) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// jwtTokenSource implements the [oauth2.TokenSource] interface, but with the
|
// jwtTokenSource implements the [oauth2.TokenSource] interface, but with the
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
|
||||||
|
|
||||||
//go:build !plan9
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewStaticClient(t *testing.T) {
|
|
||||||
const (
|
|
||||||
clientIDFile = "client-id"
|
|
||||||
clientSecretFile = "client-secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
tmp := t.TempDir()
|
|
||||||
clientIDPath := filepath.Join(tmp, clientIDFile)
|
|
||||||
if err := os.WriteFile(clientIDPath, []byte("test-client-id"), 0600); err != nil {
|
|
||||||
t.Fatalf("error writing test file %q: %v", clientIDPath, err)
|
|
||||||
}
|
|
||||||
clientSecretPath := filepath.Join(tmp, clientSecretFile)
|
|
||||||
if err := os.WriteFile(clientSecretPath, []byte("test-client-secret"), 0600); err != nil {
|
|
||||||
t.Fatalf("error writing test file %q: %v", clientSecretPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := testAPI(t, 3600)
|
|
||||||
cl, err := newTSClient(zap.NewNop().Sugar(), "", clientIDPath, clientSecretPath, srv.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error creating Tailscale client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := cl.HTTPClient.Get(srv.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error making test API call: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error reading response body: %v", err)
|
|
||||||
}
|
|
||||||
want := "Bearer " + testToken("/api/v2/oauth/token", "test-client-id", "test-client-secret", "")
|
|
||||||
if string(got) != want {
|
|
||||||
t.Errorf("got %q; want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewWorkloadIdentityClient(t *testing.T) {
|
|
||||||
// 5 seconds is within expiryDelta leeway, so the access token will
|
|
||||||
// immediately be considered expired and get refreshed on each access.
|
|
||||||
srv := testAPI(t, 5)
|
|
||||||
cl, err := newTSClient(zap.NewNop().Sugar(), "test-client-id", "", "", srv.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error creating Tailscale client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify the path where the JWT will be read from.
|
|
||||||
oauth2Transport, ok := cl.HTTPClient.Transport.(*oauth2.Transport)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected oauth2.Transport, got %T", cl.HTTPClient.Transport)
|
|
||||||
}
|
|
||||||
jwtTokenSource, ok := oauth2Transport.Source.(*jwtTokenSource)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected jwtTokenSource, got %T", oauth2Transport.Source)
|
|
||||||
}
|
|
||||||
tmp := t.TempDir()
|
|
||||||
jwtPath := filepath.Join(tmp, "token")
|
|
||||||
jwtTokenSource.jwtPath = jwtPath
|
|
||||||
|
|
||||||
for _, jwt := range []string{"test-jwt", "updated-test-jwt"} {
|
|
||||||
if err := os.WriteFile(jwtPath, []byte(jwt), 0600); err != nil {
|
|
||||||
t.Fatalf("error writing test file %q: %v", jwtPath, err)
|
|
||||||
}
|
|
||||||
resp, err := cl.HTTPClient.Get(srv.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error making test API call: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
got, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error reading response body: %v", err)
|
|
||||||
}
|
|
||||||
if want := "Bearer " + testToken("/api/v2/oauth/token-exchange", "test-client-id", "", jwt); string(got) != want {
|
|
||||||
t.Errorf("got %q; want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAPI(t *testing.T, expirationSeconds int) *httptest.Server {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
t.Logf("test server got request: %s %s", r.Method, r.URL.Path)
|
|
||||||
switch r.URL.Path {
|
|
||||||
case "/api/v2/oauth/token", "/api/v2/oauth/token-exchange":
|
|
||||||
id, secret, ok := r.BasicAuth()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("missing or invalid basic auth")
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"access_token": testToken(r.URL.Path, id, secret, r.FormValue("jwt")),
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": expirationSeconds,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("error writing response: %v", err)
|
|
||||||
}
|
|
||||||
case "/":
|
|
||||||
// Echo back the authz header for test assertions.
|
|
||||||
_, err := w.Write([]byte(r.Header.Get("Authorization")))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error writing response: %v", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func testToken(path, id, secret, jwt string) string {
|
|
||||||
return fmt.Sprintf("%s|%s|%s|%s", path, id, secret, jwt)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,10 +29,11 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/client/tailscale"
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
@@ -60,9 +60,8 @@ type RecorderReconciler struct {
|
|||||||
log *zap.SugaredLogger
|
log *zap.SugaredLogger
|
||||||
recorder record.EventRecorder
|
recorder record.EventRecorder
|
||||||
clock tstime.Clock
|
clock tstime.Clock
|
||||||
|
clients ClientProvider
|
||||||
tsNamespace string
|
tsNamespace string
|
||||||
tsClient tsClient
|
|
||||||
loginServer string
|
|
||||||
|
|
||||||
mu sync.Mutex // protects following
|
mu sync.Mutex // protects following
|
||||||
recorders set.Slice[types.UID] // for recorders gauge
|
recorders set.Slice[types.UID] // for recorders gauge
|
||||||
@@ -99,7 +98,7 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
|||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tailscaleClient, loginUrl, err := r.getClientAndLoginURL(ctx, tsr.Spec.Tailnet)
|
tsClient, err := r.clients.For(tsr.Spec.Tailnet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderTailnetUnavailable, err.Error())
|
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderTailnetUnavailable, err.Error())
|
||||||
}
|
}
|
||||||
@@ -112,7 +111,7 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
|||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if done, err := r.maybeCleanup(ctx, tsr, tailscaleClient); err != nil {
|
if done, err := r.maybeCleanup(ctx, tsr, tsClient); err != nil {
|
||||||
return reconcile.Result{}, err
|
return reconcile.Result{}, err
|
||||||
} else if !done {
|
} else if !done {
|
||||||
logger.Debugf("Recorder resource cleanup not yet finished, will retry...")
|
logger.Debugf("Recorder resource cleanup not yet finished, will retry...")
|
||||||
@@ -144,7 +143,7 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
|||||||
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderInvalid, message)
|
return setStatusReady(tsr, metav1.ConditionFalse, reasonRecorderInvalid, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = r.maybeProvision(ctx, tailscaleClient, loginUrl, tsr); err != nil {
|
if err = r.maybeProvision(ctx, tsClient, tsr); err != nil {
|
||||||
reason := reasonRecorderCreationFailed
|
reason := reasonRecorderCreationFailed
|
||||||
message := fmt.Sprintf("failed creating Recorder: %s", err)
|
message := fmt.Sprintf("failed creating Recorder: %s", err)
|
||||||
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
if strings.Contains(err.Error(), optimisticLockErrorMsg) {
|
||||||
@@ -162,30 +161,7 @@ func (r *RecorderReconciler) Reconcile(ctx context.Context, req reconcile.Reques
|
|||||||
return setStatusReady(tsr, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated)
|
return setStatusReady(tsr, metav1.ConditionTrue, reasonRecorderCreated, reasonRecorderCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientAndLoginURL returns the appropriate Tailscale client and resolved login URL
|
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
||||||
// for the given tailnet name. If no tailnet is specified, returns the default client
|
|
||||||
// and login server. Applies fallback to the operator's login server if the tailnet
|
|
||||||
// doesn't specify a custom login URL.
|
|
||||||
func (r *RecorderReconciler) getClientAndLoginURL(ctx context.Context, tailnetName string) (tsClient,
|
|
||||||
string, error) {
|
|
||||||
if tailnetName == "" {
|
|
||||||
return r.tsClient, r.loginServer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tc, loginUrl, err := clientForTailnet(ctx, r.Client, r.tsNamespace, tailnetName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fallback if tailnet doesn't specify custom login URL
|
|
||||||
if loginUrl == "" {
|
|
||||||
loginUrl = r.loginServer
|
|
||||||
}
|
|
||||||
|
|
||||||
return tc, loginUrl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RecorderReconciler) maybeProvision(ctx context.Context, tailscaleClient tsClient, loginUrl string, tsr *tsapi.Recorder) error {
|
|
||||||
logger := r.logger(tsr.Name)
|
logger := r.logger(tsr.Name)
|
||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -193,7 +169,7 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tailscaleClient
|
|||||||
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
gaugeRecorderResources.Set(int64(r.recorders.Len()))
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
if err := r.ensureAuthSecretsCreated(ctx, tailscaleClient, tsr); err != nil {
|
if err := r.ensureAuthSecretsCreated(ctx, tsClient, tsr); err != nil {
|
||||||
return fmt.Errorf("error creating secrets: %w", err)
|
return fmt.Errorf("error creating secrets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +228,7 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tailscaleClient
|
|||||||
return fmt.Errorf("error creating RoleBinding: %w", err)
|
return fmt.Errorf("error creating RoleBinding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ss := tsrStatefulSet(tsr, r.tsNamespace, loginUrl)
|
ss := tsrStatefulSet(tsr, r.tsNamespace, tsClient.LoginURL())
|
||||||
_, err = createOrUpdate(ctx, r.Client, r.tsNamespace, ss, func(s *appsv1.StatefulSet) {
|
_, err = createOrUpdate(ctx, r.Client, r.tsNamespace, ss, func(s *appsv1.StatefulSet) {
|
||||||
s.ObjectMeta.Labels = ss.ObjectMeta.Labels
|
s.ObjectMeta.Labels = ss.ObjectMeta.Labels
|
||||||
s.ObjectMeta.Annotations = ss.ObjectMeta.Annotations
|
s.ObjectMeta.Annotations = ss.ObjectMeta.Annotations
|
||||||
@@ -271,13 +247,13 @@ func (r *RecorderReconciler) maybeProvision(ctx context.Context, tailscaleClient
|
|||||||
|
|
||||||
// If we have scaled the recorder down, we will have dangling state secrets
|
// If we have scaled the recorder down, we will have dangling state secrets
|
||||||
// that we need to clean up.
|
// that we need to clean up.
|
||||||
if err = r.maybeCleanupSecrets(ctx, tailscaleClient, tsr); err != nil {
|
if err = r.maybeCleanupSecrets(ctx, tsClient, tsr); err != nil {
|
||||||
return fmt.Errorf("error cleaning up Secrets: %w", err)
|
return fmt.Errorf("error cleaning up Secrets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var devices []tsapi.RecorderTailnetDevice
|
var devices []tsapi.RecorderTailnetDevice
|
||||||
for replica := range replicas {
|
for replica := range replicas {
|
||||||
dev, ok, err := r.getDeviceInfo(ctx, tailscaleClient, tsr.Name, replica)
|
dev, ok, err := r.getDeviceInfo(ctx, tsClient, tsr.Name, replica)
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("failed to get device info: %w", err)
|
return fmt.Errorf("failed to get device info: %w", err)
|
||||||
@@ -342,7 +318,7 @@ func (r *RecorderReconciler) maybeCleanupServiceAccounts(ctx context.Context, ts
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecorderReconciler) maybeCleanupSecrets(ctx context.Context, tailscaleClient tsClient, tsr *tsapi.Recorder) error {
|
func (r *RecorderReconciler) maybeCleanupSecrets(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
||||||
options := []client.ListOption{
|
options := []client.ListOption{
|
||||||
client.InNamespace(r.tsNamespace),
|
client.InNamespace(r.tsNamespace),
|
||||||
client.MatchingLabels(tsrLabels("recorder", tsr.Name, nil)),
|
client.MatchingLabels(tsrLabels("recorder", tsr.Name, nil)),
|
||||||
@@ -382,11 +358,12 @@ func (r *RecorderReconciler) maybeCleanupSecrets(ctx context.Context, tailscaleC
|
|||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
r.log.Debugf("deleting device %s", devicePrefs.Config.NodeID)
|
r.log.Debugf("deleting device %s", devicePrefs.Config.NodeID)
|
||||||
err = tailscaleClient.DeleteDevice(ctx, string(devicePrefs.Config.NodeID))
|
err = tsClient.Devices().Delete(ctx, string(devicePrefs.Config.NodeID))
|
||||||
if errResp, ok := errors.AsType[*tailscale.ErrResponse](err); ok && errResp.Status == http.StatusNotFound {
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
// This device has possibly already been deleted in the admin console. So we can ignore this
|
// This device has possibly already been deleted in the admin console. So we can ignore this
|
||||||
// and move on to removing the secret.
|
// and move on to removing the secret.
|
||||||
} else if err != nil {
|
case err != nil:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +379,7 @@ func (r *RecorderReconciler) maybeCleanupSecrets(ctx context.Context, tailscaleC
|
|||||||
// maybeCleanup just deletes the device from the tailnet. All the kubernetes
|
// maybeCleanup just deletes the device from the tailnet. All the kubernetes
|
||||||
// resources linked to a Recorder will get cleaned up via owner references
|
// resources linked to a Recorder will get cleaned up via owner references
|
||||||
// (which we can use because they are all in the same namespace).
|
// (which we can use because they are all in the same namespace).
|
||||||
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder, tailscaleClient tsClient) (bool, error) {
|
func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Recorder, tsClient tsclient.Client) (bool, error) {
|
||||||
logger := r.logger(tsr.Name)
|
logger := r.logger(tsr.Name)
|
||||||
|
|
||||||
var replicas int32 = 1
|
var replicas int32 = 1
|
||||||
@@ -426,12 +403,12 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
|
|||||||
|
|
||||||
nodeID := string(devicePrefs.Config.NodeID)
|
nodeID := string(devicePrefs.Config.NodeID)
|
||||||
logger.Debugf("deleting device %s from control", nodeID)
|
logger.Debugf("deleting device %s from control", nodeID)
|
||||||
if err = tailscaleClient.DeleteDevice(ctx, nodeID); err != nil {
|
err = tsClient.Devices().Delete(ctx, nodeID)
|
||||||
if errResp, ok := errors.AsType[tailscale.ErrResponse](err); ok && errResp.Status == http.StatusNotFound {
|
switch {
|
||||||
|
case tailscale.IsNotFound(err):
|
||||||
logger.Debugf("device %s not found, likely because it has already been deleted from control", nodeID)
|
logger.Debugf("device %s not found, likely because it has already been deleted from control", nodeID)
|
||||||
continue
|
continue
|
||||||
}
|
case err != nil:
|
||||||
|
|
||||||
return false, fmt.Errorf("error deleting device: %w", err)
|
return false, fmt.Errorf("error deleting device: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +428,7 @@ func (r *RecorderReconciler) maybeCleanup(ctx context.Context, tsr *tsapi.Record
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tailscaleClient tsClient, tsr *tsapi.Recorder) error {
|
func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tsClient tsclient.Client, tsr *tsapi.Recorder) error {
|
||||||
var replicas int32 = 1
|
var replicas int32 = 1
|
||||||
if tsr.Spec.Replicas != nil {
|
if tsr.Spec.Replicas != nil {
|
||||||
replicas = *tsr.Spec.Replicas
|
replicas = *tsr.Spec.Replicas
|
||||||
@@ -479,7 +456,7 @@ func (r *RecorderReconciler) ensureAuthSecretsCreated(ctx context.Context, tails
|
|||||||
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
|
return fmt.Errorf("failed to get Secret %q: %w", key.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authKey, err := newAuthKey(ctx, tailscaleClient, tags.Stringify())
|
authKey, err := newAuthKey(ctx, tsClient, tags.Stringify())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -581,7 +558,7 @@ func getDevicePrefs(secret *corev1.Secret) (prefs prefs, ok bool, err error) {
|
|||||||
return prefs, ok, nil
|
return prefs, ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tailscaleClient tsClient, tsrName string, replica int32) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
|
func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tsClient tsclient.Client, tsrName string, replica int32) (d tsapi.RecorderTailnetDevice, ok bool, err error) {
|
||||||
secret, err := r.getStateSecret(ctx, tsrName, replica)
|
secret, err := r.getStateSecret(ctx, tsrName, replica)
|
||||||
if err != nil || secret == nil {
|
if err != nil || secret == nil {
|
||||||
return tsapi.RecorderTailnetDevice{}, false, err
|
return tsapi.RecorderTailnetDevice{}, false, err
|
||||||
@@ -595,7 +572,7 @@ func (r *RecorderReconciler) getDeviceInfo(ctx context.Context, tailscaleClient
|
|||||||
// TODO(tomhjp): The profile info doesn't include addresses, which is why we
|
// TODO(tomhjp): The profile info doesn't include addresses, which is why we
|
||||||
// need the API. Should maybe update tsrecorder to write IPs to the state
|
// need the API. Should maybe update tsrecorder to write IPs to the state
|
||||||
// Secret like containerboot does.
|
// Secret like containerboot does.
|
||||||
device, err := tailscaleClient.Device(ctx, string(prefs.Config.NodeID), nil)
|
device, err := tsClient.Devices().Get(ctx, string(prefs.Config.NodeID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tsapi.RecorderTailnetDevice{}, false, fmt.Errorf("failed to get device info from API: %w", err)
|
return tsapi.RecorderTailnetDevice{}, false, fmt.Errorf("failed to get device info from API: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import (
|
|||||||
"k8s.io/client-go/tools/record"
|
"k8s.io/client-go/tools/record"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
tsoperator "tailscale.com/k8s-operator"
|
tsoperator "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,18 +50,17 @@ func TestRecorder(t *testing.T) {
|
|||||||
WithObjects(tsr).
|
WithObjects(tsr).
|
||||||
WithStatusSubresource(tsr).
|
WithStatusSubresource(tsr).
|
||||||
Build()
|
Build()
|
||||||
tsClient := &fakeTSClient{}
|
tsClient := &fakeTSClient{loginURL: tsLoginServer}
|
||||||
zl, _ := zap.NewDevelopment()
|
zl, _ := zap.NewDevelopment()
|
||||||
fr := record.NewFakeRecorder(2)
|
fr := record.NewFakeRecorder(2)
|
||||||
cl := tstest.NewClock(tstest.ClockOpts{})
|
cl := tstest.NewClock(tstest.ClockOpts{})
|
||||||
reconciler := &RecorderReconciler{
|
reconciler := &RecorderReconciler{
|
||||||
tsNamespace: tsNamespace,
|
tsNamespace: tsNamespace,
|
||||||
Client: fc,
|
Client: fc,
|
||||||
tsClient: tsClient,
|
clients: tsclient.NewProvider(tsClient),
|
||||||
recorder: fr,
|
recorder: fr,
|
||||||
log: zl.Sugar(),
|
log: zl.Sugar(),
|
||||||
clock: cl,
|
clock: cl,
|
||||||
loginServer: tsLoginServer,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
|
t.Run("invalid_spec_gives_an_error_condition", func(t *testing.T) {
|
||||||
@@ -194,8 +195,8 @@ func TestRecorder(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("populate_node_info_in_state_secret_and_see_it_appear_in_status", func(t *testing.T) {
|
t.Run("populate_node_info_in_state_secret_and_see_it_appear_in_status", func(t *testing.T) {
|
||||||
|
|
||||||
const key = "profile-abc"
|
const key = "profile-abc"
|
||||||
|
|
||||||
for replica := range *tsr.Spec.Replicas {
|
for replica := range *tsr.Spec.Replicas {
|
||||||
bytes, err := json.Marshal(map[string]any{
|
bytes, err := json.Marshal(map[string]any{
|
||||||
"Config": map[string]any{
|
"Config": map[string]any{
|
||||||
@@ -218,6 +219,24 @@ func TestRecorder(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tsClient.devices = []tailscale.Device{
|
||||||
|
{
|
||||||
|
ID: "node-0",
|
||||||
|
Hostname: "hostname-node-0",
|
||||||
|
Addresses: []string{"1.2.3.4", "::1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "node-1",
|
||||||
|
Hostname: "hostname-node-1",
|
||||||
|
Addresses: []string{"1.2.3.4", "::1"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "node-2",
|
||||||
|
Hostname: "hostname-node-2",
|
||||||
|
Addresses: []string{"1.2.3.4", "::1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
expectReconciled(t, reconciler, "", tsr.Name)
|
expectReconciled(t, reconciler, "", tsr.Name)
|
||||||
tsr.Status.Devices = []tsapi.RecorderTailnetDevice{
|
tsr.Status.Devices = []tsapi.RecorderTailnetDevice{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
"tailscale.com/util/precompress"
|
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,10 +38,6 @@ func runBuildPkg() {
|
|||||||
|
|
||||||
runEsbuild(*buildOptions)
|
runEsbuild(*buildOptions)
|
||||||
|
|
||||||
if err := precompressWasm(); err != nil {
|
|
||||||
log.Fatalf("Could not pre-recompress wasm: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Generating types...\n")
|
log.Printf("Generating types...\n")
|
||||||
if err := runYarn("pkg-types"); err != nil {
|
if err := runYarn("pkg-types"); err != nil {
|
||||||
log.Fatalf("Type generation failed: %v", err)
|
log.Fatalf("Type generation failed: %v", err)
|
||||||
@@ -59,13 +54,6 @@ func runBuildPkg() {
|
|||||||
log.Printf("Built package version %s", version.Long())
|
log.Printf("Built package version %s", version.Long())
|
||||||
}
|
}
|
||||||
|
|
||||||
func precompressWasm() error {
|
|
||||||
log.Printf("Pre-compressing main.wasm...\n")
|
|
||||||
return precompress.Precompress(path.Join(*pkgDir, "main.wasm"), precompress.Options{
|
|
||||||
FastCompression: *fastCompression,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateVersion() error {
|
func updateVersion() error {
|
||||||
packageJSONBytes, err := os.ReadFile("package.json.tmpl")
|
packageJSONBytes, err := os.ReadFile("package.json.tmpl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ func buildWasm(dev bool) ([]byte, error) {
|
|||||||
// to fail for unclosed files.
|
// to fail for unclosed files.
|
||||||
defer outputFile.Close()
|
defer outputFile.Close()
|
||||||
|
|
||||||
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,nethttpomithttp2,omitidna,omitpemdecrypt"}
|
args := []string{"build", "-tags", "tailscale_go,osusergo,netgo,omitidna,omitpemdecrypt"}
|
||||||
if !dev {
|
if !dev {
|
||||||
if *devControl != "" {
|
if *devControl != "" {
|
||||||
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
return nil, fmt.Errorf("Development control URL can only be used in dev mode.")
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact"
|
"jsxImportSource": "preact",
|
||||||
|
"types": ["golang-wasm-exec", "qrcode"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !ts_omit_drive
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"tailscale.com/drive"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tsd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time check that jsFileSystemForRemote implements drive.FileSystemForRemote.
|
||||||
|
var _ drive.FileSystemForRemote = (*jsFileSystemForRemote)(nil)
|
||||||
|
|
||||||
|
// jsFileSystemForRemote implements drive.FileSystemForRemote by bridging
|
||||||
|
// incoming WebDAV requests to a JS handler function. Auth and permission
|
||||||
|
// parsing are handled upstream by handleServeDrive before this is called.
|
||||||
|
type jsFileSystemForRemote struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
fn js.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *jsFileSystemForRemote) setHandler(fn js.Value) {
|
||||||
|
fs.mu.Lock()
|
||||||
|
fs.fn = fn
|
||||||
|
fs.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFileServerAddr is a no-op: the JS handler owns its own storage.
|
||||||
|
func (fs *jsFileSystemForRemote) SetFileServerAddr(_ string) {}
|
||||||
|
|
||||||
|
// SetShares is a no-op: the JS handler controls which shares it exposes.
|
||||||
|
func (fs *jsFileSystemForRemote) SetShares(_ []*drive.Share) {}
|
||||||
|
|
||||||
|
// Close is a no-op.
|
||||||
|
func (fs *jsFileSystemForRemote) Close() error { return nil }
|
||||||
|
|
||||||
|
// ServeHTTPWithPerms handles a WebDAV request by bridging it to the JS handler.
|
||||||
|
// It streams the request body to JS via readBodyChunk() and streams the
|
||||||
|
// response body back via write()/end() callbacks, so no full-body buffering
|
||||||
|
// occurs regardless of file size.
|
||||||
|
//
|
||||||
|
// The call blocks until JS calls end() (or a write error occurs).
|
||||||
|
func (fs *jsFileSystemForRemote) ServeHTTPWithPerms(
|
||||||
|
perms drive.Permissions, w http.ResponseWriter, r *http.Request,
|
||||||
|
) {
|
||||||
|
fs.mu.RLock()
|
||||||
|
fn := fs.fn
|
||||||
|
fs.mu.RUnlock()
|
||||||
|
|
||||||
|
if fn.IsUndefined() || fn.IsNull() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBodyChunk is exposed to JS as req.readBodyChunk().
|
||||||
|
// Each call returns a Promise<Uint8Array|null>: null signals EOF.
|
||||||
|
readBodyChunk := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
n, err := r.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
arr := js.Global().Get("Uint8Array").New(n)
|
||||||
|
js.CopyBytesToJS(arr, buf[:n])
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return js.Null(), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// doneCh receives nil when JS calls end(), or a write error if Write fails.
|
||||||
|
doneCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// writeHead sets response headers and status code. Must be called before write().
|
||||||
|
writeHead := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status := args[0].Int()
|
||||||
|
if len(args) > 1 && !args[1].IsUndefined() && !args[1].IsNull() {
|
||||||
|
for k, vs := range jsHeadersToGo(args[1]) {
|
||||||
|
for _, v := range vs {
|
||||||
|
w.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// write streams a single response body chunk to the client.
|
||||||
|
write := js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data := args[0]
|
||||||
|
buf := make([]byte, data.Get("length").Int())
|
||||||
|
js.CopyBytesToGo(buf, data)
|
||||||
|
if _, werr := w.Write(buf); werr != nil {
|
||||||
|
select {
|
||||||
|
case doneCh <- werr:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// end signals that the response is complete.
|
||||||
|
end := js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||||
|
select {
|
||||||
|
case doneCh <- nil:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
readBodyChunk.Release()
|
||||||
|
writeHead.Release()
|
||||||
|
write.Release()
|
||||||
|
end.Release()
|
||||||
|
}()
|
||||||
|
|
||||||
|
jsReq := map[string]any{
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"rawQuery": r.URL.RawQuery,
|
||||||
|
"headers": goHeadersToJS(r.Header),
|
||||||
|
"readBodyChunk": readBodyChunk,
|
||||||
|
}
|
||||||
|
jsRes := map[string]any{
|
||||||
|
"writeHead": writeHead,
|
||||||
|
"write": write,
|
||||||
|
"end": end,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn.Invoke(jsReq, jsRes, drivePermsToJS(perms))
|
||||||
|
|
||||||
|
// Block this goroutine until JS calls end() or a write error occurs.
|
||||||
|
// The Go WASM scheduler yields back to JS while we wait.
|
||||||
|
<-doneCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// drivePermsToJS converts drive.Permissions to a plain JS-friendly object.
|
||||||
|
// Each share name maps to a numeric permission: 0=none, 1=read-only, 2=read-write.
|
||||||
|
// The wildcard share name "*" is included if present.
|
||||||
|
func drivePermsToJS(p drive.Permissions) map[string]any {
|
||||||
|
result := make(map[string]any, len(p))
|
||||||
|
for name, perm := range p {
|
||||||
|
result[name] = int(perm)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// goHeadersToJS converts an http.Header to a map[string]any suitable for JS.
|
||||||
|
// Single-value headers become a string; multi-value headers become a []any.
|
||||||
|
func goHeadersToJS(h http.Header) map[string]any {
|
||||||
|
result := make(map[string]any, len(h))
|
||||||
|
for k, vs := range h {
|
||||||
|
if len(vs) == 1 {
|
||||||
|
result[k] = vs[0]
|
||||||
|
} else {
|
||||||
|
arr := make([]any, len(vs))
|
||||||
|
for i, v := range vs {
|
||||||
|
arr[i] = v
|
||||||
|
}
|
||||||
|
result[k] = arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsHeadersToGo parses a JS headers object into an http.Header map.
|
||||||
|
// Values may be a string or an array of strings.
|
||||||
|
func jsHeadersToGo(jsHeaders js.Value) http.Header {
|
||||||
|
h := make(http.Header)
|
||||||
|
keys := js.Global().Get("Object").Call("keys", jsHeaders)
|
||||||
|
for i := 0; i < keys.Length(); i++ {
|
||||||
|
key := keys.Index(i).String()
|
||||||
|
val := jsHeaders.Get(key)
|
||||||
|
switch val.Type() {
|
||||||
|
case js.TypeString:
|
||||||
|
h.Set(key, val.String())
|
||||||
|
case js.TypeObject:
|
||||||
|
if val.InstanceOf(js.Global().Get("Array")) {
|
||||||
|
for j := 0; j < val.Length(); j++ {
|
||||||
|
h.Add(key, val.Index(j).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDriveForRemote creates the JS-backed FileSystemForRemote and registers
|
||||||
|
// it with sys. Must be called before NewLocalBackend (SubSystem is set-once).
|
||||||
|
func initDriveForRemote(sys *tsd.System) *jsFileSystemForRemote {
|
||||||
|
driveFS := &jsFileSystemForRemote{}
|
||||||
|
sys.Set(driveFS)
|
||||||
|
return driveFS
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireDriveJS adds drive-related methods to the IPN JS methods map.
|
||||||
|
// driveFS must be the value returned by initDriveForRemote.
|
||||||
|
func wireDriveJS(i *jsIPN, driveFS *jsFileSystemForRemote, m map[string]any) {
|
||||||
|
m["setDriveHandler"] = js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||||
|
if len(args) < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
driveFS.setHandler(args[0])
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
m["listDrivePeers"] = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||||
|
return i.listDrivePeers()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsDrivePeer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PeerAPIURL string `json:"peerAPIURL"`
|
||||||
|
StableNodeID string `json:"stableNodeID"`
|
||||||
|
Online *bool `json:"online,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listDrivePeers returns a JSON array of peers that carry
|
||||||
|
// PeerCapabilityTaildriveSharer. Returns an empty array if the local node
|
||||||
|
// does not have drive:access in its ACL (DriveAccessEnabled). This mirrors
|
||||||
|
// the filtering in LocalBackend.driveRemotesFromPeers.
|
||||||
|
func (i *jsIPN) listDrivePeers() js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
if !i.lb.DriveAccessEnabled() {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nm := i.lb.NetMap()
|
||||||
|
if nm == nil {
|
||||||
|
return nil, errors.New("listDrivePeers: no network map available")
|
||||||
|
}
|
||||||
|
|
||||||
|
var selfHave4, selfHave6 bool
|
||||||
|
for _, a := range nm.GetAddresses().All() {
|
||||||
|
if !a.IsSingleIP() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Addr().Is4() {
|
||||||
|
selfHave4 = true
|
||||||
|
} else if a.Addr().Is6() {
|
||||||
|
selfHave6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peers := make([]jsDrivePeer, 0)
|
||||||
|
for _, p := range nm.Peers {
|
||||||
|
// Check PeerCapabilityTaildriveSharer via the live PeerCaps map
|
||||||
|
// (derived from ACL rules), mirroring driveRemotesFromPeers.
|
||||||
|
hasCap := false
|
||||||
|
for _, a := range p.Addresses().All() {
|
||||||
|
if a.IsSingleIP() && i.lb.PeerCaps(a.Addr()).HasCapability(tailcfg.PeerCapabilityTaildriveSharer) {
|
||||||
|
hasCap = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
peerURL := buildPeerAPIURL(p, selfHave4, selfHave6)
|
||||||
|
online := p.Online().Clone()
|
||||||
|
peers = append(peers, jsDrivePeer{
|
||||||
|
Name: p.DisplayName(false),
|
||||||
|
PeerAPIURL: peerURL,
|
||||||
|
StableNodeID: string(p.StableID()),
|
||||||
|
Online: online,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(peers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listDrivePeers: marshal: %w", err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build ts_omit_drive
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"tailscale.com/tsd"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jsFileSystemForRemote struct{}
|
||||||
|
|
||||||
|
// initDriveForRemote is a no-op when the drive feature is omitted.
|
||||||
|
func initDriveForRemote(_ *tsd.System) *jsFileSystemForRemote { return nil }
|
||||||
|
|
||||||
|
// wireDriveJS is a no-op when the drive feature is omitted.
|
||||||
|
func wireDriveJS(_ *jsIPN, _ *jsFileSystemForRemote, _ map[string]any) {}
|
||||||
|
|
||||||
|
// listDrivePeers returns an empty list when the drive feature is omitted.
|
||||||
|
func (i *jsIPN) listDrivePeers() js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
return "[]", nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPeerAPIURL returns the HTTP base URL for a peer's peerAPI server,
|
||||||
|
// selecting IPv4 when available and falling back to IPv6. Returns an empty
|
||||||
|
// string if the peer advertises no reachable peerAPI port.
|
||||||
|
func buildPeerAPIURL(p tailcfg.NodeView, selfHave4, selfHave6 bool) string {
|
||||||
|
var pp4, pp6 uint16
|
||||||
|
for _, s := range p.Hostinfo().Services().All() {
|
||||||
|
switch s.Proto {
|
||||||
|
case tailcfg.PeerAPI4:
|
||||||
|
pp4 = s.Port
|
||||||
|
case tailcfg.PeerAPI6:
|
||||||
|
pp6 = s.Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selfHave4 && pp4 != 0 {
|
||||||
|
for _, a := range p.Addresses().All() {
|
||||||
|
if a.IsSingleIP() && a.Addr().Is4() {
|
||||||
|
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selfHave6 && pp6 != 0 {
|
||||||
|
for _, a := range p.Addresses().All() {
|
||||||
|
if a.IsSingleIP() && a.Addr().Is6() {
|
||||||
|
return fmt.Sprintf("http://%v", netip.AddrPortFrom(a.Addr(), pp6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// This file bridges the Taildrop FileOps interface to JS callbacks,
|
||||||
|
// using the same channel+FuncOf pattern as the Go stdlib's WASM HTTP
|
||||||
|
// transport (src/net/http/roundtrip_js.go): Go passes a js.FuncOf to JS,
|
||||||
|
// then blocks on a channel until JS calls it back — which may be async.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"syscall/js"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
"tailscale.com/feature/taildrop"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/util/progresstracking"
|
||||||
|
"tailscale.com/util/rands"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time check that jsFileOps implements taildrop.FileOps.
|
||||||
|
var _ taildrop.FileOps = (*jsFileOps)(nil)
|
||||||
|
|
||||||
|
// taildropExt returns the taildrop extension, or an error if unavailable.
|
||||||
|
func (i *jsIPN) taildropExt() (*taildrop.Extension, error) {
|
||||||
|
ext, ok := ipnlocal.GetExt[*taildrop.Extension](i.lb)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("taildrop extension not available")
|
||||||
|
}
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listFileTargets returns the peers that can receive Taildrop files as a JSON
|
||||||
|
// array of {stableNodeID, name, addresses, os} objects.
|
||||||
|
func (i *jsIPN) listFileTargets() js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
ext, err := i.taildropExt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fts, err := ext.FileTargets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type jsTarget struct {
|
||||||
|
StableNodeID string `json:"stableNodeID"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
}
|
||||||
|
out := make([]jsTarget, 0, len(fts))
|
||||||
|
for _, ft := range fts {
|
||||||
|
addrs := make([]string, 0, len(ft.Node.Addresses))
|
||||||
|
for _, a := range ft.Node.Addresses {
|
||||||
|
addrs = append(addrs, a.Addr().String())
|
||||||
|
}
|
||||||
|
out = append(out, jsTarget{
|
||||||
|
StableNodeID: string(ft.Node.StableID),
|
||||||
|
Name: ft.Node.Name,
|
||||||
|
Addresses: addrs,
|
||||||
|
OS: ft.Node.Hostinfo.OS(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFile sends stream as filename to the peer identified by stableNodeID,
|
||||||
|
// reporting progress via notifyOutgoingFiles callbacks roughly once per second.
|
||||||
|
// declaredSize is the total byte count (-1 if unknown); it is used for progress
|
||||||
|
// reporting and sets Content-Length on the PUT request (chunked TE when -1).
|
||||||
|
func (i *jsIPN) sendFile(stableNodeID, filename string, stream js.Value, declaredSize int) js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
ext, err := i.taildropExt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fts, err := ext.FileTargets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ft *apitype.FileTarget
|
||||||
|
for _, x := range fts {
|
||||||
|
if x.Node.StableID == tailcfg.StableNodeID(stableNodeID) {
|
||||||
|
ft = x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ft == nil {
|
||||||
|
return nil, fmt.Errorf("node %q not found or not a file target", stableNodeID)
|
||||||
|
}
|
||||||
|
dstURL, err := url.Parse(ft.PeerAPIURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bogus peer URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := stream.Call("getReader")
|
||||||
|
body := &jsStreamReader{reader: reader}
|
||||||
|
|
||||||
|
outgoing := &ipn.OutgoingFile{
|
||||||
|
ID: rands.HexString(30),
|
||||||
|
PeerID: tailcfg.StableNodeID(stableNodeID),
|
||||||
|
Name: filename,
|
||||||
|
DeclaredSize: int64(declaredSize),
|
||||||
|
Started: time.Now(),
|
||||||
|
}
|
||||||
|
updates := map[string]*ipn.OutgoingFile{outgoing.ID: outgoing}
|
||||||
|
|
||||||
|
// Report final state (success or failure) when the function returns.
|
||||||
|
var sendErr error
|
||||||
|
defer func() {
|
||||||
|
outgoing.Finished = true
|
||||||
|
outgoing.Succeeded = sendErr == nil
|
||||||
|
ext.UpdateOutgoingFiles(updates)
|
||||||
|
}()
|
||||||
|
|
||||||
|
progressBody := progresstracking.NewReader(body, time.Second, func(n int, _ error) {
|
||||||
|
outgoing.Sent = int64(n)
|
||||||
|
ext.UpdateOutgoingFiles(updates)
|
||||||
|
})
|
||||||
|
|
||||||
|
req, err := http.NewRequest("PUT", dstURL.String()+"/v0/put/"+url.PathEscape(filename), progressBody)
|
||||||
|
if err != nil {
|
||||||
|
sendErr = err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.ContentLength = int64(declaredSize)
|
||||||
|
client := &http.Client{Transport: i.lb.Dialer().PeerAPITransport()}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
sendErr = err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
b := make([]byte, len(respBody))
|
||||||
|
copy(b, respBody)
|
||||||
|
// trim trailing whitespace
|
||||||
|
for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r' || b[len(b)-1] == ' ') {
|
||||||
|
b = b[:len(b)-1]
|
||||||
|
}
|
||||||
|
sendErr = fmt.Errorf("send file: %s: %s", resp.Status, b)
|
||||||
|
return nil, sendErr
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingFiles returns received files waiting for pickup as a JSON array of
|
||||||
|
// {name, size} objects. Always returns an array (never null).
|
||||||
|
func (i *jsIPN) waitingFiles() js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
ext, err := i.taildropExt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wfs, err := ext.WaitingFiles()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type jsWaitingFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
out := make([]jsWaitingFile, len(wfs))
|
||||||
|
for i, wf := range wfs {
|
||||||
|
out[i] = jsWaitingFile{Name: wf.Name, Size: wf.Size}
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openWaitingFile returns the contents of a received file as a ReadableStream.
|
||||||
|
// The stream emits Uint8Array chunks and closes when the file is fully read.
|
||||||
|
func (i *jsIPN) openWaitingFile(name string) js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
ext, err := i.taildropExt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rc, _, err := ext.OpenFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jsReadableStream(rc), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteWaitingFile deletes a received file by name.
|
||||||
|
func (i *jsIPN) deleteWaitingFile(name string) js.Value {
|
||||||
|
return makePromise(func() (any, error) {
|
||||||
|
ext, err := i.taildropExt()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, ext.DeleteFile(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireTaildropFileOps installs a JS-backed FileOps on the taildrop extension
|
||||||
|
// if jsObj is a non-null JS object. It must be called after NewLocalBackend
|
||||||
|
// and before lb.Start (i.e. before run() is called by the user), so that the
|
||||||
|
// FileOps is in place when the extension's onChangeProfile hook fires on init.
|
||||||
|
//
|
||||||
|
// SetStagedFileOps is used instead of SetFileOps so that files are staged for
|
||||||
|
// explicit retrieval via WaitingFiles/OpenFile rather than delivered directly
|
||||||
|
// (DirectFileMode=false). The JS caller fetches them via waitingFiles() et al.
|
||||||
|
func wireTaildropFileOps(lb *ipnlocal.LocalBackend, jsObj js.Value) {
|
||||||
|
if jsObj.IsUndefined() || jsObj.IsNull() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext.SetStagedFileOps(&jsFileOps{v: jsObj})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsStreamReader implements io.ReadCloser by pulling chunks from a JS
|
||||||
|
// ReadableStreamDefaultReader. Each Read call awaits one reader.read() Promise,
|
||||||
|
// using the channel+FuncOf pattern so Go blocks until JS delivers the chunk.
|
||||||
|
type jsStreamReader struct {
|
||||||
|
reader js.Value
|
||||||
|
buf []byte
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *jsStreamReader) Read(p []byte) (int, error) {
|
||||||
|
if r.done {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if len(r.buf) > 0 {
|
||||||
|
n := copy(p, r.buf)
|
||||||
|
r.buf = r.buf[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
type chunkResult struct {
|
||||||
|
data []byte
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
ch := make(chan chunkResult, 1)
|
||||||
|
thenFn := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
result := args[0]
|
||||||
|
if result.Get("done").Bool() {
|
||||||
|
ch <- chunkResult{done: true}
|
||||||
|
} else {
|
||||||
|
value := result.Get("value")
|
||||||
|
b := make([]byte, value.Get("byteLength").Int())
|
||||||
|
js.CopyBytesToGo(b, value)
|
||||||
|
ch <- chunkResult{data: b}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer thenFn.Release()
|
||||||
|
r.reader.Call("read").Call("then", thenFn)
|
||||||
|
result := <-ch
|
||||||
|
if result.done {
|
||||||
|
r.done = true
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(p, result.data)
|
||||||
|
r.buf = result.data[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *jsStreamReader) Close() error {
|
||||||
|
r.reader.Call("cancel")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsReadableStream wraps rc in a pull-based JS ReadableStream. Each pull call
|
||||||
|
// reads up to 64 KiB from rc and enqueues a Uint8Array chunk; the stream
|
||||||
|
// closes on EOF or signals an error on any other read failure.
|
||||||
|
func jsReadableStream(rc io.ReadCloser) js.Value {
|
||||||
|
var pullFn, cancelFn js.Func
|
||||||
|
|
||||||
|
cancelFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
rc.Close()
|
||||||
|
pullFn.Release()
|
||||||
|
cancelFn.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
pullFn = js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
|
controller := args[0]
|
||||||
|
var execFn js.Func
|
||||||
|
execFn = js.FuncOf(func(this js.Value, rr []js.Value) any {
|
||||||
|
resolve := rr[0]
|
||||||
|
go func() {
|
||||||
|
defer execFn.Release()
|
||||||
|
buf := make([]byte, 65536)
|
||||||
|
n, err := rc.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
chunk := js.Global().Get("Uint8Array").New(n)
|
||||||
|
js.CopyBytesToJS(chunk, buf[:n])
|
||||||
|
controller.Call("enqueue", chunk)
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
rc.Close()
|
||||||
|
pullFn.Release()
|
||||||
|
cancelFn.Release()
|
||||||
|
controller.Call("close")
|
||||||
|
} else if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
pullFn.Release()
|
||||||
|
cancelFn.Release()
|
||||||
|
controller.Call("error", err.Error())
|
||||||
|
}
|
||||||
|
resolve.Invoke()
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return js.Global().Get("Promise").New(execFn)
|
||||||
|
})
|
||||||
|
|
||||||
|
return js.Global().Get("ReadableStream").New(map[string]any{
|
||||||
|
"pull": pullFn,
|
||||||
|
"cancel": cancelFn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsFileOps implements [taildrop.FileOps] by delegating to JS callbacks.
|
||||||
|
// JS methods use one of two callback conventions:
|
||||||
|
//
|
||||||
|
// Void ops (openWriter, write, closeWriter, remove): cb(err?: string)
|
||||||
|
//
|
||||||
|
// on success: cb() or cb("")
|
||||||
|
// on error: cb("error message")
|
||||||
|
// not found: cb("ENOENT")
|
||||||
|
//
|
||||||
|
// Result ops (rename, listFiles, stat, openReader): cb(result: T | null, err?: string)
|
||||||
|
//
|
||||||
|
// on success: cb(result)
|
||||||
|
// on error: cb(null, "error message")
|
||||||
|
// not found: cb(null, "ENOENT")
|
||||||
|
type jsFileOps struct {
|
||||||
|
v js.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsCallResult invokes method on j.v, appending a Go-owned js.FuncOf as the
|
||||||
|
// final argument. It blocks until JS calls back with (result, errStr?), then
|
||||||
|
// returns (result, error). An absent or empty errStr means success.
|
||||||
|
//
|
||||||
|
// JS convention for result ops: cb(result: T | null, err?: string)
|
||||||
|
func (j jsFileOps) jsCallResult(method string, args ...any) (js.Value, error) {
|
||||||
|
type result struct {
|
||||||
|
val js.Value
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
||||||
|
var r result
|
||||||
|
if len(cbArgs) > 0 {
|
||||||
|
if t := cbArgs[0].Type(); t != js.TypeNull && t != js.TypeUndefined {
|
||||||
|
r.val = cbArgs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cbArgs) > 1 && cbArgs[1].Type() == js.TypeString {
|
||||||
|
if s := cbArgs[1].String(); s != "" {
|
||||||
|
r.err = errors.New(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- r
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer cb.Release()
|
||||||
|
j.v.Call(method, append(args, cb)...)
|
||||||
|
r := <-ch
|
||||||
|
return r.val, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsCallVoid invokes method on j.v for operations that return no result,
|
||||||
|
// appending a Go-owned js.FuncOf as the final argument. It blocks until JS
|
||||||
|
// calls back with an optional error string, then returns the error or nil.
|
||||||
|
//
|
||||||
|
// JS convention for void ops: cb(err?: string)
|
||||||
|
func (j jsFileOps) jsCallVoid(method string, args ...any) error {
|
||||||
|
ch := make(chan error, 1)
|
||||||
|
cb := js.FuncOf(func(this js.Value, cbArgs []js.Value) any {
|
||||||
|
var err error
|
||||||
|
if len(cbArgs) > 0 && cbArgs[0].Type() == js.TypeString {
|
||||||
|
if s := cbArgs[0].String(); s != "" {
|
||||||
|
err = errors.New(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- err
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
defer cb.Release()
|
||||||
|
j.v.Call(method, append(args, cb)...)
|
||||||
|
return <-ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSNotExist reports whether err is the sentinel "ENOENT" from JS.
|
||||||
|
func isJSNotExist(err error) bool {
|
||||||
|
return err != nil && err.Error() == "ENOENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) OpenWriter(name string, offset int64, _ os.FileMode) (io.WriteCloser, string, error) {
|
||||||
|
if err := j.jsCallVoid("openWriter", name, offset); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return &jsWriteCloser{ops: j, name: name}, name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsWriteCloser struct {
|
||||||
|
ops jsFileOps
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *jsWriteCloser) Write(p []byte) (int, error) {
|
||||||
|
buf := js.Global().Get("Uint8Array").New(len(p))
|
||||||
|
js.CopyBytesToJS(buf, p)
|
||||||
|
if err := w.ops.jsCallVoid("write", w.name, buf); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *jsWriteCloser) Close() error {
|
||||||
|
return w.ops.jsCallVoid("closeWriter", w.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) Remove(name string) error {
|
||||||
|
err := j.jsCallVoid("remove", name)
|
||||||
|
if isJSNotExist(err) {
|
||||||
|
return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) Rename(oldPath, newName string) (string, error) {
|
||||||
|
val, err := j.jsCallResult("rename", oldPath, newName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) ListFiles() ([]string, error) {
|
||||||
|
val, err := j.jsCallResult("listFiles")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := val.Length()
|
||||||
|
names := make([]string, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
names[i] = val.Index(i).String()
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) Stat(name string) (fs.FileInfo, error) {
|
||||||
|
val, err := j.jsCallResult("stat", name)
|
||||||
|
if isJSNotExist(err) {
|
||||||
|
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Use Float to correctly handle files larger than 2 GiB (int is 32-bit on wasm).
|
||||||
|
return &jsFileInfo{name: name, size: int64(val.Float())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsFileOps) OpenReader(name string) (io.ReadCloser, error) {
|
||||||
|
val, err := j.jsCallResult("openReader", name)
|
||||||
|
if isJSNotExist(err) {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// val is a ReadableStream; wrap its reader for streaming delivery to Go.
|
||||||
|
reader := val.Call("getReader")
|
||||||
|
return &jsStreamReader{reader: reader}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsFileInfo is a minimal [fs.FileInfo] backed by a name and a size.
|
||||||
|
// Only Size() is used by the taildrop manager; the other fields are stubs.
|
||||||
|
type jsFileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *jsFileInfo) Name() string { return i.name }
|
||||||
|
func (i *jsFileInfo) Size() int64 { return i.size }
|
||||||
|
func (i *jsFileInfo) Mode() fs.FileMode { return 0o444 }
|
||||||
|
func (i *jsFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (i *jsFileInfo) IsDir() bool { return false }
|
||||||
|
func (i *jsFileInfo) Sys() any { return nil }
|
||||||
+1094
-10
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,12 @@ func (c *Auto) restartMap() {
|
|||||||
c.updateControl()
|
c.updateControl()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RestartMap cancels the existing map poll and starts a fresh streaming one,
|
||||||
|
// forcing the control server to send a new full netmap response.
|
||||||
|
func (c *Auto) RestartMap() {
|
||||||
|
c.restartMap()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Auto) authRoutine() {
|
func (c *Auto) authRoutine() {
|
||||||
defer close(c.authDone)
|
defer close(c.authDone)
|
||||||
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
bo := backoff.NewBackoff("authRoutine", c.logf, 30*time.Second)
|
||||||
|
|||||||
+16
-2
@@ -76,6 +76,12 @@ type Extension struct {
|
|||||||
// This is currently being used for Android to use the Storage Access Framework.
|
// This is currently being used for Android to use the Storage Access Framework.
|
||||||
fileOps FileOps
|
fileOps FileOps
|
||||||
|
|
||||||
|
// directFileOps, when true, means that files received via fileOps should be
|
||||||
|
// delivered directly to the caller (DirectFileMode=true). Set by SetFileOps.
|
||||||
|
// SetStagedFileOps leaves this false so that received files are staged for
|
||||||
|
// explicit retrieval via WaitingFiles/OpenFile (used by the WASM JS bridge).
|
||||||
|
directFileOps bool
|
||||||
|
|
||||||
nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests
|
nodeBackendForTest ipnext.NodeBackend // if non-nil, pretend we're this node state for tests
|
||||||
|
|
||||||
mu sync.Mutex // Lock order: lb.mu > e.mu
|
mu sync.Mutex // Lock order: lb.mu > e.mu
|
||||||
@@ -155,9 +161,10 @@ func (e *Extension) onChangeProfile(profile ipn.LoginProfileView, _ ipn.PrefsVie
|
|||||||
// Use the provided [FileOps] implementation (typically for SAF access on Android),
|
// Use the provided [FileOps] implementation (typically for SAF access on Android),
|
||||||
// or create an [fsFileOps] instance rooted at fileRoot.
|
// or create an [fsFileOps] instance rooted at fileRoot.
|
||||||
//
|
//
|
||||||
// A non-nil [FileOps] also implies that we are in DirectFileMode.
|
// A non-nil [FileOps] with directFileOps=true implies DirectFileMode (Android SAF).
|
||||||
|
// A non-nil [FileOps] with directFileOps=false uses staged mode (WASM JS bridge).
|
||||||
fops := e.fileOps
|
fops := e.fileOps
|
||||||
isDirectFileMode := fops != nil
|
isDirectFileMode := fops != nil && e.directFileOps
|
||||||
if fops == nil {
|
if fops == nil {
|
||||||
var fileRoot string
|
var fileRoot string
|
||||||
if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
|
if fileRoot, isDirectFileMode = e.fileRoot(uid, activeLogin); fileRoot == "" {
|
||||||
@@ -411,6 +418,13 @@ func (e *Extension) taildropTargetStatus(p tailcfg.NodeView, nb ipnext.NodeBacke
|
|||||||
return ipnstate.TaildropTargetAvailable
|
return ipnstate.TaildropTargetAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateOutgoingFiles updates the tracked set of outgoing file transfers and
|
||||||
|
// sends an ipn.Notify with the full merged list. The updates map is keyed by
|
||||||
|
// OutgoingFile.ID; existing entries not present in updates are preserved.
|
||||||
|
func (e *Extension) UpdateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
|
||||||
|
e.updateOutgoingFiles(updates)
|
||||||
|
}
|
||||||
|
|
||||||
// updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
|
// updateOutgoingFiles updates b.outgoingFiles to reflect the given updates and
|
||||||
// sends an ipn.Notify with the full list of outgoingFiles.
|
// sends an ipn.Notify with the full list of outgoingFiles.
|
||||||
func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
|
func (e *Extension) updateOutgoingFiles(updates map[string]*ipn.OutgoingFile) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
//go:build !android
|
//go:build !android && !js
|
||||||
|
|
||||||
package taildrop
|
package taildrop
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build js
|
||||||
|
|
||||||
|
package taildrop
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// On WASM there is no real filesystem. newFileOps is only reached when
|
||||||
|
// SetFileOps was not called; return a clear error rather than panicking.
|
||||||
|
newFileOps = func(dir string) (FileOps, error) {
|
||||||
|
return nil, errors.New("taildrop: no filesystem on WASM; provide fileOps in the IPN config")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,21 @@ func (e *Extension) SetDirectFileRoot(root string) {
|
|||||||
e.directFileRoot = root
|
e.directFileRoot = root
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFileOps sets the platform specific file operations. This is used
|
// SetFileOps sets the platform-specific file operations. This is used
|
||||||
// to call Android's Storage Access Framework APIs.
|
// to call Android's Storage Access Framework APIs.
|
||||||
|
// It implies DirectFileMode, so received files are delivered directly to the
|
||||||
|
// caller rather than staged for retrieval via WaitingFiles/OpenFile.
|
||||||
func (e *Extension) SetFileOps(fileOps FileOps) {
|
func (e *Extension) SetFileOps(fileOps FileOps) {
|
||||||
e.fileOps = fileOps
|
e.fileOps = fileOps
|
||||||
|
e.directFileOps = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStagedFileOps sets the platform-specific file operations without enabling
|
||||||
|
// DirectFileMode. Received files are staged for explicit retrieval via
|
||||||
|
// WaitingFiles, OpenFile, and DeleteFile. Used by the WASM JS bridge.
|
||||||
|
func (e *Extension) SetStagedFileOps(fileOps FileOps) {
|
||||||
|
e.fileOps = fileOps
|
||||||
|
e.directFileOps = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extension) setPlatformDefaultDirectFileRoot() {
|
func (e *Extension) setPlatformDefaultDirectFileRoot() {
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ func (m *manager) PutFile(id clientID, baseName string, r io.Reader, offset, len
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the contents of the file to the writer.
|
// Copy via inFile (which wraps wc) so [incomingFile.Write] can track
|
||||||
copyLength, err := io.Copy(wc, r)
|
// progress and fire periodic sendFileNotify callbacks.
|
||||||
|
copyLength, err := io.Copy(inFile, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, m.redactAndLogError("Copy", err)
|
return 0, m.redactAndLogError("Copy", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,4 +163,4 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
# nix-direnv cache busting line: sha256-GB5riRI9hkutLc2wBzv2jil+Tf6fogLxUw54HRSPNUk=
|
# nix-direnv cache busting line: sha256-aZkUnWyQokNw+lxut9Fak3CazmwYE4tXILhzfK4jeK4=
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ require (
|
|||||||
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
|
github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41
|
||||||
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e
|
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e
|
||||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869
|
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
|
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd
|
||||||
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b
|
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7
|
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7
|
||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc
|
||||||
@@ -115,7 +115,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
||||||
golang.org/x/mod v0.31.0
|
golang.org/x/mod v0.31.0
|
||||||
golang.org/x/net v0.48.0
|
golang.org/x/net v0.48.0
|
||||||
golang.org/x/oauth2 v0.33.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
golang.org/x/sys v0.40.0
|
golang.org/x/sys v0.40.0
|
||||||
golang.org/x/term v0.38.0
|
golang.org/x/term v0.38.0
|
||||||
@@ -136,6 +136,7 @@ require (
|
|||||||
sigs.k8s.io/kind v0.30.0
|
sigs.k8s.io/kind v0.30.0
|
||||||
sigs.k8s.io/yaml v1.6.0
|
sigs.k8s.io/yaml v1.6.0
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0
|
software.sslmate.com/src/go-pkcs12 v0.4.0
|
||||||
|
tailscale.com/client/tailscale/v2 v2.9.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -470,7 +471,7 @@ require (
|
|||||||
github.com/tomarrell/wrapcheck/v2 v2.8.3 // indirect
|
github.com/tomarrell/wrapcheck/v2 v2.8.3 // indirect
|
||||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/ultraware/funlen v0.1.0 // indirect
|
github.com/ultraware/funlen v0.1.0 // indirect
|
||||||
github.com/ultraware/whitespace v0.1.0 // indirect
|
github.com/ultraware/whitespace v0.1.0 // indirect
|
||||||
github.com/uudashr/gocognit v1.1.2 // indirect
|
github.com/uudashr/gocognit v1.1.2 // indirect
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-GB5riRI9hkutLc2wBzv2jil+Tf6fogLxUw54HRSPNUk=
|
sha256-aZkUnWyQokNw+lxut9Fak3CazmwYE4tXILhzfK4jeK4=
|
||||||
|
|||||||
@@ -1142,8 +1142,8 @@ github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e h1:tyUUge
|
|||||||
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e/go.mod h1:7Mth+m9bq2IHusSsexMNyupHWPL8RxwOuSvBlSGtgDY=
|
github.com/tailscale/gokrazy-kernel v0.0.0-20240728225134-3d23beabda2e/go.mod h1:7Mth+m9bq2IHusSsexMNyupHWPL8RxwOuSvBlSGtgDY=
|
||||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
|
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
|
||||||
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
|
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
|
||||||
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
|
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
|
||||||
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b h1:QKqCnmp0qHWUHySySKjpuhZANzRn7XrTVZWUuUgJ3lQ=
|
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b h1:QKqCnmp0qHWUHySySKjpuhZANzRn7XrTVZWUuUgJ3lQ=
|
||||||
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b/go.mod h1:4st7fy3NTWcWsQdOC69JcHK4UXnncgcxSOvSR8aD8a0=
|
github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b/go.mod h1:4st7fy3NTWcWsQdOC69JcHK4UXnncgcxSOvSR8aD8a0=
|
||||||
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
|
||||||
@@ -1419,8 +1419,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
|||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -1796,3 +1796,5 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
|||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||||
|
tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o=
|
||||||
|
tailscale.com/client/tailscale/v2 v2.9.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U=
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ const (
|
|||||||
NotifyInitialSuggestedExitNode NotifyWatchOpt = 1 << 10 // if set, the first Notify message (sent immediately) will contain the current SuggestedExitNode if available
|
NotifyInitialSuggestedExitNode NotifyWatchOpt = 1 << 10 // if set, the first Notify message (sent immediately) will contain the current SuggestedExitNode if available
|
||||||
|
|
||||||
NotifyInitialClientVersion NotifyWatchOpt = 1 << 11 // if set, the first Notify message (sent immediately) will contain the current ClientVersion if available and if update checks are enabled
|
NotifyInitialClientVersion NotifyWatchOpt = 1 << 11 // if set, the first Notify message (sent immediately) will contain the current ClientVersion if available and if update checks are enabled
|
||||||
|
|
||||||
|
// NotifyPeerChanges, if set, causes netmap delta updates to be sent as [tailcfg.PeerChange] rather than a full NetMap.
|
||||||
|
// Full netmap responses from the control plane are still sent as a full NetMap. PeerChanges are only sent to sessions
|
||||||
|
// that have opted in to this mode.
|
||||||
|
NotifyPeerChanges NotifyWatchOpt = 1 << 12
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
|
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
|
||||||
@@ -112,6 +117,13 @@ type Notify struct {
|
|||||||
State *State // if non-nil, the new or current IPN state
|
State *State // if non-nil, the new or current IPN state
|
||||||
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
|
Prefs *PrefsView // if non-nil && Valid, the new or current preferences
|
||||||
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
|
NetMap *netmap.NetworkMap // if non-nil, the new or current netmap
|
||||||
|
|
||||||
|
// PeerChanges, if non-nil, is a list of [tailcfg.PeerChange] that have occurred since the last
|
||||||
|
// full netmap update. This is sent in lieu of a full NetMap when [NotifyPeerChanges] is set in
|
||||||
|
// the session's mask and a netmap update is derived from an incremental MapResponse.
|
||||||
|
// Full MapResponse updates from the control plane are sent as a full NetMap.
|
||||||
|
PeerChanges []*tailcfg.PeerChange `json:",omitzero"`
|
||||||
|
|
||||||
Engine *EngineStatus // if non-nil, the new or current wireguard stats
|
Engine *EngineStatus // if non-nil, the new or current wireguard stats
|
||||||
BrowseToURL *string // if non-nil, UI should open a browser right now
|
BrowseToURL *string // if non-nil, UI should open a browser right now
|
||||||
|
|
||||||
@@ -184,6 +196,9 @@ func (n Notify) String() string {
|
|||||||
if n.NetMap != nil {
|
if n.NetMap != nil {
|
||||||
sb.WriteString("NetMap{...} ")
|
sb.WriteString("NetMap{...} ")
|
||||||
}
|
}
|
||||||
|
if n.PeerChanges != nil {
|
||||||
|
fmt.Fprintf(&sb, "PeerChanges(%d) ", len(n.PeerChanges))
|
||||||
|
}
|
||||||
if n.Engine != nil {
|
if n.Engine != nil {
|
||||||
fmt.Fprintf(&sb, "wg=%v ", *n.Engine)
|
fmt.Fprintf(&sb, "wg=%v ", *n.Engine)
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-2
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,8 +117,8 @@ func (s *rateLimitingBusSender) Run(ctx context.Context, ch <-chan *ipn.Notify)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeBoringNotify merges new notify 'src' into possibly-nil 'dst',
|
// mergeBoringNotify merges new notify src into possibly-nil dst,
|
||||||
// either mutating 'dst' or allocating a new one if 'dst' is nil,
|
// either mutating dst or allocating a new one if dst is nil,
|
||||||
// returning the merged result.
|
// returning the merged result.
|
||||||
//
|
//
|
||||||
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
|
// dst and src must both be "boring" (i.e. not notable per isNotifiableNotify).
|
||||||
@@ -127,6 +128,9 @@ func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
|
|||||||
}
|
}
|
||||||
if src.NetMap != nil {
|
if src.NetMap != nil {
|
||||||
dst.NetMap = src.NetMap
|
dst.NetMap = src.NetMap
|
||||||
|
dst.PeerChanges = nil // full netmap supersedes any accumulated deltas
|
||||||
|
} else if src.PeerChanges != nil {
|
||||||
|
dst.PeerChanges = mergePeerChanges(dst.PeerChanges, src.PeerChanges)
|
||||||
}
|
}
|
||||||
if src.Engine != nil {
|
if src.Engine != nil {
|
||||||
dst.Engine = src.Engine
|
dst.Engine = src.Engine
|
||||||
@@ -134,6 +138,55 @@ func mergeBoringNotifies(dst, src *ipn.Notify) *ipn.Notify {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergePeerChanges merges new peer changes from src into dst, either
|
||||||
|
// mutating dst or allocating a new slice if dst is nil, returning the merged result.
|
||||||
|
// Values in src override those in dst for the same NodeID.
|
||||||
|
func mergePeerChanges(dst, src []*tailcfg.PeerChange) []*tailcfg.PeerChange {
|
||||||
|
idxByNode := make(map[tailcfg.NodeID]int, len(dst))
|
||||||
|
for i, d := range dst {
|
||||||
|
idxByNode[d.NodeID] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nd := range src {
|
||||||
|
if oi, ok := idxByNode[nd.NodeID]; ok {
|
||||||
|
dst[oi] = mergePeerChangeForIpnBus(dst[oi], nd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idxByNode[nd.NodeID] = len(dst)
|
||||||
|
dst = append(dst, nd)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergePeerChangeForIpnBus merges new with old, returning the result.
|
||||||
|
// Fields set in new override those in old; fields only set in old are preserved.
|
||||||
|
func mergePeerChangeForIpnBus(old, new *tailcfg.PeerChange) *tailcfg.PeerChange {
|
||||||
|
merged := *old
|
||||||
|
|
||||||
|
// This is a subset of PeerChange that reflects only the fields that can
|
||||||
|
// be changed via a NodeMutation. If future fields can be updated via
|
||||||
|
// NodeMutations from map responses (and they are relevant to the ipn bus), then
|
||||||
|
// they should be added here and merged in the same way.
|
||||||
|
if new.DERPRegion != 0 {
|
||||||
|
// netmap.NodeMutationDerpHome
|
||||||
|
merged.DERPRegion = new.DERPRegion
|
||||||
|
}
|
||||||
|
if new.Online != nil {
|
||||||
|
// netmap.NodeMutationOnline
|
||||||
|
merged.Online = new.Online
|
||||||
|
}
|
||||||
|
if new.LastSeen != nil {
|
||||||
|
// netmap.NodeMutationLastSeen
|
||||||
|
merged.LastSeen = new.LastSeen
|
||||||
|
}
|
||||||
|
if new.Endpoints != nil {
|
||||||
|
// netmap.NodeMutationEndpoints
|
||||||
|
merged.Endpoints = new.Endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
return &merged
|
||||||
|
}
|
||||||
|
|
||||||
// isNotableNotify reports whether n is a "notable" notification that
|
// isNotableNotify reports whether n is a "notable" notification that
|
||||||
// should be sent on the IPN bus immediately (e.g. to GUIs) without
|
// should be sent on the IPN bus immediately (e.g. to GUIs) without
|
||||||
// rate limiting it for a few seconds.
|
// rate limiting it for a few seconds.
|
||||||
|
|||||||
+103
-1
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"tailscale.com/drive"
|
"tailscale.com/drive"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
"tailscale.com/types/logger"
|
"tailscale.com/types/logger"
|
||||||
@@ -29,6 +30,7 @@ func TestIsNotableNotify(t *testing.T) {
|
|||||||
{"empty", &ipn.Notify{}, false},
|
{"empty", &ipn.Notify{}, false},
|
||||||
{"version", &ipn.Notify{Version: "foo"}, false},
|
{"version", &ipn.Notify{Version: "foo"}, false},
|
||||||
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
|
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
|
||||||
|
{"peerchanges", &ipn.Notify{PeerChanges: []*tailcfg.PeerChange{{}}}, false},
|
||||||
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
|
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ func TestIsNotableNotify(t *testing.T) {
|
|||||||
for sf := range rt.Fields() {
|
for sf := range rt.Fields() {
|
||||||
n := &ipn.Notify{}
|
n := &ipn.Notify{}
|
||||||
switch sf.Name {
|
switch sf.Name {
|
||||||
case "_", "NetMap", "Engine", "Version":
|
case "_", "NetMap", "PeerChanges", "Engine", "Version":
|
||||||
// Already covered above or not applicable.
|
// Already covered above or not applicable.
|
||||||
continue
|
continue
|
||||||
case "DriveShares":
|
case "DriveShares":
|
||||||
@@ -217,3 +219,103 @@ func TestRateLimitingBusSender(t *testing.T) {
|
|||||||
st.s.Run(ctx, incoming)
|
st.s.Run(ctx, incoming)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergePeerChanges(t *testing.T) {
|
||||||
|
online := true
|
||||||
|
offline := false
|
||||||
|
|
||||||
|
t.Run("no_overlap_appends", func(t *testing.T) {
|
||||||
|
old := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 1},
|
||||||
|
}
|
||||||
|
new := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 2, DERPRegion: 2},
|
||||||
|
}
|
||||||
|
got := mergePeerChanges(old, new)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len = %d; want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].NodeID != 1 || got[1].NodeID != 2 {
|
||||||
|
t.Errorf("got NodeIDs %d, %d; want 1, 2", got[0].NodeID, got[1].NodeID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overlap_merges", func(t *testing.T) {
|
||||||
|
old := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 1, Online: &online},
|
||||||
|
{NodeID: 2, DERPRegion: 10},
|
||||||
|
}
|
||||||
|
new := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 5, Online: &offline},
|
||||||
|
}
|
||||||
|
got := mergePeerChanges(old, new)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len = %d; want 2 (merged, not appended)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].DERPRegion != 5 {
|
||||||
|
t.Errorf("DERPRegion = %d; want 5 (from new)", got[0].DERPRegion)
|
||||||
|
}
|
||||||
|
if *got[0].Online != false {
|
||||||
|
t.Errorf("Online = %v; want false (from new)", *got[0].Online)
|
||||||
|
}
|
||||||
|
// Node 2 should be untouched.
|
||||||
|
if got[1].NodeID != 2 || got[1].DERPRegion != 10 {
|
||||||
|
t.Errorf("node 2 was modified unexpectedly")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("partial_overlap_merges_and_appends", func(t *testing.T) {
|
||||||
|
old := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 1},
|
||||||
|
}
|
||||||
|
new := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 2},
|
||||||
|
{NodeID: 3, DERPRegion: 30},
|
||||||
|
}
|
||||||
|
got := mergePeerChanges(old, new)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len = %d; want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].NodeID != 1 || got[0].DERPRegion != 2 {
|
||||||
|
t.Errorf("node 1: DERPRegion = %d; want 2", got[0].DERPRegion)
|
||||||
|
}
|
||||||
|
if got[1].NodeID != 3 || got[1].DERPRegion != 30 {
|
||||||
|
t.Errorf("node 3: DERPRegion = %d; want 30", got[1].DERPRegion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preserves_old_fields_on_merge", func(t *testing.T) {
|
||||||
|
old := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 1, Online: &online, Cap: 10},
|
||||||
|
}
|
||||||
|
new := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, Online: &offline},
|
||||||
|
}
|
||||||
|
got := mergePeerChanges(old, new)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len = %d; want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].DERPRegion != 1 {
|
||||||
|
t.Errorf("DERPRegion = %d; want 1 (preserved from old)", got[0].DERPRegion)
|
||||||
|
}
|
||||||
|
if got[0].Cap != 10 {
|
||||||
|
t.Errorf("Cap = %d; want 10 (preserved from old)", got[0].Cap)
|
||||||
|
}
|
||||||
|
if *got[0].Online != false {
|
||||||
|
t.Errorf("Online = %v; want false (from new)", *got[0].Online)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil_old", func(t *testing.T) {
|
||||||
|
new := []*tailcfg.PeerChange{
|
||||||
|
{NodeID: 1, DERPRegion: 1},
|
||||||
|
}
|
||||||
|
got := mergePeerChanges(nil, new)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len = %d; want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].NodeID != 1 {
|
||||||
|
t.Errorf("NodeID = %d; want 1", got[0].NodeID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+17
-1
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build !js && !ts_omit_acme
|
//go:build !ts_omit_acme
|
||||||
|
|
||||||
package ipnlocal
|
package ipnlocal
|
||||||
|
|
||||||
@@ -302,6 +302,9 @@ var errCertExpired = errors.New("cert expired")
|
|||||||
var testX509Roots *x509.CertPool // set non-nil by tests
|
var testX509Roots *x509.CertPool // set non-nil by tests
|
||||||
|
|
||||||
func (b *LocalBackend) getCertStore() (certStore, error) {
|
func (b *LocalBackend) getCertStore() (certStore, error) {
|
||||||
|
if runtime.GOOS == "js" {
|
||||||
|
return certStateStore{StateStore: b.store}, nil
|
||||||
|
}
|
||||||
switch b.store.(type) {
|
switch b.store.(type) {
|
||||||
case *store.FileStore:
|
case *store.FileStore:
|
||||||
case *mem.Store:
|
case *mem.Store:
|
||||||
@@ -333,6 +336,16 @@ func (b *LocalBackend) ConfigureCertsForTest(getCert func(hostname string) (*TLS
|
|||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetACMEHTTPClient sets a custom HTTP client for ACME certificate operations.
|
||||||
|
// On js/wasm, this can be used to route requests through the Tailscale network
|
||||||
|
// stack to bypass browser CORS if Let's Encrypt endpoints fail preflight.
|
||||||
|
// A nil value (the default) uses the standard http.DefaultClient.
|
||||||
|
func (b *LocalBackend) SetACMEHTTPClient(c *http.Client) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.acmeHTTPClient = c
|
||||||
|
}
|
||||||
|
|
||||||
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
||||||
type certFileStore struct {
|
type certFileStore struct {
|
||||||
dir string
|
dir string
|
||||||
@@ -550,6 +563,9 @@ var getCertPEM = func(ctx context.Context, b *LocalBackend, cs certStore, logf l
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
ac.HTTPClient = b.acmeHTTPClient
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
if !isDefaultDirectoryURL(ac.DirectoryURL) {
|
||||||
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
logf("acme: using Directory URL %q", ac.DirectoryURL)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) Tailscale Inc & contributors
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
//go:build js || ts_omit_acme
|
//go:build ts_omit_acme
|
||||||
|
|
||||||
package ipnlocal
|
package ipnlocal
|
||||||
|
|
||||||
|
|||||||
+96
-6
@@ -152,6 +152,7 @@ type watchSession struct {
|
|||||||
owner ipnauth.Actor // or nil
|
owner ipnauth.Actor // or nil
|
||||||
sessionID string
|
sessionID string
|
||||||
cancel context.CancelFunc // to shut down the session
|
cancel context.CancelFunc // to shut down the session
|
||||||
|
mask ipn.NotifyWatchOpt // watch options for this session
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -293,6 +294,7 @@ type LocalBackend struct {
|
|||||||
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
capTailnetLock bool // whether netMap contains the tailnet lock capability
|
||||||
// hostinfo is mutated in-place while mu is held.
|
// hostinfo is mutated in-place while mu is held.
|
||||||
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
hostinfo *tailcfg.Hostinfo // TODO(nickkhyl): move to nodeBackend
|
||||||
|
explicitServices []tailcfg.Service // services set explicitly via SetExplicitServices; always uploaded
|
||||||
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
nmExpiryTimer tstime.TimerController // for updating netMap on node expiry; can be nil; TODO(nickkhyl): move to nodeBackend
|
||||||
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
activeLogin string // last logged LoginName from netMap; TODO(nickkhyl): move to nodeBackend (or remove? it's in [ipn.LoginProfile]).
|
||||||
engineStatus ipn.EngineStatus
|
engineStatus ipn.EngineStatus
|
||||||
@@ -411,6 +413,10 @@ type LocalBackend struct {
|
|||||||
// See [LocalBackend.ConfigureCertsForTest].
|
// See [LocalBackend.ConfigureCertsForTest].
|
||||||
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
|
getCertForTest func(hostname string) (*TLSCertKeyPair, error)
|
||||||
|
|
||||||
|
// acmeHTTPClient, if non-nil, is used for all ACME HTTP requests instead
|
||||||
|
// of http.DefaultClient. Set via SetACMEHTTPClient before first cert use.
|
||||||
|
acmeHTTPClient *http.Client
|
||||||
|
|
||||||
// existsPendingAuthReconfig tracks if a goroutine is waiting to
|
// existsPendingAuthReconfig tracks if a goroutine is waiting to
|
||||||
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
|
// acquire [LocalBackend]'s mutex inside of [LocalBackend.AuthReconfig].
|
||||||
// It is used to prevent goroutines from piling up to do the same
|
// It is used to prevent goroutines from piling up to do the same
|
||||||
@@ -2164,10 +2170,20 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
|
|||||||
b.suggestExitNodeLocked()
|
b.suggestExitNodeLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
if cn.NetMap() != nil && mutationsAreWorthyOfTellingIPNBus(muts) {
|
if cn.NetMap() == nil {
|
||||||
|
b.logf("[unexpected] got node mutations but netmap is nil; mutations not applied")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
nm := cn.netMapWithPeers()
|
if mutationsAreWorthyOfTellingIPNBus(muts) {
|
||||||
notify = &ipn.Notify{NetMap: nm}
|
// The notifier will strip the netmap based on the watchOpts mask if the watcher
|
||||||
|
// has indicated it can handle PeerChanges.
|
||||||
|
notify = &ipn.Notify{NetMap: cn.netMapWithPeers()}
|
||||||
|
if peerChanges, ok := ipnBusPeerChangesFromNodeMutations(muts); ok {
|
||||||
|
notify.PeerChanges = peerChanges
|
||||||
|
} else {
|
||||||
|
b.logf("[unexpected] got mutations worthy of telling IPN bus but failed to convert to peer changes")
|
||||||
|
}
|
||||||
} else if testenv.InTest() {
|
} else if testenv.InTest() {
|
||||||
// In tests, send an empty Notify as a wake-up so end-to-end
|
// In tests, send an empty Notify as a wake-up so end-to-end
|
||||||
// integration tests in another repo can check on the status of
|
// integration tests in another repo can check on the status of
|
||||||
@@ -2215,6 +2231,39 @@ func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutati
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ipnBusPeerChangesFromNodeMutations converts a slice of NodeMutations to a slice of
|
||||||
|
// *tailcfg.PeerChange for use in ipn.Notify.PeerChanges.
|
||||||
|
// Multiple mutations to the same node are merged into a single PeerChange.
|
||||||
|
// If we encounter any mutations that we cannot convert to a PeerChange, we return (nil, false)
|
||||||
|
// to indicate that the caller should send a Notify with the full netmap instead of
|
||||||
|
// trying to send granular peer changes.
|
||||||
|
func ipnBusPeerChangesFromNodeMutations(muts []netmap.NodeMutation) ([]*tailcfg.PeerChange, bool) {
|
||||||
|
byID := map[tailcfg.NodeID]*tailcfg.PeerChange{}
|
||||||
|
var ordered []*tailcfg.PeerChange
|
||||||
|
for _, m := range muts {
|
||||||
|
nid := m.NodeIDBeingMutated()
|
||||||
|
pc := byID[nid]
|
||||||
|
if pc == nil {
|
||||||
|
pc = &tailcfg.PeerChange{NodeID: nid}
|
||||||
|
byID[nid] = pc
|
||||||
|
ordered = append(ordered, pc)
|
||||||
|
}
|
||||||
|
switch v := m.(type) {
|
||||||
|
case netmap.NodeMutationOnline:
|
||||||
|
pc.Online = &v.Online
|
||||||
|
case netmap.NodeMutationLastSeen:
|
||||||
|
pc.LastSeen = &v.LastSeen
|
||||||
|
case netmap.NodeMutationDERPHome:
|
||||||
|
pc.DERPRegion = v.DERPRegion
|
||||||
|
case netmap.NodeMutationEndpoints:
|
||||||
|
pc.Endpoints = v.Endpoints
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered, true
|
||||||
|
}
|
||||||
|
|
||||||
// mutationsAreWorthyOfTellingIPNBus reports whether any mutation type in muts is
|
// mutationsAreWorthyOfTellingIPNBus reports whether any mutation type in muts is
|
||||||
// worthy of spamming the IPN bus (the Windows & Mac GUIs, basically) to tell them
|
// worthy of spamming the IPN bus (the Windows & Mac GUIs, basically) to tell them
|
||||||
// about the update.
|
// about the update.
|
||||||
@@ -3212,6 +3261,7 @@ func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.A
|
|||||||
owner: actor,
|
owner: actor,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
mask: mask,
|
||||||
}
|
}
|
||||||
mak.Set(&b.notifyWatchers, sessionID, session)
|
mak.Set(&b.notifyWatchers, sessionID, session)
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
@@ -3449,14 +3499,28 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, sess := range b.notifyWatchers {
|
for _, sess := range b.notifyWatchers {
|
||||||
if recipient.match(sess.owner) {
|
if !recipient.match(sess.owner) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nOut := &n
|
||||||
|
if n.PeerChanges != nil {
|
||||||
|
// Take a shallow copy of n so we can elide the PeerChanges or the Netmap
|
||||||
|
// based on the session's mask.
|
||||||
|
nOut = new(n)
|
||||||
|
if sess.mask&ipn.NotifyPeerChanges != 0 {
|
||||||
|
// Skip the full Netmap
|
||||||
|
nOut.NetMap = nil
|
||||||
|
} else {
|
||||||
|
// Skip the PeerChanges
|
||||||
|
nOut.PeerChanges = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case sess.ch <- &n:
|
case sess.ch <- nOut:
|
||||||
default:
|
default:
|
||||||
// Drop the notification if the channel is full.
|
// Drop the notification if the channel is full.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAuthURLLocked sets the authURL and triggers [LocalBackend.popBrowserAuthNow] if the URL has changed.
|
// setAuthURLLocked sets the authURL and triggers [LocalBackend.popBrowserAuthNow] if the URL has changed.
|
||||||
@@ -4904,6 +4968,30 @@ func (b *LocalBackend) setPortlistServices(sl []tailcfg.Service) {
|
|||||||
b.doSetHostinfoFilterServices()
|
b.doSetHostinfoFilterServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExplicitServices sets the services this node advertises on the netmap.
|
||||||
|
// Unlike the OS port-scan path (setPortlistServices), services set here are
|
||||||
|
// always uploaded to the control server regardless of the ShouldUploadServices
|
||||||
|
// hook — suitable for environments like browser WASM where OS port scanning is
|
||||||
|
// unavailable and services are declared programmatically.
|
||||||
|
func (b *LocalBackend) SetExplicitServices(sl []tailcfg.Service) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.hostinfo == nil {
|
||||||
|
b.hostinfo = new(tailcfg.Hostinfo)
|
||||||
|
}
|
||||||
|
b.hostinfo.Services = sl
|
||||||
|
b.explicitServices = sl
|
||||||
|
ccAuto := b.ccAuto
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
b.doSetHostinfoFilterServices()
|
||||||
|
// Restart the streaming map poll so the control server sends back a fresh
|
||||||
|
// netmap that includes our updated services in SelfNode, and so peers
|
||||||
|
// receive the update promptly via the control server's push.
|
||||||
|
if ccAuto != nil {
|
||||||
|
ccAuto.RestartMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
// doSetHostinfoFilterServices calls SetHostinfo on the controlclient,
|
||||||
// possibly after mangling the given hostinfo.
|
// possibly after mangling the given hostinfo.
|
||||||
//
|
//
|
||||||
@@ -4948,8 +5036,10 @@ func (b *LocalBackend) hostInfoWithServicesLocked() *tailcfg.Hostinfo {
|
|||||||
// Make a shallow copy of hostinfo so we can mutate
|
// Make a shallow copy of hostinfo so we can mutate
|
||||||
// at the Service field.
|
// at the Service field.
|
||||||
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
if f, ok := b.extHost.Hooks().ShouldUploadServices.GetOk(); !ok || !f() {
|
||||||
|
if len(b.explicitServices) == 0 {
|
||||||
hi.Services = []tailcfg.Service{}
|
hi.Services = []tailcfg.Service{}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't mutate hi.Service's underlying array. Append to
|
// Don't mutate hi.Service's underlying array. Append to
|
||||||
// the slice with no free capacity.
|
// the slice with no free capacity.
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify the control plane immediately so that changes to IngressEnabled /
|
||||||
|
// WireIngress (required for Funnel DNS provisioning) are not delayed until
|
||||||
|
// the next periodic heartbeat.
|
||||||
|
b.authReconfigLocked()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -18,28 +20,62 @@ type (
|
|||||||
ErrorOnKeys bool
|
ErrorOnKeys bool
|
||||||
ErrorOnServices bool
|
ErrorOnServices bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MockDeviceResource struct {
|
||||||
|
tsclient.DeviceResource
|
||||||
|
|
||||||
|
Error bool
|
||||||
|
}
|
||||||
|
|
||||||
|
MockKeyResource struct {
|
||||||
|
tsclient.KeyResource
|
||||||
|
|
||||||
|
Error bool
|
||||||
|
}
|
||||||
|
|
||||||
|
MockVIPServiceResource struct {
|
||||||
|
tsclient.VIPServiceResource
|
||||||
|
|
||||||
|
Error bool
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m MockTailnetClient) Devices(_ context.Context, _ *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error) {
|
func (m MockKeyResource) List(_ context.Context, _ bool) ([]tailscale.Key, error) {
|
||||||
if m.ErrorOnDevices {
|
if m.Error {
|
||||||
return nil, io.EOF
|
return nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m MockTailnetClient) Keys(_ context.Context) ([]string, error) {
|
func (m MockDeviceResource) List(_ context.Context, _ ...tailscale.ListDevicesOptions) ([]tailscale.Device, error) {
|
||||||
if m.ErrorOnKeys {
|
if m.Error {
|
||||||
return nil, io.EOF
|
return nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m MockTailnetClient) ListVIPServices(_ context.Context) (*tailscale.VIPServiceList, error) {
|
func (m MockVIPServiceResource) List(_ context.Context) ([]tailscale.VIPService, error) {
|
||||||
if m.ErrorOnServices {
|
if m.Error {
|
||||||
return nil, io.EOF
|
return nil, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m MockTailnetClient) Devices() tsclient.DeviceResource {
|
||||||
|
return MockDeviceResource{Error: m.ErrorOnDevices}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MockTailnetClient) Keys() tsclient.KeyResource {
|
||||||
|
return MockKeyResource{Error: m.ErrorOnKeys}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MockTailnetClient) VIPServices() tsclient.VIPServiceResource {
|
||||||
|
return MockVIPServiceResource{Error: m.ErrorOnServices}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MockTailnetClient) LoginURL() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/clientcredentials"
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -26,12 +25,13 @@ import (
|
|||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/manager"
|
"sigs.k8s.io/controller-runtime/pkg/manager"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
|
||||||
"tailscale.com/internal/client/tailscale"
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
operatorutils "tailscale.com/k8s-operator"
|
operatorutils "tailscale.com/k8s-operator"
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/k8s-operator/reconciler"
|
"tailscale.com/k8s-operator/reconciler"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/kube/kubetypes"
|
"tailscale.com/kube/kubetypes"
|
||||||
"tailscale.com/tstime"
|
"tailscale.com/tstime"
|
||||||
"tailscale.com/util/clientmetric"
|
"tailscale.com/util/clientmetric"
|
||||||
@@ -47,7 +47,8 @@ type (
|
|||||||
tailscaleNamespace string
|
tailscaleNamespace string
|
||||||
clock tstime.Clock
|
clock tstime.Clock
|
||||||
logger *zap.SugaredLogger
|
logger *zap.SugaredLogger
|
||||||
clientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient
|
clientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client
|
||||||
|
registry ClientRegistry
|
||||||
|
|
||||||
// Metrics related fields
|
// Metrics related fields
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -68,14 +69,18 @@ type (
|
|||||||
Logger *zap.SugaredLogger
|
Logger *zap.SugaredLogger
|
||||||
// ClientFunc is a function that takes tailscale credentials and returns an implementation for the Tailscale
|
// ClientFunc is a function that takes tailscale credentials and returns an implementation for the Tailscale
|
||||||
// HTTP API. This should generally be nil unless needed for testing.
|
// HTTP API. This should generally be nil unless needed for testing.
|
||||||
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) TailscaleClient
|
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client
|
||||||
|
// Registry is used to store and share initialized tailscale clients for use by other reconcilers.
|
||||||
|
Registry ClientRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
// The TailscaleClient interface describes types that interact with the Tailscale HTTP API.
|
// The ClientRegistry interface describes types that can store initialized tailscale clients for use by other
|
||||||
TailscaleClient interface {
|
// reconcilers.
|
||||||
Devices(context.Context, *tailscale.DeviceFieldsOpts) ([]*tailscale.Device, error)
|
ClientRegistry interface {
|
||||||
Keys(ctx context.Context) ([]string, error)
|
// Add should store the given tsclient.Client implementation for a specified tailnet.
|
||||||
ListVIPServices(ctx context.Context) (*tailscale.VIPServiceList, error)
|
Add(tailnet string, client tsclient.Client, ready bool)
|
||||||
|
// Remove should remove any tsclient.Client implementation for a specified tailnet.
|
||||||
|
Remove(tailnet string)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,6 +95,7 @@ func NewReconciler(options ReconcilerOptions) *Reconciler {
|
|||||||
clock: options.Clock,
|
clock: options.Clock,
|
||||||
logger: options.Logger.Named(reconcilerName),
|
logger: options.Logger.Named(reconcilerName),
|
||||||
clientFunc: options.ClientFunc,
|
clientFunc: options.ClientFunc,
|
||||||
|
registry: options.Registry,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,7 @@ func (r *Reconciler) delete(ctx context.Context, tailnet *tsapi.Tailnet) (reconc
|
|||||||
r.tailnets.Remove(tailnet.UID)
|
r.tailnets.Remove(tailnet.UID)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
gaugeTailnetResources.Set(int64(r.tailnets.Len()))
|
gaugeTailnetResources.Set(int64(r.tailnets.Len()))
|
||||||
|
r.registry.Remove(tailnet.Name)
|
||||||
|
|
||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
@@ -193,11 +200,16 @@ func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet)
|
|||||||
return reconcile.Result{RequeueAfter: time.Minute / 2}, nil
|
return reconcile.Result{RequeueAfter: time.Minute / 2}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tsClient := r.createClient(ctx, tailnet, &secret)
|
tsClient, err := r.createClient(tailnet, &secret)
|
||||||
|
if err != nil {
|
||||||
|
return reconcile.Result{}, fmt.Errorf("failed to create tailnet client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Second, we ensure the OAuth credentials supplied in the secret are valid and have the required scopes to access
|
// Second, we ensure the OAuth credentials supplied in the secret are valid and have the required scopes to access
|
||||||
// the various API endpoints required by the operator.
|
// the various API endpoints required by the operator.
|
||||||
if ok := r.ensurePermissions(ctx, tsClient, tailnet); !ok {
|
if ok := r.ensurePermissions(ctx, tsClient, tailnet); !ok {
|
||||||
|
r.registry.Add(tailnet.Name, tsClient, false)
|
||||||
|
|
||||||
if err = r.Status().Update(ctx, tailnet); err != nil {
|
if err = r.Status().Update(ctx, tailnet); err != nil {
|
||||||
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
return reconcile.Result{}, fmt.Errorf("failed to update Tailnet status for %q: %w", tailnet.Name, err)
|
||||||
}
|
}
|
||||||
@@ -226,6 +238,8 @@ func (r *Reconciler) createOrUpdate(ctx context.Context, tailnet *tsapi.Tailnet)
|
|||||||
return reconcile.Result{}, fmt.Errorf("failed to add finalizer to Tailnet %q: %w", tailnet.Name, err)
|
return reconcile.Result{}, fmt.Errorf("failed to add finalizer to Tailnet %q: %w", tailnet.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
r.registry.Add(tailnet.Name, tsClient, true)
|
||||||
|
|
||||||
return reconcile.Result{}, nil
|
return reconcile.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,9 +249,9 @@ const (
|
|||||||
clientSecretKey = "client_secret"
|
clientSecretKey = "client_secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Reconciler) createClient(ctx context.Context, tailnet *tsapi.Tailnet, secret *corev1.Secret) TailscaleClient {
|
func (r *Reconciler) createClient(tailnet *tsapi.Tailnet, secret *corev1.Secret) (tsclient.Client, error) {
|
||||||
if r.clientFunc != nil {
|
if r.clientFunc != nil {
|
||||||
return r.clientFunc(tailnet, secret)
|
return r.clientFunc(tailnet, secret), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := ipn.DefaultControlURL
|
baseURL := ipn.DefaultControlURL
|
||||||
@@ -245,38 +259,36 @@ func (r *Reconciler) createClient(ctx context.Context, tailnet *tsapi.Tailnet, s
|
|||||||
baseURL = tailnet.Spec.LoginURL
|
baseURL = tailnet.Spec.LoginURL
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials := clientcredentials.Config{
|
base, err := url.Parse(baseURL)
|
||||||
ClientID: string(secret.Data[clientIDKey]),
|
if err != nil {
|
||||||
ClientSecret: string(secret.Data[clientSecretKey]),
|
return nil, fmt.Errorf("failed to parse base URL %q: %w", baseURL, err)
|
||||||
TokenURL: baseURL + "/api/v2/oauth/token",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
source := credentials.TokenSource(ctx)
|
return tsclient.Wrap(&tailscale.Client{
|
||||||
httpClient := oauth2.NewClient(ctx, source)
|
BaseURL: base,
|
||||||
|
UserAgent: "tailscale-k8s-operator",
|
||||||
tsClient := tailscale.NewClient("-", nil)
|
Auth: &tailscale.OAuth{
|
||||||
tsClient.UserAgent = "tailscale-k8s-operator"
|
ClientID: string(secret.Data[clientIDKey]),
|
||||||
tsClient.HTTPClient = httpClient
|
ClientSecret: string(secret.Data[clientSecretKey]),
|
||||||
tsClient.BaseURL = baseURL
|
},
|
||||||
|
}), nil
|
||||||
return tsClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient TailscaleClient, tailnet *tsapi.Tailnet) bool {
|
func (r *Reconciler) ensurePermissions(ctx context.Context, tsClient tsclient.Client, tailnet *tsapi.Tailnet) bool {
|
||||||
// Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource
|
// Perform basic list requests here to confirm that the OAuth credentials referenced on the Tailnet resource
|
||||||
// can perform the basic operations required for the operator to function. This has a caveat of only performing
|
// can perform the basic operations required for the operator to function. This has a caveat of only performing
|
||||||
// read actions, as we don't want to create arbitrary keys and VIP services. However, it will catch when a user
|
// read actions, as we don't want to create arbitrary keys and VIP services. However, it will catch when a user
|
||||||
// has completely forgotten an entire scope that's required.
|
// has completely forgotten an entire scope that's required.
|
||||||
var errs error
|
var errs error
|
||||||
if _, err := tsClient.Devices(ctx, nil); err != nil {
|
if _, err := tsClient.Devices().List(ctx); err != nil {
|
||||||
errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w", err))
|
errs = errors.Join(errs, fmt.Errorf("failed to list devices: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tsClient.Keys(ctx); err != nil {
|
if _, err := tsClient.Keys().List(ctx, false); err != nil {
|
||||||
errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w", err))
|
errs = errors.Join(errs, fmt.Errorf("failed to list auth keys: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tsClient.ListVIPServices(ctx); err != nil {
|
if _, err := tsClient.VIPServices().List(ctx); err != nil {
|
||||||
errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w", err))
|
errs = errors.Join(errs, fmt.Errorf("failed to list tailscale services: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
||||||
"tailscale.com/k8s-operator/reconciler/tailnet"
|
"tailscale.com/k8s-operator/reconciler/tailnet"
|
||||||
|
"tailscale.com/k8s-operator/tsclient"
|
||||||
"tailscale.com/tstest"
|
"tailscale.com/tstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
Secret *corev1.Secret
|
Secret *corev1.Secret
|
||||||
ExpectsError bool
|
ExpectsError bool
|
||||||
ExpectedConditions []metav1.Condition
|
ExpectedConditions []metav1.Condition
|
||||||
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tailnet.TailscaleClient
|
ClientFunc func(*tsapi.Tailnet, *corev1.Secret) tsclient.Client
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
Name: "ignores-unknown-tailnet-requests",
|
Name: "ignores-unknown-tailnet-requests",
|
||||||
@@ -201,7 +202,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
"client_secret": []byte("test"),
|
"client_secret": []byte("test"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tsclient.Client {
|
||||||
return &MockTailnetClient{ErrorOnDevices: true}
|
return &MockTailnetClient{ErrorOnDevices: true}
|
||||||
},
|
},
|
||||||
ExpectedConditions: []metav1.Condition{
|
ExpectedConditions: []metav1.Condition{
|
||||||
@@ -240,7 +241,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
"client_secret": []byte("test"),
|
"client_secret": []byte("test"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tsclient.Client {
|
||||||
return &MockTailnetClient{ErrorOnServices: true}
|
return &MockTailnetClient{ErrorOnServices: true}
|
||||||
},
|
},
|
||||||
ExpectedConditions: []metav1.Condition{
|
ExpectedConditions: []metav1.Condition{
|
||||||
@@ -279,7 +280,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
"client_secret": []byte("test"),
|
"client_secret": []byte("test"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tsclient.Client {
|
||||||
return &MockTailnetClient{ErrorOnKeys: true}
|
return &MockTailnetClient{ErrorOnKeys: true}
|
||||||
},
|
},
|
||||||
ExpectedConditions: []metav1.Condition{
|
ExpectedConditions: []metav1.Condition{
|
||||||
@@ -318,7 +319,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
"client_secret": []byte("test"),
|
"client_secret": []byte("test"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tailnet.TailscaleClient {
|
ClientFunc: func(_ *tsapi.Tailnet, _ *corev1.Secret) tsclient.Client {
|
||||||
return &MockTailnetClient{}
|
return &MockTailnetClient{}
|
||||||
},
|
},
|
||||||
ExpectedConditions: []metav1.Condition{
|
ExpectedConditions: []metav1.Condition{
|
||||||
@@ -349,6 +350,7 @@ func TestReconciler_Reconcile(t *testing.T) {
|
|||||||
Logger: logger.Sugar(),
|
Logger: logger.Sugar(),
|
||||||
ClientFunc: tc.ClientFunc,
|
ClientFunc: tc.ClientFunc,
|
||||||
TailscaleNamespace: "tailscale",
|
TailscaleNamespace: "tailscale",
|
||||||
|
Registry: tsclient.NewProvider(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
reconciler := tailnet.NewReconciler(opts)
|
reconciler := tailnet.NewReconciler(opts)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// Package tsclient provides a mockable wrapper around the tailscale-client-go-v2 package for use by the Kubernetes
|
||||||
|
// operator. It also contains the Provider type used to manage multiple instances of tailscale clients for different
|
||||||
|
// tailnets.
|
||||||
|
package tsclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// The Client interface describes types that interact with the Tailscale API.
|
||||||
|
Client interface {
|
||||||
|
// LoginURL should return the url of the Tailscale control plane.
|
||||||
|
LoginURL() string
|
||||||
|
// Devices should return a DeviceResource implementation used to interact with the devices API.
|
||||||
|
Devices() DeviceResource
|
||||||
|
// Keys should return a KeyResource implementation used to interact with the keys API.
|
||||||
|
Keys() KeyResource
|
||||||
|
// VIPServices should return a VIPServiceResource implementation used to interact with the VIP services API.
|
||||||
|
VIPServices() VIPServiceResource
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DeviceResource interface describes types that expose device related API endpoints.
|
||||||
|
DeviceResource interface {
|
||||||
|
// Delete should delete a device with a matching id.
|
||||||
|
Delete(ctx context.Context, id string) error
|
||||||
|
// List should return all devices based on the specified options.
|
||||||
|
List(ctx context.Context, opts ...tailscale.ListDevicesOptions) ([]tailscale.Device, error)
|
||||||
|
// Get should return the device with the matching identifier.
|
||||||
|
Get(ctx context.Context, id string) (*tailscale.Device, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The KeyResource interface describes types that expose key related API endpoints.
|
||||||
|
KeyResource interface {
|
||||||
|
// CreateAuthKey should create and return a new auth key used to authenticate a device.
|
||||||
|
CreateAuthKey(ctx context.Context, ckr tailscale.CreateKeyRequest) (*tailscale.Key, error)
|
||||||
|
// List should return keys created by the caller or all keys if the provided boolean is set to true.
|
||||||
|
List(ctx context.Context, all bool) ([]tailscale.Key, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The VIPServiceResource interface describes types that expose vip service related API endpoints.
|
||||||
|
VIPServiceResource interface {
|
||||||
|
// List should return all existing vip services within the tailnet.
|
||||||
|
List(ctx context.Context) ([]tailscale.VIPService, error)
|
||||||
|
// Delete should remove a named service from the tailnet.
|
||||||
|
Delete(ctx context.Context, name string) error
|
||||||
|
// Get should return the vip service associated with the given name.
|
||||||
|
Get(ctx context.Context, name string) (*tailscale.VIPService, error)
|
||||||
|
// CreateOrUpdate should update the provided vip service, creating it if it does not exist.
|
||||||
|
CreateOrUpdate(ctx context.Context, svc tailscale.VIPService) error
|
||||||
|
}
|
||||||
|
|
||||||
|
clientWrapper struct {
|
||||||
|
loginURL string
|
||||||
|
client *tailscale.Client
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wrap converts a given tailscale.Client into a Client.
|
||||||
|
func Wrap(client *tailscale.Client) Client {
|
||||||
|
return &clientWrapper{client: client, loginURL: client.BaseURL.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientWrapper) Devices() DeviceResource {
|
||||||
|
return c.client.Devices()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientWrapper) Keys() KeyResource {
|
||||||
|
return c.client.Keys()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientWrapper) VIPServices() VIPServiceResource {
|
||||||
|
return c.client.VIPServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clientWrapper) LoginURL() string {
|
||||||
|
return c.loginURL
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package tsclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// The Provider type is used to manage multiple Client implementations for different tailnets.
|
||||||
|
Provider struct {
|
||||||
|
defaultClient Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[string]Client
|
||||||
|
readiness map[string]bool
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrClientNotFound is the error given when calling Provider.For with a tailnet that has not yet been registered
|
||||||
|
// with the provider.
|
||||||
|
ErrClientNotFound = errors.New("client not found")
|
||||||
|
// ErrNotReady is the error given when calling Provider.For with a tailnet that has not yet been declared as
|
||||||
|
// ready to use by the operator.
|
||||||
|
ErrNotReady = errors.New("tailnet not ready")
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewProvider returns a new instance of the Provider type that uses the given Client implementation as the default
|
||||||
|
// client. This client will be given when calling Provider.For with a blank tailnet name.
|
||||||
|
func NewProvider(defaultClient Client) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
defaultClient: defaultClient,
|
||||||
|
clients: make(map[string]Client),
|
||||||
|
readiness: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a Client implementation for a given tailnet.
|
||||||
|
func (p *Provider) Add(tailnet string, client Client, ready bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.clients[tailnet] = client
|
||||||
|
p.readiness[tailnet] = ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the Client implementation associated with the given tailnet.
|
||||||
|
func (p *Provider) Remove(tailnet string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
delete(p.clients, tailnet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For returns a Client implementation associated with the given tailnet. Returns ErrClientNotFound if the given
|
||||||
|
// tailnet does not exist. Use a blank tailnet name to obtain the default Client.
|
||||||
|
func (p *Provider) For(tailnet string) (Client, error) {
|
||||||
|
if tailnet == "" {
|
||||||
|
return p.defaultClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
|
if client, ok := p.clients[tailnet]; ok {
|
||||||
|
if ready, _ := p.readiness[tailnet]; !ready {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrNotReady, tailnet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrClientNotFound, tailnet)
|
||||||
|
}
|
||||||
@@ -5,15 +5,22 @@ package safesocket
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/akutz/memconn"
|
"github.com/akutz/memconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const memName = "Tailscale-IPN"
|
const memName = "Tailscale-IPN"
|
||||||
|
|
||||||
|
// memSeq ensures each IPN instance in the same WASM process gets a distinct
|
||||||
|
// memconn address, so concurrent instances do not conflict on the registry.
|
||||||
|
var memSeq atomic.Int64
|
||||||
|
|
||||||
func listen(path string) (net.Listener, error) {
|
func listen(path string) (net.Listener, error) {
|
||||||
return memconn.Listen("memu", memName)
|
name := fmt.Sprintf("%s-%d", memName, memSeq.Add(1))
|
||||||
|
return memconn.Listen("memu", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
func connect(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
|||||||
@@ -16,4 +16,4 @@
|
|||||||
) {
|
) {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
}).shellNix
|
}).shellNix
|
||||||
# nix-direnv cache busting line: sha256-GB5riRI9hkutLc2wBzv2jil+Tf6fogLxUw54HRSPNUk=
|
# nix-direnv cache busting line: sha256-aZkUnWyQokNw+lxut9Fak3CazmwYE4tXILhzfK4jeK4=
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ func defaultPathForUser(u *user.User) string {
|
|||||||
if isRoot {
|
if isRoot {
|
||||||
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
}
|
}
|
||||||
return "/usr/local/bin:/usr/bin:/bin:/usr/bn/games"
|
return "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
|
||||||
case distro.NixOS:
|
case distro.NixOS:
|
||||||
return defaultPathForUserOnNixOS(u)
|
return defaultPathForUserOnNixOS(u)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// createCloudInitISO creates a cidata seed ISO for the given cloud VM node.
|
// createCloudInitISO creates a cidata seed ISO for the given cloud VM node.
|
||||||
// The ISO contains meta-data, user-data, and network-config files.
|
// For Linux VMs, the ISO contains meta-data, user-data, and network-config.
|
||||||
// Cloud-init reads these during init-local (pre-network), which is critical
|
// For FreeBSD VMs, the ISO contains meta-data and user-data only (nuageinit
|
||||||
// for network-config to take effect before systemd-networkd-wait-online runs.
|
// doesn't use netplan-style network-config; DHCP is enabled in rc.conf).
|
||||||
func (e *Env) createCloudInitISO(n *Node) (string, error) {
|
func (e *Env) createCloudInitISO(n *Node) (string, error) {
|
||||||
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", n.name, n.name)
|
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", n.name, n.name)
|
||||||
userData := e.generateUserData(n)
|
userData := e.generateUserData(n)
|
||||||
|
|
||||||
// Network config: DHCP all ethernet interfaces.
|
files := map[string]string{
|
||||||
// The "optional: true" prevents systemd-networkd-wait-online from blocking.
|
"meta-data": metaData,
|
||||||
// The first vnet NIC gets the default route (metric 100).
|
"user-data": userData,
|
||||||
// Other interfaces get higher metrics to avoid routing conflicts.
|
}
|
||||||
networkConfig := `version: 2
|
|
||||||
|
// Linux cloud-init needs network-config to configure interfaces before
|
||||||
|
// systemd-networkd-wait-online blocks boot.
|
||||||
|
if n.os.GOOS() == "linux" {
|
||||||
|
files["network-config"] = `version: 2
|
||||||
ethernets:
|
ethernets:
|
||||||
primary:
|
primary:
|
||||||
match:
|
match:
|
||||||
@@ -41,6 +45,7 @@ ethernets:
|
|||||||
route-metric: 200
|
route-metric: 200
|
||||||
optional: true
|
optional: true
|
||||||
`
|
`
|
||||||
|
}
|
||||||
|
|
||||||
iw, err := iso9660.NewWriter()
|
iw, err := iso9660.NewWriter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,11 +53,7 @@ ethernets:
|
|||||||
}
|
}
|
||||||
defer iw.Cleanup()
|
defer iw.Cleanup()
|
||||||
|
|
||||||
for name, content := range map[string]string{
|
for name, content := range files {
|
||||||
"meta-data": metaData,
|
|
||||||
"user-data": userData,
|
|
||||||
"network-config": networkConfig,
|
|
||||||
} {
|
|
||||||
if err := iw.AddFile(strings.NewReader(content), name); err != nil {
|
if err := iw.AddFile(strings.NewReader(content), name); err != nil {
|
||||||
return "", fmt.Errorf("adding %s to ISO: %w", name, err)
|
return "", fmt.Errorf("adding %s to ISO: %w", name, err)
|
||||||
}
|
}
|
||||||
@@ -72,6 +73,18 @@ ethernets:
|
|||||||
|
|
||||||
// generateUserData creates the cloud-init user-data (#cloud-config) for a node.
|
// generateUserData creates the cloud-init user-data (#cloud-config) for a node.
|
||||||
func (e *Env) generateUserData(n *Node) string {
|
func (e *Env) generateUserData(n *Node) string {
|
||||||
|
switch n.os.GOOS() {
|
||||||
|
case "linux":
|
||||||
|
return e.generateLinuxUserData(n)
|
||||||
|
case "freebsd":
|
||||||
|
return e.generateFreeBSDUserData(n)
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("unsupported GOOS %q for cloud-init user-data", n.os.GOOS()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateLinuxUserData creates Linux cloud-init user-data (#cloud-config) for a node.
|
||||||
|
func (e *Env) generateLinuxUserData(n *Node) string {
|
||||||
var ud strings.Builder
|
var ud strings.Builder
|
||||||
ud.WriteString("#cloud-config\n")
|
ud.WriteString("#cloud-config\n")
|
||||||
|
|
||||||
@@ -95,8 +108,9 @@ func (e *Env) generateUserData(n *Node) string {
|
|||||||
|
|
||||||
// Download binaries from the files.tailscale VIP (52.52.0.6).
|
// Download binaries from the files.tailscale VIP (52.52.0.6).
|
||||||
// Use the IP directly to avoid DNS resolution issues during early boot.
|
// Use the IP directly to avoid DNS resolution issues during early boot.
|
||||||
|
binDir := n.os.GOOS() + "_" + n.os.GOARCH()
|
||||||
for _, bin := range []string{"tailscaled", "tailscale", "tta"} {
|
for _, bin := range []string{"tailscaled", "tailscale", "tta"} {
|
||||||
fmt.Fprintf(&ud, " - [\"/bin/sh\", \"-c\", \"curl -v --retry 10 --retry-delay 2 --retry-all-errors -o /usr/local/bin/%s http://52.52.0.6/%s 2>&1\"]\n", bin, bin)
|
fmt.Fprintf(&ud, " - [\"/bin/sh\", \"-c\", \"curl -v --retry 10 --retry-delay 2 --retry-all-errors -o /usr/local/bin/%s http://52.52.0.6/%s/%s 2>&1\"]\n", bin, binDir, bin)
|
||||||
}
|
}
|
||||||
ud.WriteString(" - [\"chmod\", \"+x\", \"/usr/local/bin/tailscaled\", \"/usr/local/bin/tailscale\", \"/usr/local/bin/tta\"]\n")
|
ud.WriteString(" - [\"chmod\", \"+x\", \"/usr/local/bin/tailscaled\", \"/usr/local/bin/tailscale\", \"/usr/local/bin/tta\"]\n")
|
||||||
|
|
||||||
@@ -115,3 +129,55 @@ func (e *Env) generateUserData(n *Node) string {
|
|||||||
|
|
||||||
return ud.String()
|
return ud.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateFreeBSDUserData creates FreeBSD nuageinit user-data (#cloud-config)
|
||||||
|
// for a node. FreeBSD's nuageinit supports a subset of cloud-init directives
|
||||||
|
// including runcmd, which runs after networking is up.
|
||||||
|
//
|
||||||
|
// IMPORTANT: nuageinit's runcmd only supports string entries, not the YAML
|
||||||
|
// array form that Linux cloud-init supports. Each entry must be a plain string
|
||||||
|
// that gets passed to /bin/sh -c.
|
||||||
|
func (e *Env) generateFreeBSDUserData(n *Node) string {
|
||||||
|
var ud strings.Builder
|
||||||
|
ud.WriteString("#cloud-config\n")
|
||||||
|
ud.WriteString("ssh_pwauth: true\n")
|
||||||
|
|
||||||
|
ud.WriteString("runcmd:\n")
|
||||||
|
|
||||||
|
// /usr/local/bin may not exist on a fresh FreeBSD cloud image (it's
|
||||||
|
// created when the first package is installed).
|
||||||
|
ud.WriteString(" - \"mkdir -p /usr/local/bin\"\n")
|
||||||
|
|
||||||
|
// Remove the default route via the debug NIC's SLIRP gateway so that
|
||||||
|
// traffic goes through the vnet NICs. The debug NIC is only for SSH.
|
||||||
|
ud.WriteString(" - \"route delete default 10.0.2.2 2>/dev/null || true\"\n")
|
||||||
|
|
||||||
|
// Download binaries from the files.tailscale VIP (52.52.0.6).
|
||||||
|
// FreeBSD's fetch(1) is part of the base system (no curl needed).
|
||||||
|
// Retry in a loop since the file server may not be ready immediately.
|
||||||
|
binDir := n.os.GOOS() + "_" + n.os.GOARCH()
|
||||||
|
for _, bin := range []string{"tailscaled", "tailscale", "tta"} {
|
||||||
|
fmt.Fprintf(&ud, " - \"n=0; while [ $n -lt 10 ]; do fetch -o /usr/local/bin/%s http://52.52.0.6/%s/%s && break; n=$((n+1)); sleep 2; done\"\n", bin, binDir, bin)
|
||||||
|
}
|
||||||
|
ud.WriteString(" - \"chmod +x /usr/local/bin/tailscaled /usr/local/bin/tailscale /usr/local/bin/tta\"\n")
|
||||||
|
|
||||||
|
// Enable IP forwarding for subnet routers.
|
||||||
|
// This is currently a noop as of 2026-04-08 because FreeBSD uses
|
||||||
|
// gvisor netstack for subnet routing until
|
||||||
|
// https://github.com/tailscale/tailscale/issues/5573 etc are fixed.
|
||||||
|
if n.advertiseRoutes != "" {
|
||||||
|
ud.WriteString(" - \"sysctl net.inet.ip.forwarding=1\"\n")
|
||||||
|
ud.WriteString(" - \"sysctl net.inet6.ip6.forwarding=1\"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start tailscaled and tta in the background.
|
||||||
|
// Set PATH to include /usr/local/bin so that tta can find "tailscale"
|
||||||
|
// (TTA uses exec.Command("tailscale", ...) without a full path).
|
||||||
|
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tailscaled --state=mem: &\"\n")
|
||||||
|
ud.WriteString(" - \"sleep 2\"\n")
|
||||||
|
|
||||||
|
// Start tta (Tailscale Test Agent).
|
||||||
|
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tta &\"\n")
|
||||||
|
|
||||||
|
return ud.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,17 +14,36 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ulikunitz/xz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OSImage describes a VM operating system image.
|
// OSImage describes a VM operating system image.
|
||||||
type OSImage struct {
|
type OSImage struct {
|
||||||
Name string
|
Name string
|
||||||
URL string // download URL for the cloud image
|
URL string // download URL for the cloud image
|
||||||
SHA256 string // expected SHA256 hash of the image
|
SHA256 string // expected SHA256 hash of the image (of the final qcow2, after any decompression)
|
||||||
MemoryMB int // RAM for the VM
|
MemoryMB int // RAM for the VM
|
||||||
IsGokrazy bool // true for gokrazy images (different QEMU setup)
|
IsGokrazy bool // true for gokrazy images (different QEMU setup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GOOS returns the Go OS name for this image.
|
||||||
|
func (img OSImage) GOOS() string {
|
||||||
|
if img.IsGokrazy {
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(img.Name, "freebsd") {
|
||||||
|
return "freebsd"
|
||||||
|
}
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOARCH returns the Go architecture name for this image.
|
||||||
|
func (img OSImage) GOARCH() string {
|
||||||
|
return "amd64"
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Gokrazy is a minimal Tailscale appliance image built from the gokrazy/natlabapp directory.
|
// Gokrazy is a minimal Tailscale appliance image built from the gokrazy/natlabapp directory.
|
||||||
Gokrazy = OSImage{
|
Gokrazy = OSImage{
|
||||||
@@ -46,6 +65,14 @@ var (
|
|||||||
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
|
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
|
||||||
MemoryMB: 1024,
|
MemoryMB: 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FreeBSD150 is FreeBSD 15.0-RELEASE with BASIC-CLOUDINIT (nuageinit) support.
|
||||||
|
// The image is distributed as xz-compressed qcow2.
|
||||||
|
FreeBSD150 = OSImage{
|
||||||
|
Name: "freebsd-15.0",
|
||||||
|
URL: "https://download.freebsd.org/releases/VM-IMAGES/15.0-RELEASE/amd64/Latest/FreeBSD-15.0-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz",
|
||||||
|
MemoryMB: 1024,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// imageCacheDir returns the directory for cached VM images.
|
// imageCacheDir returns the directory for cached VM images.
|
||||||
@@ -84,6 +111,7 @@ func ensureImage(ctx context.Context, img OSImage) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isXZ := strings.HasSuffix(img.URL, ".xz")
|
||||||
log.Printf("downloading %s from %s...", img.Name, img.URL)
|
log.Printf("downloading %s from %s...", img.Name, img.URL)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", img.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", img.URL, nil)
|
||||||
@@ -99,6 +127,16 @@ func ensureImage(ctx context.Context, img OSImage) error {
|
|||||||
return fmt.Errorf("downloading %s: HTTP %s", img.Name, resp.Status)
|
return fmt.Errorf("downloading %s: HTTP %s", img.Name, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up the reader pipeline: HTTP body → (optional xz decompress) → file.
|
||||||
|
var src io.Reader = resp.Body
|
||||||
|
if isXZ {
|
||||||
|
xzr, err := xz.NewReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating xz reader for %s: %w", img.Name, err)
|
||||||
|
}
|
||||||
|
src = xzr
|
||||||
|
}
|
||||||
|
|
||||||
tmpFile := cachedPath + ".tmp"
|
tmpFile := cachedPath + ".tmp"
|
||||||
f, err := os.Create(tmpFile)
|
f, err := os.Create(tmpFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,8 +149,7 @@ func ensureImage(ctx context.Context, img OSImage) error {
|
|||||||
|
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
w := io.MultiWriter(f, h)
|
w := io.MultiWriter(f, h)
|
||||||
|
if _, err := io.Copy(w, src); err != nil {
|
||||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
|
||||||
return fmt.Errorf("downloading %s: %w", img.Name, err)
|
return fmt.Errorf("downloading %s: %w", img.Name, err)
|
||||||
}
|
}
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (e *Env) startGokrazyQEMU(n *Node) error {
|
|||||||
return e.launchQEMU(n.name, logPath, args)
|
return e.launchQEMU(n.name, logPath, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startCloudQEMU launches a QEMU process for a cloud image (Ubuntu, Debian, etc).
|
// startCloudQEMU launches a QEMU process for a cloud image (Ubuntu, Debian, FreeBSD, etc).
|
||||||
func (e *Env) startCloudQEMU(n *Node) error {
|
func (e *Env) startCloudQEMU(n *Node) error {
|
||||||
basePath := cachedImagePath(n.os)
|
basePath := cachedImagePath(n.os)
|
||||||
disk := filepath.Join(e.tempDir, fmt.Sprintf("%s.qcow2", n.name))
|
disk := filepath.Join(e.tempDir, fmt.Sprintf("%s.qcow2", n.name))
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -185,17 +184,22 @@ func (e *Env) Start() {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if we have any non-gokrazy "cloud" images (e.g. Ubuntu, Debian)
|
// Determine which GOOS/GOARCH pairs need compiled binaries (non-gokrazy
|
||||||
// that require compiled binaries pushed into their image later. (Gokrazy
|
// images). Gokrazy has binaries built-in, so doesn't need compilation.
|
||||||
// has them built-in, so doesn't need the compileBinaries step.)
|
type platform struct{ goos, goarch string }
|
||||||
needBuildBinaries := slices.ContainsFunc(e.nodes, func(n *Node) bool { return !n.os.IsGokrazy })
|
needPlatform := set.Set[platform]{}
|
||||||
|
for _, n := range e.nodes {
|
||||||
|
if !n.os.IsGokrazy {
|
||||||
|
needPlatform.Add(platform{n.os.GOOS(), n.os.GOARCH()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compile binaries and download/build images in parallel.
|
// Compile binaries and download/build images in parallel.
|
||||||
// Any failure cancels the others via the errgroup context.
|
// Any failure cancels the others via the errgroup context.
|
||||||
eg, egCtx := errgroup.WithContext(ctx)
|
eg, egCtx := errgroup.WithContext(ctx)
|
||||||
if needBuildBinaries {
|
for _, p := range needPlatform.Slice() {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
return e.compileBinaries(egCtx)
|
return e.compileBinariesForOS(egCtx, p.goos, p.goarch)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
didOS := set.Set[string]{} // dedup by image name
|
didOS := set.Set[string]{} // dedup by image name
|
||||||
@@ -227,13 +231,15 @@ func (e *Env) Start() {
|
|||||||
t.Cleanup(func() { e.server.Close() })
|
t.Cleanup(func() { e.server.Close() })
|
||||||
|
|
||||||
// Register compiled binaries with the file server VIP.
|
// Register compiled binaries with the file server VIP.
|
||||||
if needBuildBinaries {
|
// Binaries are registered at <goos>_<goarch>/<name> (e.g. "linux_amd64/tta").
|
||||||
|
for _, p := range needPlatform.Slice() {
|
||||||
|
dir := p.goos + "_" + p.goarch
|
||||||
for _, name := range []string{"tta", "tailscale", "tailscaled"} {
|
for _, name := range []string{"tta", "tailscale", "tailscaled"} {
|
||||||
data, err := os.ReadFile(filepath.Join(e.binDir, name))
|
data, err := os.ReadFile(filepath.Join(e.binDir, dir, name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reading compiled %s: %v", name, err)
|
t.Fatalf("reading compiled %s/%s: %v", dir, name, err)
|
||||||
}
|
}
|
||||||
e.server.RegisterFile(name, data)
|
e.server.RegisterFile(dir+"/"+name, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,14 +609,20 @@ func (e *Env) ensureGokrazy(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// compileBinaries cross-compiles tta, tailscale, and tailscaled for linux/amd64
|
// compileBinariesForOS cross-compiles tta, tailscale, and tailscaled for the
|
||||||
// and places them in e.binDir.
|
// given GOOS/GOARCH and places them in e.binDir/<goos>_<goarch>/.
|
||||||
func (e *Env) compileBinaries(ctx context.Context) error {
|
func (e *Env) compileBinariesForOS(ctx context.Context, goos, goarch string) error {
|
||||||
modRoot, err := findModRoot()
|
modRoot, err := findModRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dir := goos + "_" + goarch
|
||||||
|
outDir := filepath.Join(e.binDir, dir)
|
||||||
|
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
binaries := []struct{ name, pkg string }{
|
binaries := []struct{ name, pkg string }{
|
||||||
{"tta", "./cmd/tta"},
|
{"tta", "./cmd/tta"},
|
||||||
{"tailscale", "./cmd/tailscale"},
|
{"tailscale", "./cmd/tailscale"},
|
||||||
@@ -620,15 +632,15 @@ func (e *Env) compileBinaries(ctx context.Context) error {
|
|||||||
var eg errgroup.Group
|
var eg errgroup.Group
|
||||||
for _, bin := range binaries {
|
for _, bin := range binaries {
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
outPath := filepath.Join(e.binDir, bin.name)
|
outPath := filepath.Join(outDir, bin.name)
|
||||||
e.t.Logf("compiling %s...", bin.name)
|
e.t.Logf("compiling %s/%s...", dir, bin.name)
|
||||||
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, bin.pkg)
|
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, bin.pkg)
|
||||||
cmd.Dir = modRoot
|
cmd.Dir = modRoot
|
||||||
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0")
|
cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch, "CGO_ENABLED=0")
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("building %s: %v\n%s", bin.name, err, out)
|
return fmt.Errorf("building %s/%s: %v\n%s", dir, bin.name, err, out)
|
||||||
}
|
}
|
||||||
e.t.Logf("compiled %s", bin.name)
|
e.t.Logf("compiled %s/%s", dir, bin.name)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSubnetRouter(t *testing.T) {
|
func TestSubnetRouter(t *testing.T) {
|
||||||
|
testSubnetRouterForOS(t, vmtest.Ubuntu2404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubnetRouterFreeBSD(t *testing.T) {
|
||||||
|
testSubnetRouterForOS(t, vmtest.FreeBSD150)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubnetRouterForOS(t testing.TB, srOS vmtest.OSImage) {
|
||||||
|
t.Helper()
|
||||||
env := vmtest.New(t)
|
env := vmtest.New(t)
|
||||||
|
|
||||||
clientNet := env.AddNetwork("2.1.1.1", "192.168.1.1/24", "2000:1::1/64", vnet.EasyNAT)
|
clientNet := env.AddNetwork("2.1.1.1", "192.168.1.1/24", "2000:1::1/64", vnet.EasyNAT)
|
||||||
@@ -21,7 +30,7 @@ func TestSubnetRouter(t *testing.T) {
|
|||||||
client := env.AddNode("client", clientNet,
|
client := env.AddNode("client", clientNet,
|
||||||
vmtest.OS(vmtest.Gokrazy))
|
vmtest.OS(vmtest.Gokrazy))
|
||||||
sr := env.AddNode("subnet-router", clientNet, internalNet,
|
sr := env.AddNode("subnet-router", clientNet, internalNet,
|
||||||
vmtest.OS(vmtest.Ubuntu2404),
|
vmtest.OS(srOS),
|
||||||
vmtest.AdvertiseRoutes("10.0.0.0/24"))
|
vmtest.AdvertiseRoutes("10.0.0.0/24"))
|
||||||
backend := env.AddNode("backend", internalNet,
|
backend := env.AddNode("backend", internalNet,
|
||||||
vmtest.OS(vmtest.Gokrazy),
|
vmtest.OS(vmtest.Gokrazy),
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ type Impl struct {
|
|||||||
packetsInFlight map[stack.TransportEndpointID]struct{}
|
packetsInFlight map[stack.TransportEndpointID]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stack returns the underlying gVisor network stack.
|
||||||
|
func (ns *Impl) Stack() *stack.Stack {
|
||||||
|
return ns.ipstack
|
||||||
|
}
|
||||||
|
|
||||||
const nicID = 1
|
const nicID = 1
|
||||||
|
|
||||||
// maxUDPPacketSize is the maximum size of a UDP packet we copy in
|
// maxUDPPacketSize is the maximum size of a UDP packet we copy in
|
||||||
|
|||||||
Reference in New Issue
Block a user