Signed-off-by: Maisem Ali <maisem@tailscale.com>main
parent
f53792026e
commit
0842e2f45b
@ -0,0 +1,20 @@ |
||||
# Using Kubernetes Secrets as the state store for Tailscale |
||||
Tailscale supports using Kubernetes Secrets as the state store, however there is some configuration required in order for it to work. |
||||
|
||||
**Note: this only works if `tailscaled` runs inside a pod in the cluster.** |
||||
|
||||
1. Create a service account for Tailscale (optional) |
||||
``` |
||||
kubectl create -f sa.yaml |
||||
``` |
||||
|
||||
1. Create role and role bindings for the service account |
||||
``` |
||||
kubectl create -f role.yaml |
||||
kubectl create -f rolebinding.yaml |
||||
``` |
||||
|
||||
1. Launch `tailscaled` with a Kubernetes Secret as the state store. |
||||
``` |
||||
tailscaled --state=kube:tailscale |
||||
``` |
||||
@ -0,0 +1,10 @@ |
||||
apiVersion: rbac.authorization.k8s.io/v1 |
||||
kind: Role |
||||
metadata: |
||||
namespace: default |
||||
name: tailscale |
||||
rules: |
||||
- apiGroups: [""] # "" indicates the core API group |
||||
resourceNames: ["tailscale"] |
||||
resources: ["secrets"] |
||||
verbs: ["create", "get", "update"] |
||||
@ -0,0 +1,12 @@ |
||||
apiVersion: rbac.authorization.k8s.io/v1 |
||||
kind: RoleBinding |
||||
metadata: |
||||
namespace: default |
||||
name: tailscale |
||||
subjects: |
||||
- kind: ServiceAccount |
||||
name: tailscale |
||||
roleRef: |
||||
kind: Role |
||||
name: tailscale |
||||
apiGroup: rbac.authorization.k8s.io |
||||
@ -0,0 +1,5 @@ |
||||
apiVersion: v1 |
||||
kind: ServiceAccount |
||||
metadata: |
||||
name: tailscale |
||||
namespace: default |
||||
@ -0,0 +1,188 @@ |
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package kube |
||||
|
||||
import "time" |
||||
|
||||
// Note: The API types are copied from k8s.io/api{,machinery} to not introduce a
|
||||
// module dependency on the Kubernetes API as it pulls in many more dependencies.
|
||||
|
||||
// TypeMeta describes an individual object in an API response or request with
|
||||
// strings representing the type of the object and its API schema version.
|
||||
// Structures that are versioned or persisted should inline TypeMeta.
|
||||
type TypeMeta struct { |
||||
// Kind is a string value representing the REST resource this object represents.
|
||||
// Servers may infer this from the endpoint the client submits requests to.
|
||||
// Cannot be updated.
|
||||
// In CamelCase.
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
// +optional
|
||||
Kind string `json:"kind,omitempty"` |
||||
|
||||
// APIVersion defines the versioned schema of this representation of an object.
|
||||
// Servers should convert recognized schemas to the latest internal value, and
|
||||
// may reject unrecognized values.
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
// +optional
|
||||
APIVersion string `json:"apiVersion,omitempty"` |
||||
} |
||||
|
||||
// ObjectMeta is metadata that all persisted resources must have, which
|
||||
// includes all objects users must create.
|
||||
type ObjectMeta struct { |
||||
// Name must be unique within a namespace. Is required when creating resources, although
|
||||
// some resources may allow a client to request the generation of an appropriate name
|
||||
// automatically. Name is primarily intended for creation idempotence and configuration
|
||||
// definition.
|
||||
// Cannot be updated.
|
||||
// More info: http://kubernetes.io/docs/user-guide/identifiers#names
|
||||
// +optional
|
||||
Name string `json:"name"` |
||||
|
||||
// Namespace defines the space within which each name must be unique. An empty namespace is
|
||||
// equivalent to the "default" namespace, but "default" is the canonical representation.
|
||||
// Not all objects are required to be scoped to a namespace - the value of this field for
|
||||
// those objects will be empty.
|
||||
//
|
||||
// Must be a DNS_LABEL.
|
||||
// Cannot be updated.
|
||||
// More info: http://kubernetes.io/docs/user-guide/namespaces
|
||||
// +optional
|
||||
Namespace string `json:"namespace"` |
||||
|
||||
// UID is the unique in time and space value for this object. It is typically generated by
|
||||
// the server on successful creation of a resource and is not allowed to change on PUT
|
||||
// operations.
|
||||
//
|
||||
// Populated by the system.
|
||||
// Read-only.
|
||||
// More info: http://kubernetes.io/docs/user-guide/identifiers#uids
|
||||
// +optional
|
||||
UID string `json:"uid,omitempty"` |
||||
|
||||
// An opaque value that represents the internal version of this object that can
|
||||
// be used by clients to determine when objects have changed. May be used for optimistic
|
||||
// concurrency, change detection, and the watch operation on a resource or set of resources.
|
||||
// Clients must treat these values as opaque and passed unmodified back to the server.
|
||||
// They may only be valid for a particular resource or set of resources.
|
||||
//
|
||||
// Populated by the system.
|
||||
// Read-only.
|
||||
// Value must be treated as opaque by clients and .
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
|
||||
// +optional
|
||||
ResourceVersion string `json:"resourceVersion,omitempty"` |
||||
|
||||
// A sequence number representing a specific generation of the desired state.
|
||||
// Populated by the system. Read-only.
|
||||
// +optional
|
||||
Generation int64 `json:"generation,omitempty"` |
||||
|
||||
// CreationTimestamp is a timestamp representing the server time when this object was
|
||||
// created. It is not guaranteed to be set in happens-before order across separate operations.
|
||||
// Clients may not set this value. It is represented in RFC3339 form and is in UTC.
|
||||
//
|
||||
// Populated by the system.
|
||||
// Read-only.
|
||||
// Null for lists.
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
|
||||
// +optional
|
||||
CreationTimestamp time.Time `json:"creationTimestamp,omitempty"` |
||||
|
||||
// DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This
|
||||
// field is set by the server when a graceful deletion is requested by the user, and is not
|
||||
// directly settable by a client. The resource is expected to be deleted (no longer visible
|
||||
// from resource lists, and not reachable by name) after the time in this field, once the
|
||||
// finalizers list is empty. As long as the finalizers list contains items, deletion is blocked.
|
||||
// Once the deletionTimestamp is set, this value may not be unset or be set further into the
|
||||
// future, although it may be shortened or the resource may be deleted prior to this time.
|
||||
// For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react
|
||||
// by sending a graceful termination signal to the containers in the pod. After that 30 seconds,
|
||||
// the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup,
|
||||
// remove the pod from the API. In the presence of network partitions, this object may still
|
||||
// exist after this timestamp, until an administrator or automated process can determine the
|
||||
// resource is fully terminated.
|
||||
// If not set, graceful deletion of the object has not been requested.
|
||||
//
|
||||
// Populated by the system when a graceful deletion is requested.
|
||||
// Read-only.
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
|
||||
// +optional
|
||||
DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` |
||||
|
||||
// Number of seconds allowed for this object to gracefully terminate before
|
||||
// it will be removed from the system. Only set when deletionTimestamp is also set.
|
||||
// May only be shortened.
|
||||
// Read-only.
|
||||
// +optional
|
||||
DeletionGracePeriodSeconds *int64 `json:"deletionGracePeriodSeconds,omitempty"` |
||||
|
||||
// Map of string keys and values that can be used to organize and categorize
|
||||
// (scope and select) objects. May match selectors of replication controllers
|
||||
// and services.
|
||||
// More info: http://kubernetes.io/docs/user-guide/labels
|
||||
// +optional
|
||||
Labels map[string]string `json:"labels,omitempty"` |
||||
|
||||
// Annotations is an unstructured key value map stored with a resource that may be
|
||||
// set by external tools to store and retrieve arbitrary metadata. They are not
|
||||
// queryable and should be preserved when modifying objects.
|
||||
// More info: http://kubernetes.io/docs/user-guide/annotations
|
||||
// +optional
|
||||
Annotations map[string]string `json:"annotations,omitempty"` |
||||
} |
||||
|
||||
// Secret holds secret data of a certain type. The total bytes of the values
|
||||
// in the Data field must be less than MaxSecretSize bytes.
|
||||
type Secret struct { |
||||
TypeMeta `json:",inline"` |
||||
ObjectMeta `json:"metadata"` |
||||
|
||||
// Data contains the secret data. Each key must consist of alphanumeric
|
||||
// characters, '-', '_' or '.'. The serialized form of the secret data is a
|
||||
// base64 encoded string, representing the arbitrary (possibly non-string)
|
||||
// data value here. Described in https://tools.ietf.org/html/rfc4648#section-4
|
||||
// +optional
|
||||
Data map[string][]byte `json:"data,omitempty"` |
||||
} |
||||
|
||||
// Status is a return value for calls that don't return other objects.
|
||||
type Status struct { |
||||
TypeMeta `json:",inline"` |
||||
// Status of the operation.
|
||||
// One of: "Success" or "Failure".
|
||||
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
|
||||
// +optional
|
||||
Status string `json:"status,omitempty"` |
||||
|
||||
// A human-readable description of the status of this operation.
|
||||
// +optional
|
||||
Message string `json:"message,omitempty"` |
||||
|
||||
// A machine-readable description of why this operation is in the
|
||||
// "Failure" status. If this value is empty there
|
||||
// is no information available. A Reason clarifies an HTTP status
|
||||
// code but does not override it.
|
||||
// +optional
|
||||
Reason string `json:"reason,omitempty"` |
||||
|
||||
// Extended data associated with the reason. Each reason may define its
|
||||
// own extended details. This field is optional and the data returned
|
||||
// is not guaranteed to conform to any schema except that defined by
|
||||
// the reason type.
|
||||
// +optional
|
||||
Details *struct { |
||||
Name string `json:"name,omitempty"` |
||||
Kind string `json:"kind,omitempty"` |
||||
} `json:"details,omitempty"` |
||||
|
||||
// Suggested HTTP return code for this status, 0 if not set.
|
||||
// +optional
|
||||
Code int `json:"code,omitempty"` |
||||
} |
||||
|
||||
func (s *Status) Error() string { |
||||
return s.Message |
||||
} |
||||
@ -0,0 +1,170 @@ |
||||
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package kube provides a client to interact with Kubernetes.
|
||||
// This package is Tailscale-internal and not meant for external consumption.
|
||||
// Further, the API should not be considered stable.
|
||||
package kube |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"crypto/tls" |
||||
"crypto/x509" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"os" |
||||
"path/filepath" |
||||
"sync" |
||||
"time" |
||||
) |
||||
|
||||
const ( |
||||
saPath = "/var/run/secrets/kubernetes.io/serviceaccount" |
||||
defaultURL = "https://kubernetes.default.svc" |
||||
) |
||||
|
||||
func readFile(n string) ([]byte, error) { |
||||
return os.ReadFile(filepath.Join(saPath, n)) |
||||
} |
||||
|
||||
// Client handles connections to Kubernetes.
|
||||
// It expects to be run inside a cluster.
|
||||
type Client struct { |
||||
mu sync.Mutex |
||||
url string |
||||
ns string |
||||
client *http.Client |
||||
token string |
||||
tokenExpiry time.Time |
||||
} |
||||
|
||||
// New returns a new client
|
||||
func New() (*Client, error) { |
||||
ns, err := readFile("namespace") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
caCert, err := readFile("ca.crt") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
cp := x509.NewCertPool() |
||||
if ok := cp.AppendCertsFromPEM(caCert); !ok { |
||||
return nil, fmt.Errorf("kube: error in creating root cert pool") |
||||
} |
||||
return &Client{ |
||||
url: defaultURL, |
||||
ns: string(ns), |
||||
client: &http.Client{ |
||||
Transport: &http.Transport{ |
||||
TLSClientConfig: &tls.Config{ |
||||
RootCAs: cp, |
||||
}, |
||||
}, |
||||
}, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *Client) expireToken() { |
||||
c.mu.Lock() |
||||
defer c.mu.Unlock() |
||||
c.tokenExpiry = time.Now() |
||||
} |
||||
|
||||
func (c *Client) getOrRenewToken() (string, error) { |
||||
c.mu.Lock() |
||||
defer c.mu.Unlock() |
||||
tk, te := c.token, c.tokenExpiry |
||||
if time.Now().Before(te) { |
||||
return tk, nil |
||||
} |
||||
|
||||
tkb, err := readFile("token") |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
c.token = string(tkb) |
||||
c.tokenExpiry = time.Now().Add(30 * time.Minute) |
||||
return c.token, nil |
||||
} |
||||
|
||||
func (c *Client) secretURL(name string) string { |
||||
if name == "" { |
||||
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets", c.url, c.ns) |
||||
} |
||||
return fmt.Sprintf("%s/api/v1/namespaces/%s/secrets/%s", c.url, c.ns, name) |
||||
} |
||||
|
||||
func getError(resp *http.Response) error { |
||||
if resp.StatusCode == 200 { |
||||
return nil |
||||
} |
||||
st := &Status{} |
||||
if err := json.NewDecoder(resp.Body).Decode(st); err != nil { |
||||
return err |
||||
} |
||||
return st |
||||
} |
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, url string, in, out interface{}) error { |
||||
tk, err := c.getOrRenewToken() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
var body io.Reader |
||||
if in != nil { |
||||
var b bytes.Buffer |
||||
if err := json.NewEncoder(&b).Encode(in); err != nil { |
||||
return err |
||||
} |
||||
body = &b |
||||
} |
||||
req, err := http.NewRequestWithContext(ctx, method, url, body) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if body != nil { |
||||
req.Header.Add("Content-Type", "application/json") |
||||
} |
||||
req.Header.Add("Accept", "application/json") |
||||
req.Header.Add("Authorization", "Bearer "+tk) |
||||
resp, err := c.client.Do(req) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer resp.Body.Close() |
||||
if err := getError(resp); err != nil { |
||||
if st, ok := err.(*Status); ok && st.Code == 401 { |
||||
c.expireToken() |
||||
} |
||||
return err |
||||
} |
||||
if out != nil { |
||||
return json.NewDecoder(resp.Body).Decode(out) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// GetSecret fetches the secret from the Kubernetes API.
|
||||
func (c *Client) GetSecret(ctx context.Context, name string) (*Secret, error) { |
||||
s := &Secret{Data: make(map[string][]byte)} |
||||
if err := c.doRequest(ctx, "GET", c.secretURL(name), nil, s); err != nil { |
||||
return nil, err |
||||
} |
||||
return s, nil |
||||
} |
||||
|
||||
// CreateSecret creates a secret in the Kubernetes API.
|
||||
func (c *Client) CreateSecret(ctx context.Context, s *Secret) error { |
||||
s.Namespace = c.ns |
||||
return c.doRequest(ctx, "POST", c.secretURL(""), s, nil) |
||||
} |
||||
|
||||
// UpdateSecret updates a secret in the Kubernetes API.
|
||||
func (c *Client) UpdateSecret(ctx context.Context, s *Secret) error { |
||||
return c.doRequest(ctx, "PUT", c.secretURL(s.Name), s, nil) |
||||
} |
||||
Loading…
Reference in new issue