自定义域名 zì dìng yì yù míng = BYOD
  • Rust 84.3%
  • PLpgSQL 5.9%
  • Nix 5%
  • Just 3.2%
  • Go Template 1.6%
Find a file
Simon Shine df11e0cc22
Some checks failed
Rust CI / build (push) Failing after 9m57s
feat(chart): publish a Helm chart alongside the image
The chart covers what an installation actually needs: the API deployment, its Service,
an optional Gateway API route, an optional CloudNativePG cluster, and the secrets that
join them.

Chart:
- CNPG is off by default. Most installations already have a database, and a chart that
  creates a stateful cluster on install would also propose deleting it on uninstall.
  When it is on, the Cluster carries helm.sh/resource-policy: keep, because byod's
  Postgres is the point of truth for every tenant, zone and record.
- The DSN is composed by the secrets operator inside the cluster, so no password
  reaches the release's values. postgres.dsn and aead.key exist for development and say
  in NOTES what they cost.
- Migrations run in an initContainer. Every replica races it and sqlx serialises them
  on an advisory lock, so the losers find nothing to do.
- Only /v1 and /openapi.json are routed. Health answers on a second port and stays
  unreachable from outside; /readyz reaches the database.
- Liveness touches nothing and readiness does. The other way round turns a brief
  database outage into a crash loop across every replica at once.

Checks:
- chart-command-check asserts every container states a command. The image sets no
  Entrypoint on purpose, so one that states only args runs nothing, and that surfaces
  at rollout rather than at render.
- chart-lint renders with every optional template switched on and fails on an object
  without a namespace, because a renderer that applies the manifest itself supplies
  none and the object lands wherever kubectl points.
- check-chart-version ties appVersion to the release tag. appVersion is the default for
  image.tag, so a mismatch installs a pod that cannot pull.

CI:
- A `chart` devshell with helm and yq alone, so a chart-only change waits on no Rust
  toolchain. The release job pushes the chart after the image, since the chart names an
  image tag and would otherwise briefly point at something unpublished.
- The workflow gains the sqlx-check and openapi-check steps that `just ci` already ran,
  and release now runs the integration suite: a release should not be verified less
  thoroughly than a pull request.
- openapi.json joins the package fileset. A test include_str!s it to assert the served
  document matches the committed one, so it is a build input, not only an artifact.
2026-08-28 12:56:33 +02:00
.forgejo/workflows feat(chart): publish a Helm chart alongside the image 2026-08-28 12:56:33 +02:00
.sqlx feat(store): every query, behind a tenant scope 2026-08-28 11:34:50 +02:00
chart/byod-operator feat(chart): publish a Helm chart alongside the image 2026-08-28 12:56:33 +02:00
crates feat(api): a curl-drivable REST API, and the server that runs it 2026-08-28 12:19:47 +02:00
migrations feat(migrations): operations, an audit trail, and replay protection 2026-08-28 11:01:31 +02:00
nix feat(chart): publish a Helm chart alongside the image 2026-08-28 12:56:33 +02:00
.envrc chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
.gitignore feat(chart): publish a Helm chart alongside the image 2026-08-28 12:56:33 +02:00
ARCHITECTURE.md docs(architecture): show how the Kubernetes services fit together 2026-08-28 10:41:40 +02:00
Cargo.lock feat(api): a curl-drivable REST API, and the server that runs it 2026-08-28 12:19:47 +02:00
Cargo.toml feat(api): a curl-drivable REST API, and the server that runs it 2026-08-28 12:19:47 +02:00
flake.lock chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
flake.nix chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
justfile feat(chart): publish a Helm chart alongside the image 2026-08-28 12:56:33 +02:00
LICENSE-APACHE chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
LICENSE-MIT chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
openapi.json feat(api): a curl-drivable REST API, and the server that runs it 2026-08-28 12:19:47 +02:00
README.md chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00
rust-toolchain.toml chore: scaffold cargo workspace and dendritic flake 2026-08-27 23:06:01 +02:00

byod-operator

Multi-tenant DNS and certificate management for domains customers delegate to you.

