diff --git a/cmd/k8s-operator/manifests/operator.yaml b/cmd/k8s-operator/manifests/operator.yaml index 2c49bbc3d..597863318 100644 --- a/cmd/k8s-operator/manifests/operator.yaml +++ b/cmd/k8s-operator/manifests/operator.yaml @@ -79,6 +79,14 @@ roleRef: name: operator apiGroup: rbac.authorization.k8s.io --- +apiVersion: v1 +kind: Secret +metadata: + name: tailscale-operator-oauth +stringData: + client_id: # SET CLIENT ID HERE + client_secret: # SET CLIENT SECRET HERE +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -96,6 +104,10 @@ spec: app: tailscale-operator spec: serviceAccountName: operator + volumes: + - name: oauth + secret: + secretName: tailscale-operator-oauth containers: - name: tailscale-operator image: tailscale/k8s-operator:latest @@ -108,7 +120,15 @@ spec: value: tailscale-operator - name: OPERATOR_SECRET value: tailscale-operator + - name: CLIENT_ID_FILE + value: /oauth/client_id + - name: CLIENT_SECRET_FILE + value: /oauth/client_secret - name: PROXY_IMAGE value: tailscale/tailscale:latest - name: PROXY_TAGS value: tag:k8s + volumeMounts: + - name: oauth + mountPath: /oauth + readOnly: true \ No newline at end of file diff --git a/cmd/k8s-operator/operator.go b/cmd/k8s-operator/operator.go index f789f5857..edce15911 100644 --- a/cmd/k8s-operator/operator.go +++ b/cmd/k8s-operator/operator.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" "golang.org/x/exp/slices" + "golang.org/x/oauth2/clientcredentials" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -38,22 +39,29 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" "sigs.k8s.io/yaml" "tailscale.com/client/tailscale" + "tailscale.com/ipn" "tailscale.com/ipn/store/kubestore" "tailscale.com/tsnet" "tailscale.com/types/logger" ) 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 ( - hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator") - kubeSecret = defaultEnv("OPERATOR_SECRET", "") - tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "default") - tslogging = defaultEnv("OPERATOR_LOGGING", "info") - image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest") - tags = defaultEnv("PROXY_TAGS", "tag:k8s") + hostname = defaultEnv("OPERATOR_HOSTNAME", "tailscale-operator") + kubeSecret = defaultEnv("OPERATOR_SECRET", "") + operatorTags = defaultEnv("OPERATOR_INITIAL_TAGS", "tag:k8s-operator") + tsNamespace = defaultEnv("OPERATOR_NAMESPACE", "default") + tslogging = defaultEnv("OPERATOR_LOGGING", "info") + clientIDPath = defaultEnv("CLIENT_ID_FILE", "") + clientSecretPath = defaultEnv("CLIENT_SECRET_FILE", "") + image = defaultEnv("PROXY_IMAGE", "tailscale/tailscale:latest") + tags = defaultEnv("PROXY_TAGS", "tag:k8s") ) - tailscale.I_Acknowledge_This_API_Is_Unstable = true var opts []kzap.Opts switch tslogging { case "info": @@ -66,6 +74,25 @@ func main() { zlog := kzap.NewRaw(opts...).Sugar() logf.SetLogger(zapr.NewLogger(zlog.Desugar())) startlog := zlog.Named("startup") + + if clientIDPath == "" || clientSecretPath == "" { + startlog.Fatalf("CLIENT_ID_FILE and CLIENT_SECRET_FILE must be set") + } + clientID, err := os.ReadFile(clientIDPath) + if err != nil { + startlog.Fatalf("reading client ID %q: %v", clientIDPath, err) + } + clientSecret, err := os.ReadFile(clientSecretPath) + if err != nil { + startlog.Fatalf("reading client secret %q: %v", clientSecretPath, err) + } + credentials := clientcredentials.Config{ + ClientID: string(clientID), + ClientSecret: string(clientSecret), + TokenURL: "https://login.tailscale.com/api/v2/oauth/token", + } + tsClient := tailscale.NewClient("-", nil) + tsClient.HTTPClient = credentials.Client(context.Background()) s := &tsnet.Server{ Hostname: hostname, Logf: zlog.Named("tailscaled").Debugf, @@ -87,10 +114,11 @@ func main() { } ctx := context.Background() - loginShown := false + loginDone := false machineAuthShown := false waitOnline: for { + startlog.Debugf("querying tailscaled status") st, err := lc.StatusWithoutPeers(ctx) if err != nil { startlog.Fatalf("getting status: %v", err) @@ -99,10 +127,32 @@ waitOnline: case "Running": break waitOnline case "NeedsLogin": - if !loginShown && st.AuthURL != "" { - startlog.Infof("tailscale needs login, please visit: %s", st.AuthURL) - loginShown = true + if loginDone { + break } + caps := tailscale.KeyCapabilities{ + Devices: tailscale.KeyDeviceCapabilities{ + Create: tailscale.KeyDeviceCreateCapabilities{ + Reusable: false, + Preauthorized: true, + Tags: strings.Split(operatorTags, ","), + }, + }, + } + authkey, _, err := tsClient.CreateKey(ctx, caps) + if err != nil { + startlog.Fatalf("creating operator authkey: %v", err) + } + if err := lc.Start(ctx, ipn.Options{ + AuthKey: authkey, + }); err != nil { + startlog.Fatalf("starting tailscale: %v", err) + } + if err := lc.StartLoginInteractive(ctx); err != nil { + startlog.Fatalf("starting login: %v", err) + } + startlog.Debugf("requested login by authkey") + loginDone = true case "NeedsMachineAuth": if !machineAuthShown { startlog.Infof("Machine authorization required, please visit the admin panel to authorize") @@ -114,6 +164,14 @@ waitOnline: time.Sleep(time.Second) } + sr := &ServiceReconciler{ + tsClient: tsClient, + defaultTags: strings.Split(tags, ","), + operatorNamespace: tsNamespace, + proxyImage: image, + logger: zlog.Named("service-reconciler"), + } + // For secrets and statefulsets, we only get permission to touch the objects // in the controller's own namespace. This cannot be expressed by // .Watches(...) below, instead you have to add a per-type field selector to @@ -134,17 +192,7 @@ waitOnline: if err != nil { startlog.Fatalf("could not create manager: %v", err) } - tsClient, err := s.APIClient() - if err != nil { - startlog.Fatalf("getting tailscale client: %v", err) - } - sr := &ServiceReconciler{ - tsClient: tsClient, - defaultTags: strings.Split(tags, ","), - operatorNamespace: tsNamespace, - proxyImage: image, - logger: zlog.Named("service-reconciler"), - } + reconcileFilter := handler.EnqueueRequestsFromMapFunc(func(o client.Object) []reconcile.Request { ls := o.GetLabels() if ls[LabelManaged] != "true" { diff --git a/go.mod b/go.mod index 33f409ae3..bb91bfb28 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( golang.org/x/crypto v0.3.0 golang.org/x/exp v0.0.0-20221205204356-47842c84f3db golang.org/x/net v0.2.0 + golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 golang.org/x/sys v0.2.0 golang.org/x/term v0.2.0 @@ -301,7 +302,6 @@ require ( golang.org/x/exp/typeparams v0.0.0-20220328175248-053ad81199eb // indirect golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect golang.org/x/mod v0.6.0 // indirect - golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect golang.org/x/text v0.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect