Files
fancy-dns/internal/kubezones/client.go
T
mats 32f940b763
build / build-push (push) Successful in 29s
Add UI editing for local DNS zones
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
2026-09-28 08:50:50 +02:00

237 lines
6.7 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) {
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
}