mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 07:13:44 +00:00
cmd/tsidp: This change enables tsidp to store its operational state in a Kubernetes secret, similar to how tsrecorder and the k8s-operator can.
It introduces a new '--state' flag (and TS_STATE environment variable) that accepts 'kube:<secret-name>' to specify the Kubernetes secret to use. The necessary RBAC permissions for the tsidp service account are documented in the README. Updates #15965 Signed-off-by: Raj Singh <raj@tailscale.com>
This commit is contained in:
parent
b63f8a457d
commit
995ab4a252
@ -78,15 +78,82 @@ The `tsidp` server supports several command-line flags:
|
|||||||
- `--local-port`: Allow requests from localhost
|
- `--local-port`: Allow requests from localhost
|
||||||
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
|
- `--use-local-tailscaled`: Use local tailscaled instead of tsnet
|
||||||
- `--hostname`: tsnet hostname
|
- `--hostname`: tsnet hostname
|
||||||
- `--dir`: tsnet state directory
|
- `--dir`: tsnet state directory; a default one will be created if not provided
|
||||||
|
- `--state`: Path to tailscale state file. Can also be set to use a Kubernetes Secret with the format `kube:<secret-name>`. If unset, `dir` is used for file-based state, or tsnet default if `dir` is also unset.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
- `TS_AUTHKEY`: Your Tailscale authentication key (required)
|
- `TS_AUTHKEY`: Your Tailscale authentication key (required)
|
||||||
- `TS_HOSTNAME`: Hostname for the `tsidp` server (default: "idp", Docker only)
|
- `TS_HOSTNAME`: Hostname for the `tsidp` server (default: "idp", Docker only)
|
||||||
- `TS_STATE_DIR`: State directory (default: "/var/lib/tsidp", Docker only)
|
- `TS_STATE_DIR`: Default state directory for `tsnet` (default: "/var/lib/tsidp" in Docker). This variable typically sets the default for the `--dir` flag in the Docker environment. `tsnet` uses the directory specified by `--dir` (or its internal default if `--dir` is not set) for its persistent files (e.g., node keys).
|
||||||
- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (default: "1")
|
- `TAILSCALE_USE_WIP_CODE`: Enable work-in-progress code (default: "1")
|
||||||
|
|
||||||
|
## Storing State in Kubernetes Secrets
|
||||||
|
|
||||||
|
When running `tsidp` in a Kubernetes environment, you can configure it to store its state in a Kubernetes Secret. This is achieved by setting the `--state` flag to `kube:<your-secret-name>`. The Secret will be created by `tsidp` if it doesn't already exist, and will be created in the same namespace where `tsidp` is running.
|
||||||
|
|
||||||
|
**Important**: Each Pod must use its own unique Secret. Multiple Pods cannot share the same Secret for state storage.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
`./tsidp --state kube:my-tsidp-state-secret`
|
||||||
|
|
||||||
|
### StatefulSet Example for Multiple Pods
|
||||||
|
|
||||||
|
When deploying multiple `tsidp` instances, use a StatefulSet to ensure each Pod gets its own unique Secret:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: tsidp
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
serviceName: tsidp
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: tsidp
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: tsidp
|
||||||
|
spec:
|
||||||
|
serviceAccountName: tsidp
|
||||||
|
containers:
|
||||||
|
- name: tsidp
|
||||||
|
image: tailscale/tsidp:unstable
|
||||||
|
args:
|
||||||
|
- tsidp
|
||||||
|
- --state=kube:$(POD_NAME)
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
apiVersion: v1
|
||||||
|
fieldPath: metadata.name
|
||||||
|
- name: TS_AUTHKEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: tsidp-auth
|
||||||
|
key: authkey
|
||||||
|
- name: TAILSCALE_USE_WIP_CODE
|
||||||
|
value: "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required RBAC Permissions
|
||||||
|
|
||||||
|
If you use Kubernetes Secret storage, the service account under which `tsidp` runs needs the following permissions on Secrets in the same namespace:
|
||||||
|
- `get`
|
||||||
|
- `patch` (primary mechanism for writing state)
|
||||||
|
- `create` (if the Secret does not already exist)
|
||||||
|
- `update` (for backwards compatibility, though patch is preferred)
|
||||||
|
|
||||||
|
Additionally, the service account needs the following permissions on Events (for debugging purposes when Secret operations fail):
|
||||||
|
- `create`
|
||||||
|
- `patch`
|
||||||
|
- `get`
|
||||||
|
|
||||||
|
Ensure that appropriate Role and RoleBinding are configured in your Kubernetes cluster.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
This is an experimental, work in progress, [community project](https://tailscale.com/kb/1531/community-projects). For issues or questions, file issues on the [GitHub repository](https://github.com/tailscale/tailscale).
|
This is an experimental, work in progress, [community project](https://tailscale.com/kb/1531/community-projects). For issues or questions, file issues on the [GitHub repository](https://github.com/tailscale/tailscale).
|
||||||
|
@ -254,10 +254,10 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
|||||||
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
|
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
|
||||||
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
|
tailscale.com/ipn/store from tailscale.com/ipn/ipnlocal+
|
||||||
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
|
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
|
||||||
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
|
tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store+
|
||||||
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
|
tailscale.com/ipn/store/mem from tailscale.com/ipn/ipnlocal+
|
||||||
L tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
|
tailscale.com/kube/kubeapi from tailscale.com/ipn/store/kubestore+
|
||||||
L tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore
|
tailscale.com/kube/kubeclient from tailscale.com/ipn/store/kubestore+
|
||||||
tailscale.com/kube/kubetypes from tailscale.com/envknob+
|
tailscale.com/kube/kubetypes from tailscale.com/envknob+
|
||||||
tailscale.com/licenses from tailscale.com/client/web
|
tailscale.com/licenses from tailscale.com/client/web
|
||||||
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
||||||
|
@ -42,12 +42,16 @@ import (
|
|||||||
"tailscale.com/hostinfo"
|
"tailscale.com/hostinfo"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/ipn/store"
|
||||||
|
_ "tailscale.com/ipn/store/kubestore"
|
||||||
|
"tailscale.com/kube/kubeclient"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/tsnet"
|
"tailscale.com/tsnet"
|
||||||
"tailscale.com/types/key"
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/types/lazy"
|
"tailscale.com/types/lazy"
|
||||||
"tailscale.com/types/views"
|
"tailscale.com/types/views"
|
||||||
"tailscale.com/util/mak"
|
"tailscale.com/util/mak"
|
||||||
|
"tailscale.com/util/multierr"
|
||||||
"tailscale.com/util/must"
|
"tailscale.com/util/must"
|
||||||
"tailscale.com/util/rands"
|
"tailscale.com/util/rands"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
@ -68,6 +72,7 @@ var (
|
|||||||
flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
|
flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet")
|
||||||
flagHostname = flag.String("hostname", "idp", "tsnet hostname to use instead of idp")
|
flagHostname = flag.String("hostname", "idp", "tsnet hostname to use instead of idp")
|
||||||
flagDir = flag.String("dir", "", "tsnet state directory; a default one will be created if not provided")
|
flagDir = flag.String("dir", "", "tsnet state directory; a default one will be created if not provided")
|
||||||
|
flagState = flag.String("state", "", "path to tailscale state file or 'kube:<secret-name>' to use Kubernetes secret; if unset, 'dir' is used")
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -125,11 +130,30 @@ func main() {
|
|||||||
hostinfo.SetApp("tsidp")
|
hostinfo.SetApp("tsidp")
|
||||||
ts := &tsnet.Server{
|
ts := &tsnet.Server{
|
||||||
Hostname: *flagHostname,
|
Hostname: *flagHostname,
|
||||||
Dir: *flagDir,
|
|
||||||
}
|
}
|
||||||
if *flagVerbose {
|
if *flagVerbose {
|
||||||
ts.Logf = log.Printf
|
ts.Logf = log.Printf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *flagDir != "" {
|
||||||
|
ts.Dir = *flagDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if *flagState != "" {
|
||||||
|
if isKubeStatePath(*flagState) {
|
||||||
|
if err := validateKubePermissions(ctx, *flagState); err != nil {
|
||||||
|
log.Fatalf("tsidp: state is set to be stored in a Kubernetes Secret, but kube permissions validation for the Secret failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, err := store.New(ts.Logf, *flagState)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create state store: %v", err)
|
||||||
|
}
|
||||||
|
ts.Store = s
|
||||||
|
// If flagDir is not set, tsnet will use its own OS-dependent default directory
|
||||||
|
// for its persistent state (like node keys), which is the desired behavior.
|
||||||
|
}
|
||||||
|
|
||||||
st, err = ts.Up(ctx)
|
st, err = ts.Up(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@ -1240,3 +1264,86 @@ func isFunnelRequest(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isKubeStatePath evaluates whether the provided state path indicates that
|
||||||
|
// tailscaled state should be stored in a Kubernetes Secret.
|
||||||
|
func isKubeStatePath(statePath string) bool {
|
||||||
|
return strings.HasPrefix(statePath, "kube:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKubePermissions validates that a tsidp instance has the right
|
||||||
|
// permissions to modify its state Secret.
|
||||||
|
// It needs to have permissions to get and update the Secret.
|
||||||
|
// If the Secret does not already exist, it also needs to have permissions to create it.
|
||||||
|
// patch permission is beneficial but not strictly required by kubestore's default operations.
|
||||||
|
func validateKubePermissions(ctx context.Context, state string) error {
|
||||||
|
secretName, ok := strings.CutPrefix(state, "kube:")
|
||||||
|
if !ok || secretName == "" {
|
||||||
|
return fmt.Errorf("unable to retrieve valid Kubernetes Secret name from %q", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
kc, err := kubeclient.New("tailscale-tsidp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error initializing kube client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our kube client connects to kube API server via the kubernetes
|
||||||
|
// Service in the default namespace, which is not the default client-go
|
||||||
|
// etc behaviour and causes issues to some users. The client defaults
|
||||||
|
// probably cannot be changed for backwards compatibility reasons, but
|
||||||
|
// we can do the right thing here at the same time as adding support for
|
||||||
|
// tsidp to be deployed to kube.
|
||||||
|
url, err := kubeAPIServerAddress()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error initiating kube client: %w", err)
|
||||||
|
}
|
||||||
|
kc.SetURL(url)
|
||||||
|
|
||||||
|
// CheckSecretPermissions returns an error if the permissions to get or update
|
||||||
|
// the Secret are missing. It also returns bools for canPatch and canCreate.
|
||||||
|
// kubestore primarily uses patch.
|
||||||
|
canPatch, canCreate, err := kc.CheckSecretPermissions(ctx, secretName)
|
||||||
|
if err != nil { // This err means get or update failed, or other auth issue
|
||||||
|
return fmt.Errorf("error checking required permissions (get/update) for Kubernetes Secret %q: %w", secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if secret exists if we don't have create permissions.
|
||||||
|
// If it doesn't exist and we can't create, it's an error.
|
||||||
|
// If it doesn't exist and we *can* create, that's fine, kubestore will create it.
|
||||||
|
// If it exists, we're good (Get permission was implicitly checked by CheckSecretPermissions).
|
||||||
|
secretExistsErr := func() error { _, err := kc.GetSecret(ctx, secretName); return err }()
|
||||||
|
if kubeclient.IsNotFoundErr(secretExistsErr) {
|
||||||
|
if !canCreate {
|
||||||
|
return fmt.Errorf("kube state Kubernetes Secret %q does not exist and tsidp lacks permissions to create it. Ensure RBAC allows 'create' for Secrets", secretName)
|
||||||
|
}
|
||||||
|
// It's okay if it doesn't exist and we can create it.
|
||||||
|
} else if secretExistsErr != nil {
|
||||||
|
// Any other error while trying to GetSecret (besides NotFound) is a problem.
|
||||||
|
return fmt.Errorf("error attempting to get kube state Kubernetes Secret %q: %w", secretName, secretExistsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, we know we can get and update the secret (or create if it didn't exist).
|
||||||
|
// Log if patch is not available, as it's preferred for conflict handling, but not essential.
|
||||||
|
if !canPatch {
|
||||||
|
log.Printf("Warning: patch permission for Kubernetes Secret %q is missing; kubestore will rely on update. This is always fine.", secretName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kubeAPIServerAddress determines the address of the kube API server. It uses
|
||||||
|
// the standard environment variables set by kube that are expected to be found
|
||||||
|
// on any Pod- this is the same logic as used by client-go.
|
||||||
|
// https://github.com/kubernetes/client-go/blob/v0.29.5/rest/config.go#L516-L536
|
||||||
|
func kubeAPIServerAddress() (_ string, err error) {
|
||||||
|
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
|
||||||
|
if host == "" {
|
||||||
|
err = errors.New("[unexpected] tsidp seems to be running in a Kubernetes environment with KUBERNETES_SERVICE_HOST unset")
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
err = multierr.New(err, errors.New("[unexpected] tsidp appears to be running in a Kubernetes environment with KUBERNETES_SERVICE_PORT unset"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "https://" + net.JoinHostPort(host, port), nil
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user