mirror of
https://github.com/hanzoai/dns.git
synced 2026-10-30 18:40:55 +00:00
hanzodns becomes the one DNS control plane: authoritative zones served by its
own CoreDNS store AND third-party providers (Cloudflare) via the org's
KMS-sealed token. Both are first-class and coexist per-zone.
- Provider interface (provider.go) mirroring the platform dns-provider.ts:
Type/ListZones/ZoneIDForName/ListRecords/Create/Update/DeleteRecord.
- Cloudflare connector (cloudflare.go) ported from cloudflare.ts — CF API v4
CRUD, token only on the Authorization header, fail-closed, never logged.
- Per-org credential sourcing (kms.go): providerFor reads the sealed token from
KMS by RELAYING the caller's own validated bearer to the KMS REST API
(/v1/kms/orgs/{org}/secrets/integrations/cloudflare/api_token). hanzodns holds
no standing cross-tenant credential — KMS enforces org isolation. org is the
IAM claim; auth-method-agnostic (apikey + oauth seal the same coord).
- Zone.provider dispatch (api.go): authoritative -> local store; cloudflare ->
provider (source of truth) with a local-store mirror so CoreDNS serves it and
listings stay consistent. OIDC wired into the auth chain so org + bearer reach
the provider path.
- Durable authoritative zones (store_snapshot.go): opt-in file snapshot via
HANZO_DNS_STATE_DIR — load on startup, atomic rewrite on every mutation, so a
restart preserves native zones. Unset keeps the in-memory store.
Tests: CF connector, KMS bearer-relay (incl. foreign-org refusal), the full
create/list/delete dispatch for a Cloudflare zone, the native authoritative
path, and snapshot round-trip across a restart. go test -tags metrics green.
88 lines
2.9 KiB
Go
88 lines
2.9 KiB
Go
package hanzodns
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// kmsBaseURL is the Hanzo KMS REST origin. Defaults to the platform API and is
|
|
// overridable for non-prod / tests via HANZO_DNS_KMS_URL.
|
|
func kmsBaseURL() string {
|
|
if v := strings.TrimSpace(os.Getenv("HANZO_DNS_KMS_URL")); v != "" {
|
|
return strings.TrimRight(v, "/")
|
|
}
|
|
return "https://api.hanzo.ai"
|
|
}
|
|
|
|
var kmsHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
// kmsSecret reads one org-scoped secret value from the Hanzo KMS REST API,
|
|
// relaying the CALLER's validated bearer (from ctx) as the Authorization so
|
|
// KMS's own org-scope guard authorizes the read. This is the least-privilege
|
|
// design: hanzodns holds no standing cross-tenant KMS credential, so a request
|
|
// can only reach the secrets of the org whose token it carried, and the KMS
|
|
// service — not this plugin — remains the single tenant-isolation boundary.
|
|
//
|
|
// subpath is the fixed secret coordinate under the org root, e.g.
|
|
// "integrations/cloudflare/api_token", producing
|
|
//
|
|
// GET {base}/v1/kms/orgs/{org}/secrets/integrations/cloudflare/api_token?env=default
|
|
//
|
|
// The secret value is returned to the caller but is NEVER logged, wrapped into
|
|
// an error, or placed anywhere other than the returned string.
|
|
func kmsSecret(ctx context.Context, org, subpath string) (string, error) {
|
|
bearer := BearerFromContext(ctx)
|
|
if bearer == "" {
|
|
return "", fmt.Errorf("no validated credential on request")
|
|
}
|
|
if org == "" {
|
|
return "", fmt.Errorf("no org on request")
|
|
}
|
|
|
|
// org is a single validated path segment; subpath is a fixed in-code
|
|
// coordinate (never client input), so only org is escaped.
|
|
endpoint := kmsBaseURL() + "/v1/kms/orgs/" + url.PathEscape(org) + "/secrets/" + subpath + "?env=default"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
|
|
resp, err := kmsHTTPClient.Do(req)
|
|
if err != nil {
|
|
// Never wrap err: a transport error can echo the URL but never the
|
|
// header. Keep the message credential-free regardless.
|
|
return "", fmt.Errorf("kms request failed")
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
// fall through
|
|
case http.StatusNotFound:
|
|
return "", fmt.Errorf("cloudflare is not connected for this org")
|
|
case http.StatusUnauthorized, http.StatusForbidden:
|
|
return "", fmt.Errorf("not authorized to read this org's cloudflare token")
|
|
default:
|
|
return "", fmt.Errorf("kms returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body struct {
|
|
Value string `json:"value"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
|
return "", fmt.Errorf("kms: malformed response")
|
|
}
|
|
if strings.TrimSpace(body.Value) == "" {
|
|
return "", fmt.Errorf("cloudflare token is empty")
|
|
}
|
|
return body.Value, nil
|
|
}
|