// 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) { 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") } name := os.Getenv("FANCYDNS_ZONE_CONFIGMAP") if name == "" { name = defaultName } 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) } 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") } if content != nil && len(*content) > 512*1024 { return errors.New("zone file 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 }