A customer points a domain at a DNS provider, byod manages its records, obtains a certificate, and optionally serves it from the cluster. API-first: everything is drivable with curl before any frontend exists.

Status: scaffold. The workspace, flake and schema are in place. Nothing is implemented yet.

Shape

                 ┌──────────────────────────────────────────┐
   curl / web ──►│ byod-server  (Rust, axum)                │
                 │   REST API, tenants, zones, rrsets       │
                 │   templates, operations, audit           │
                 └───────────────┬──────────────────────────┘
                                 │ writes intent
                         ┌───────▼────────┐
                         │  Postgres      │  ◄── the point of truth
                         │  (CloudNativePG)│
                         └───┬────────┬───┘
        reads intent ────────┘        └──────── reads zone → tenant → token
        writes ownership                                    │
                │                                           │
   ┌────────────▼──────────────┐            ┌───────────────▼───────────────┐
   │ external-dns-pg    (Go)   │            │ byod-webhook   (Rust)         │
   │  upstream external-dns    │  webhook   │  external-dns provider API    │
   │  + Postgres source        ├───────────►│  cert-manager DNS01 solver    │
   │  + Postgres registry      │            │  → Cloudflare / deSEC/ Hetzner│
   └───────────────────────────┘            └───────────────────────────────┘
        a separate repository

Nothing per-domain lives in the Kubernetes API server except what has to terminate traffic: a Certificate, a TLS Secret, an HTTPRoute, and a Gateway listener. Tenants, zones and records are rows.

Why Postgres rather than custom resources

The conventional shape is one DNSEndpoint and one Certificate per domain. At a few thousand domains that is fine. At a hundred thousand it is over a gigabyte in etcd, an informer cache of the same size in every controller, and external-dns re-listing all of it every sixty seconds.

Ownership has the same problem in a different place. external-dns records "I own this" as a TXT record in the customer's zone, which costs a second provider write per record and breaks outright at a zone apex, where the default naming puts the ownership record outside the zone.

So both move into a database: desired state is a SELECT, and ownership is a row. That is what external-dns-pgregistry provides, and it is otherwise unmodified upstream external-dns.

Crates

byod-core domain types and the ownership algebra. No I/O, so the parts worth getting right are testable as pure functions
byod-store Postgres. The only crate that writes SQL, and the owner of migrations/
byod-dns Cloudflare, deSEC and Hetzner clients behind one trait
byod-k8s the Kubernetes resources that are read and applied
byod-templates declarative bundles describing what provisioning a domain produces
byod-api the axum router, auth, and the OpenAPI document
byod-server binary: the API and the reconcile loops
byod-webhook binary: external-dns provider API and cert-manager DNS01 solver

Per-tenant credentials, and why there are two webhooks in one binary

Each tenant brings their own provider token, so rate limits are per customer rather than one shared budget, and one leaked token exposes one customer.

That decides more than it looks. external-dns configures its provider once at startup with one credential, and cert-manager's DNS01 solvers reference a fixed Secret. Neither can resolve a token per zone. So byod serves both boundaries itself, over one set of provider clients, resolving zone to tenant to token on every call.

A side effect worth having: because the provider multiplexes internally, one external-dns instance covers all three providers rather than one instance per provider.

The database

migrations/ is the source of truth, and 0001_tenancy_and_dns_contract.sql also creates the three relations external-dns-pgregistry reads: dns_zones, dns_desired_endpoints and dns_ownership. That repository never runs DDL, so those column lists are a cross-repository interface — its testdata/contract.sql states the requirement and this migration satisfies it.

dns_ownership is written only by that binary. byod reads it to answer "what do we own", and must never write it: a claim inserted behind its back names a record it will then refuse to touch.

Development

$ nix develop          # or `direnv allow`
$ just ci              # fmt, clippy, unit tests, build, docs
$ just test-integration  # also the tests that start a throwaway Postgres
$ just sqlx-prepare    # after changing a query, so builds need no database
$ just images          # OCI images, streamed to stdout

Queries are checked at compile time against committed sqlx metadata, so nix build needs no database. A changed query without just sqlx-prepare compiles in the devshell and fails in the sandbox, which the pre-push hook catches first.

License

MIT or Apache-2.0, at your option.