279 lines
8.2 KiB
Go
279 lines
8.2 KiB
Go
// Package kubezones stores UI-managed zone files in a Kubernetes ConfigMap.
|
|
// The ConfigMap volume is shared by every fancy-dns replica, so UI edits do
|
|
// not depend on which pod receives the request.
|
|
package kubezones
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
|
|
defaultName = "fancy-dns-zones"
|
|
)
|
|
|
|
// Zone is a ConfigMap key and its master-file contents.
|
|
type Zone struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// Client is a narrow client for the one ConfigMap the console may edit.
|
|
type Client struct {
|
|
baseURL string
|
|
ns string
|
|
name string
|
|
http *http.Client
|
|
}
|
|
|
|
// InCluster returns nil outside Kubernetes. The projected service account
|
|
// token must be available; RBAC limits it to the fancy-dns-zones ConfigMap.
|
|
func InCluster() (*Client, error) {
|
|
name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP")
|
|
if name == "" {
|
|
name = defaultName
|
|
}
|
|
return InClusterConfigMap(name)
|
|
}
|
|
|
|
// InClusterConfigMap returns a client for a named ConfigMap. RBAC on the
|
|
// service account still limits which ConfigMaps it can read or patch.
|
|
func InClusterConfigMap(name string) (*Client, error) {
|
|
host := os.Getenv("KUBERNETES_SERVICE_HOST")
|
|
if host == "" {
|
|
return nil, nil
|
|
}
|
|
port := os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE"))
|
|
if ns == "" {
|
|
b, err := os.ReadFile(serviceAccountDir + "/namespace")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kube zones: read service account namespace: %w", err)
|
|
}
|
|
ns = strings.TrimSpace(string(b))
|
|
}
|
|
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kube zones: read service account CA: %w", err)
|
|
}
|
|
pool, err := x509.SystemCertPool()
|
|
if err != nil || pool == nil {
|
|
pool = x509.NewCertPool()
|
|
}
|
|
if !pool.AppendCertsFromPEM(ca) {
|
|
return nil, errors.New("kube zones: invalid service account CA")
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, errors.New("kube zones: ConfigMap name is required")
|
|
}
|
|
return &Client{
|
|
baseURL: "https://" + net.JoinHostPort(host, port),
|
|
ns: ns,
|
|
name: name,
|
|
http: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{
|
|
Proxy: nil,
|
|
TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "kubernetes.default.svc"},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) resourceURL() string {
|
|
return c.baseURL + "/api/v1/namespaces/" + url.PathEscape(c.ns) + "/configmaps/" + url.PathEscape(c.name)
|
|
}
|
|
|
|
// List returns all zone files stored in the managed ConfigMap.
|
|
func (c *Client) List(ctx context.Context) ([]Zone, error) {
|
|
cm, _, err := c.get(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Zone, 0, len(cm.Data))
|
|
for name, content := range cm.Data {
|
|
if strings.HasPrefix(name, "zone--") && strings.HasSuffix(name, ".zone") {
|
|
out = append(out, Zone{Name: strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone"), Content: content})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Save updates or adds one zone file. Kubernetes resourceVersion makes the
|
|
// update conditional and retrying resource-version conflicts. Separate zone
|
|
// edits merge safely; when two editors change the same zone, the last save
|
|
// wins.
|
|
func (c *Client) Save(ctx context.Context, name, content string) error {
|
|
return c.change(ctx, filename(name), &content)
|
|
}
|
|
|
|
// Delete removes one zone file.
|
|
func (c *Client) Delete(ctx context.Context, name string) error {
|
|
return c.change(ctx, filename(name), nil)
|
|
}
|
|
|
|
// Data reads one arbitrary ConfigMap data key. It is used for console-owned
|
|
// shared files that are not zone master files.
|
|
func (c *Client) Data(ctx context.Context, key string) (string, bool, error) {
|
|
if c == nil {
|
|
return "", false, errors.New("kube zones: Kubernetes ConfigMap is unavailable")
|
|
}
|
|
cm, _, err := c.get(ctx)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
value, ok := cm.Data[key]
|
|
return value, ok, nil
|
|
}
|
|
|
|
// SaveData creates or replaces one arbitrary ConfigMap data key, retrying
|
|
// resource-version conflicts so independent edits to different keys merge.
|
|
func (c *Client) SaveData(ctx context.Context, key, content string) error {
|
|
if c == nil {
|
|
return errors.New("kube zones: Kubernetes ConfigMap is unavailable")
|
|
}
|
|
if key == "" || len(key) > 253 || strings.ContainsAny(key, `/\\`) {
|
|
return errors.New("kube zones: invalid ConfigMap data key")
|
|
}
|
|
if len(content) > 512*1024 {
|
|
return errors.New("kube zones: ConfigMap data exceeds 512 KiB")
|
|
}
|
|
return c.changeData(ctx, key, &content)
|
|
}
|
|
|
|
func filename(name string) string { return "zone--" + strings.TrimSuffix(name, ".") + ".zone" }
|
|
|
|
func (c *Client) change(ctx context.Context, name string, content *string) error {
|
|
if !validKey(name) {
|
|
return errors.New("zone filename must be a DNS name ending in .zone")
|
|
}
|
|
return c.changeData(ctx, name, content)
|
|
}
|
|
|
|
func (c *Client) changeData(ctx context.Context, name string, content *string) error {
|
|
if content != nil && len(*content) > 512*1024 {
|
|
return errors.New("ConfigMap data exceeds 512 KiB")
|
|
}
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
_, rv, err := c.get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var value any
|
|
if content == nil {
|
|
value = nil
|
|
} else {
|
|
value = *content
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"metadata": map[string]string{"resourceVersion": rv},
|
|
"data": map[string]any{name: value},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.resourceURL(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.authorize(req); err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/merge-patch+json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("kube zones: update ConfigMap: %w", err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusConflict {
|
|
continue
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("kube zones: update ConfigMap: Kubernetes returned %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
return errors.New("kube zones: concurrent edits; reload and try again")
|
|
}
|
|
|
|
type configMap struct {
|
|
Metadata struct {
|
|
ResourceVersion string `json:"resourceVersion"`
|
|
} `json:"metadata"`
|
|
Data map[string]string `json:"data"`
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context) (configMap, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.resourceURL(), nil)
|
|
if err != nil {
|
|
return configMap{}, "", err
|
|
}
|
|
if err := c.authorize(req); err != nil {
|
|
return configMap{}, "", err
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return configMap{}, "", fmt.Errorf("kube zones: read ConfigMap: Kubernetes returned %s", resp.Status)
|
|
}
|
|
var cm configMap
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&cm); err != nil {
|
|
return configMap{}, "", fmt.Errorf("kube zones: decode ConfigMap: %w", err)
|
|
}
|
|
if cm.Data == nil {
|
|
cm.Data = map[string]string{}
|
|
}
|
|
return cm, cm.Metadata.ResourceVersion, nil
|
|
}
|
|
|
|
func (c *Client) authorize(req *http.Request) error {
|
|
token, err := os.ReadFile(serviceAccountDir + "/token")
|
|
if err != nil {
|
|
return fmt.Errorf("kube zones: read service account token: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token)))
|
|
return nil
|
|
}
|
|
|
|
func validKey(name string) bool {
|
|
if !strings.HasPrefix(name, "zone--") || !strings.HasSuffix(name, ".zone") ||
|
|
strings.ContainsAny(name, `/\\`) || len(name) > 253 {
|
|
return false
|
|
}
|
|
name = strings.TrimSuffix(strings.TrimPrefix(name, "zone--"), ".zone")
|
|
if name == "" || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(name, ".") {
|
|
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
|
return false
|
|
}
|
|
for _, r := range label {
|
|
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|