diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go index bf9d534c4..73ca4a3c2 100644 --- a/cmd/containerboot/main.go +++ b/cmd/containerboot/main.go @@ -13,6 +13,7 @@ // variables. All configuration is optional. // // - TS_AUTHKEY: the authkey to use for login. +// - TS_HOSTNAME: the hostname to request for the node. // - TS_ROUTES: subnet routes to advertise. // - TS_DEST_IP: proxy all incoming Tailscale traffic to the given // destination. @@ -74,6 +75,7 @@ func main() { cfg := &settings{ AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""), + Hostname: defaultEnv("TS_HOSTNAME", ""), Routes: defaultEnv("TS_ROUTES", ""), ProxyTo: defaultEnv("TS_DEST_IP", ""), DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""), @@ -394,6 +396,9 @@ func tailscaleUp(ctx context.Context, cfg *settings) error { if cfg.Routes != "" { args = append(args, "--advertise-routes="+cfg.Routes) } + if cfg.Hostname != "" { + args = append(args, "--hostname="+cfg.Hostname) + } if cfg.ExtraArgs != "" { args = append(args, strings.Fields(cfg.ExtraArgs)...) } @@ -522,6 +527,7 @@ func installIPTablesRule(ctx context.Context, dstStr string, tsIPs []netip.Prefi // settings is all the configuration for containerboot. type settings struct { AuthKey string + Hostname string Routes string ProxyTo string DaemonExtraArgs string diff --git a/cmd/containerboot/main_test.go b/cmd/containerboot/main_test.go index 05b1a8650..b789130ae 100644 --- a/cmd/containerboot/main_test.go +++ b/cmd/containerboot/main_test.go @@ -550,6 +550,22 @@ type phase struct { }, }, }, + { + Name: "hostname", + Env: map[string]string{ + "TS_HOSTNAME": "my-server", + }, + Phases: []phase{ + { + WantCmds: []string{ + "/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking", + "/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --hostname=my-server", + }, + }, { + Notify: runningNotify, + }, + }, + }, } for _, test := range tests { diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index 8496558d4..0e8e71e42 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -43,6 +43,7 @@ "tailscale.com/ipn/store/kubestore" "tailscale.com/tsnet" "tailscale.com/types/logger" + "tailscale.com/util/dnsname" ) func main() { @@ -235,8 +236,9 @@ func main() { FinalizerName = "tailscale.com/finalizer" - AnnotationExpose = "tailscale.com/expose" - AnnotationTags = "tailscale.com/tags" + AnnotationExpose = "tailscale.com/expose" + AnnotationTags = "tailscale.com/tags" + AnnotationHostname = "tailscale.com/hostname" ) // ServiceReconciler is a simple ControllerManagedBy example implementation. @@ -370,6 +372,11 @@ func (a *ServiceReconciler) maybeCleanup(ctx context.Context, logger *zap.Sugare // This function adds a finalizer to svc, ensuring that we can handle orderly // deprovisioning later. func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.SugaredLogger, svc *corev1.Service) error { + hostname, err := nameForService(svc) + if err != nil { + return err + } + if !slices.Contains(svc.Finalizers, FinalizerName) { // This log line is printed exactly once during initial provisioning, // because once the finalizer is in place this block gets skipped. So, @@ -396,7 +403,7 @@ func (a *ServiceReconciler) maybeProvision(ctx context.Context, logger *zap.Suga if err != nil { return fmt.Errorf("failed to create or get API key secret: %w", err) } - _, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName) + _, err = a.reconcileSTS(ctx, logger, svc, hsvc, secretName, hostname) if err != nil { return fmt.Errorf("failed to reconcile statefulset: %w", err) } @@ -558,7 +565,7 @@ func (a *ServiceReconciler) newAuthKey(ctx context.Context, tags []string) (stri //go:embed manifests/proxy.yaml var proxyYaml []byte -func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret string) (*appsv1.StatefulSet, error) { +func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.SugaredLogger, parentSvc, headlessSvc *corev1.Service, authKeySecret, hostname string) (*appsv1.StatefulSet, error) { var ss appsv1.StatefulSet if err := yaml.Unmarshal(proxyYaml, &ss); err != nil { return nil, fmt.Errorf("failed to unmarshal proxy spec: %w", err) @@ -573,6 +580,10 @@ func (a *ServiceReconciler) reconcileSTS(ctx context.Context, logger *zap.Sugare corev1.EnvVar{ Name: "TS_KUBE_SECRET", Value: authKeySecret, + }, + corev1.EnvVar{ + Name: "TS_HOSTNAME", + Value: hostname, }) ss.ObjectMeta = metav1.ObjectMeta{ Name: headlessSvc.Name, @@ -679,3 +690,13 @@ func defaultEnv(envName, defVal string) string { } return v } + +func nameForService(svc *corev1.Service) (string, error) { + if h, ok := svc.Annotations[AnnotationHostname]; ok { + if err := dnsname.ValidLabel(h); err != nil { + return "", fmt.Errorf("invalid Tailscale hostname %q: %w", h, err) + } + return h, nil + } + return svc.Namespace + "-" + svc.Name, nil +} diff --git a/cmd/k8s-operator/operator_test.go b/cmd/k8s-operator/operator_test.go index 68fd20880..3ee322462 100644 --- a/cmd/k8s-operator/operator_test.go +++ b/cmd/k8s-operator/operator_test.go @@ -66,7 +66,7 @@ func TestLoadBalancerClass(t *testing.T) { expectEqual(t, fc, expectedSecret(fullName)) expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) // Normally the Tailscale proxy pod would come up here and write its info // into the secret. Simulate that, then verify reconcile again and verify @@ -187,7 +187,7 @@ func TestAnnotations(t *testing.T) { expectEqual(t, fc, expectedSecret(fullName)) expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) want := &corev1.Service{ TypeMeta: metav1.TypeMeta{ Kind: "Service", @@ -284,7 +284,7 @@ func TestAnnotationIntoLB(t *testing.T) { expectEqual(t, fc, expectedSecret(fullName)) expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) // Normally the Tailscale proxy pod would come up here and write its info // into the secret. Simulate that, since it would have normally happened at @@ -328,7 +328,7 @@ func TestAnnotationIntoLB(t *testing.T) { expectReconciled(t, sr, "default", "test") // None of the proxy machinery should have changed... expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) // ... but the service should have a LoadBalancer status. want = &corev1.Service{ @@ -400,7 +400,7 @@ func TestLBIntoAnnotation(t *testing.T) { expectEqual(t, fc, expectedSecret(fullName)) expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) // Normally the Tailscale proxy pod would come up here and write its info // into the secret. Simulate that, then verify reconcile again and verify @@ -457,7 +457,7 @@ func TestLBIntoAnnotation(t *testing.T) { expectReconciled(t, sr, "default", "test") expectEqual(t, fc, expectedHeadlessService(shortName)) - expectEqual(t, fc, expectedSTS(shortName, fullName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "default-test")) want = &corev1.Service{ TypeMeta: metav1.TypeMeta{ @@ -481,6 +481,108 @@ func TestLBIntoAnnotation(t *testing.T) { expectEqual(t, fc, want) } +func TestCustomHostname(t *testing.T) { + fc := fake.NewFakeClient() + ft := &fakeTSClient{} + zl, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + sr := &ServiceReconciler{ + Client: fc, + tsClient: ft, + defaultTags: []string{"tag:k8s"}, + operatorNamespace: "operator-ns", + proxyImage: "tailscale/tailscale", + logger: zl.Sugar(), + } + + // Create a service that we should manage, and check that the initial round + // of objects looks right. + mustCreate(t, fc, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + // The apiserver is supposed to set the UID, but the fake client + // doesn't. So, set it explicitly because other code later depends + // on it being set. + UID: types.UID("1234-UID"), + Annotations: map[string]string{ + "tailscale.com/expose": "true", + "tailscale.com/hostname": "reindeer-flotilla", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.20.30.40", + Type: corev1.ServiceTypeClusterIP, + }, + }) + + expectReconciled(t, sr, "default", "test") + + fullName, shortName := findGenName(t, fc, "default", "test") + + expectEqual(t, fc, expectedSecret(fullName)) + expectEqual(t, fc, expectedHeadlessService(shortName)) + expectEqual(t, fc, expectedSTS(shortName, fullName, "reindeer-flotilla")) + want := &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Finalizers: []string{"tailscale.com/finalizer"}, + UID: types.UID("1234-UID"), + Annotations: map[string]string{ + "tailscale.com/expose": "true", + "tailscale.com/hostname": "reindeer-flotilla", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.20.30.40", + Type: corev1.ServiceTypeClusterIP, + }, + } + expectEqual(t, fc, want) + + // Turn the service back into a ClusterIP service, which should make the + // operator clean up. + mustUpdate(t, fc, "default", "test", func(s *corev1.Service) { + delete(s.ObjectMeta.Annotations, "tailscale.com/expose") + }) + // synchronous StatefulSet deletion triggers a requeue. But, the StatefulSet + // didn't create any child resources since this is all faked, so the + // deletion goes through immediately. + expectReconciled(t, sr, "default", "test") + expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) + // Second time around, the rest of cleanup happens. + expectReconciled(t, sr, "default", "test") + expectMissing[appsv1.StatefulSet](t, fc, "operator-ns", shortName) + expectMissing[corev1.Service](t, fc, "operator-ns", shortName) + expectMissing[corev1.Secret](t, fc, "operator-ns", fullName) + want = &corev1.Service{ + TypeMeta: metav1.TypeMeta{ + Kind: "Service", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + UID: types.UID("1234-UID"), + Annotations: map[string]string{ + "tailscale.com/hostname": "reindeer-flotilla", + }, + }, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.20.30.40", + Type: corev1.ServiceTypeClusterIP, + }, + } + expectEqual(t, fc, want) +} + func expectedSecret(name string) *corev1.Secret { return &corev1.Secret{ TypeMeta: metav1.TypeMeta{ @@ -529,7 +631,7 @@ func expectedHeadlessService(name string) *corev1.Service { } } -func expectedSTS(stsName, secretName string) *appsv1.StatefulSet { +func expectedSTS(stsName, secretName, hostname string) *appsv1.StatefulSet { return &appsv1.StatefulSet{ TypeMeta: metav1.TypeMeta{ Kind: "StatefulSet", @@ -578,6 +680,7 @@ func expectedSTS(stsName, secretName string) *appsv1.StatefulSet { {Name: "TS_AUTH_ONCE", Value: "true"}, {Name: "TS_DEST_IP", Value: "10.20.30.40"}, {Name: "TS_KUBE_SECRET", Value: secretName}, + {Name: "TS_HOSTNAME", Value: hostname}, }, SecurityContext: &corev1.SecurityContext{ Capabilities: &corev1.Capabilities{ diff --git a/util/dnsname/dnsname.go b/util/dnsname/dnsname.go index 259b49de2..759387fb7 100644 --- a/util/dnsname/dnsname.go +++ b/util/dnsname/dnsname.go @@ -6,6 +6,7 @@ package dnsname import ( + "errors" "fmt" "strings" ) @@ -94,6 +95,31 @@ func (f FQDN) Contains(other FQDN) bool { return strings.HasSuffix(other.WithTrailingDot(), cmp) } +// ValidLabel reports whether label is a valid DNS label. +func ValidLabel(label string) error { + if len(label) == 0 { + return errors.New("empty DNS label") + } + if len(label) > maxLabelLength { + return fmt.Errorf("%q is too long, max length is %d bytes", label, maxLabelLength) + } + if !isalphanum(label[0]) { + return fmt.Errorf("%q is not a valid DNS label: must start with a letter or number", label) + } + if !isalphanum(label[len(label)-1]) { + return fmt.Errorf("%q is not a valid DNS label: must end with a letter or number", label) + } + if len(label) < 2 { + return nil + } + for i := 1; i < len(label)-1; i++ { + if !isdnschar(label[i]) { + return fmt.Errorf("%q is not a valid DNS label: contains invalid character %q", label, label[i]) + } + } + return nil +} + // SanitizeLabel takes a string intended to be a DNS name label // and turns it into a valid name label according to RFC 1035. func SanitizeLabel(label string) string {