CustomDomains.AspNetCore
1.0.1
dotnet add package CustomDomains.AspNetCore --version 1.0.1
NuGet\Install-Package CustomDomains.AspNetCore -Version 1.0.1
<PackageReference Include="CustomDomains.AspNetCore" Version="1.0.1" />
<PackageVersion Include="CustomDomains.AspNetCore" Version="1.0.1" />
<PackageReference Include="CustomDomains.AspNetCore" />
paket add CustomDomains.AspNetCore --version 1.0.1
#r "nuget: CustomDomains.AspNetCore, 1.0.1"
#:package CustomDomains.AspNetCore@1.0.1
#addin nuget:?package=CustomDomains.AspNetCore&version=1.0.1
#tool nuget:?package=CustomDomains.AspNetCore&version=1.0.1
CustomDomains.AspNetCore
Reusable tenant custom-domain logic for multi-tenant ASP.NET Core SaaS.
Consolidates the two divergent custom-domain implementations (Kefi's CNAME-resolves-to-cluster + real Kubernetes provisioning, and Katalogos's TXT-ownership-token model) into one package. The app owns persistence; the package owns the logic.
What's in the box
- Real DNS verification (
DnsCustomDomainVerifier, DnsClient.NET-backed), supporting both strategies behind one interface —CnameResolvesToCluster(IP-set overlap against a canonical host) andTxtOwnershipToken(a real TXT value comparison at_saas-verify.{domain}). This replaces Kefi'sSystem.Net.Dnsresolver and Katalogos's non-functional stub. - Pluggable Kubernetes provisioner (
KubernetesIngressProvisioner) that emits a per-host Ingress with cert-manager auto-TLS, and — only for theAddPrefixPathrouting mode — a TraefikaddPrefixMiddleware. ANoopCustomDomainProvisionercovers local dev. The provisioner is auto-selected by service-account-token presence. - Shared name rules (
CustomDomainNameRules) — normalize + validate, lifted from Kefi. - Unified status lifecycle (
CustomDomainStatus { None, Pending, Active, Failed, Revoked }). - Opt-in background poller (
PendingDomainVerificationService) that re-verifies pending domains and provisions the successes. - App-owned persistence port (
ICustomDomainStore+CustomDomainRecord) — keep your existing table/columns; write a thin adapter.
Public API surface
| Type | Role |
|---|---|
ICustomDomainStore |
App-owned persistence port (GetByTenant, GetByDomain, GetPendingVerification, Save). |
CustomDomainRecord |
Storage-agnostic snapshot. |
CustomDomainStatus |
Unified lifecycle (None=0, Pending=1, Active=2, Failed=3, Revoked=4). |
IDomainVerifier / DnsCustomDomainVerifier |
VerifyAsync(domain, strategy, expectedHostOrToken, ct). |
VerificationStrategy |
CnameResolvesToCluster, TxtOwnershipToken. |
DomainVerificationResult |
IsVerified + Error. |
IDnsLookupClient / DnsClientLookupClient |
Resolver seam (mockable in tests). |
ICustomDomainProvisioner |
ProvisionAsync / DeprovisionAsync. |
KubernetesIngressProvisioner / NoopCustomDomainProvisioner |
Implementations. |
KubernetesIngressTemplateRenderer |
Pure YAML rendering (Ingress + Middleware). |
ProvisionTarget |
BackendServiceName, BackendServicePort, RoutingMode, PathPrefix?, TenantSlug?. |
RoutingMode |
AddPrefixPath, HostRoutedPassthrough. |
CustomDomainsOptions / KubernetesProvisionerOptions |
Configuration. |
PendingDomainVerificationService |
Opt-in BackgroundService. |
AddCustomDomains(...) / AddCustomDomainProvisionTarget(...) |
DI entry points. |
RoutingMode — the two render paths
The routing template is genuinely not one-size, so the mode selects it:
AddPrefixPath→ a TraefikaddPrefixMiddleware (rewrites/→/{PathPrefix}/{TenantSlug}/) plus an Ingress that references it via thetraefik.ingress.kubernetes.io/router.middlewaresannotation. Use this for a backend that serves per-tenant static trees with relative asset paths (Kefi / kefi-landings) — one shared host serves any tenant's site.HostRoutedPassthrough→ an Ingress only (no Middleware, no rewrite annotation). Use this for a backend that already routes on theHost:header — an SPA or API (Katalogos / Erevna).
Both modes attach the cert-manager.io/cluster-issuer annotation so a per-host
Let's Encrypt cert is auto-issued via HTTP-01.
Wiring (consumer side)
Program.cs:
using CustomDomains.AspNetCore.Abstractions;
using CustomDomains.AspNetCore.Extensions;
builder.Services.AddCustomDomains(builder.Configuration, opts =>
{
opts.VerificationStrategy = VerificationStrategy.CnameResolvesToCluster;
opts.ReservedSuffix = ".kefi.dloizides.com";
opts.CanonicalHostTemplate = "{slug}.kefi.dloizides.com";
});
// The product brings its own persistence adapter.
builder.Services.AddScoped<ICustomDomainStore, MyEfCustomDomainStore>();
// Only needed if the background poller is enabled:
builder.Services.AddCustomDomainProvisionTarget(record =>
new ProvisionTarget("kefi-landings", 80, RoutingMode.AddPrefixPath,
PathPrefix: "t", TenantSlug: record.TenantSlug));
appsettings.json:
{
"CustomDomains": {
"VerificationStrategy": "CnameResolvesToCluster",
"ReservedSuffix": ".kefi.dloizides.com",
"CanonicalHostTemplate": "{slug}.kefi.dloizides.com",
"Kubernetes": {
"ResourceNamePrefix": "cd-",
"CertManagerClusterIssuer": "letsencrypt-prod",
"Namespace": "dloizides"
},
"Poller": {
"Enabled": false,
"IntervalSeconds": 60,
"MaxDomainsPerBatch": 20,
"MinSecondsBetweenAttempts": 60
}
}
}
Verify + provision in an endpoint (synchronous flow):
var verify = await verifier.VerifyAsync(
domain, opts.VerificationStrategy, expectedHostOrToken, ct);
if (verify.IsVerified)
{
await provisioner.ProvisionAsync(
domain, new ProvisionTarget("onlinemenu-api", 8080, RoutingMode.HostRoutedPassthrough), ct);
}
What the consumer owns
ICustomDomainStore— read + upsert against their own DbContext. Map the persisted status ordinals to/fromCustomDomainStatusin the adapter (do NOT renumber live data — Kefi'sActive = 2vs Katalogos'sActive = 1+Revoked).- The DNS records they tell tenants to publish (CNAME target host, or the TXT ownership token they mint and store).
- The namespace-scoped RBAC below (a silent 403 surfaces as a
Faileddomain).
Service-account RBAC (production / Kubernetes)
The Kubernetes provisioner needs a namespace-scoped Role granting it Ingresses,
Traefik Middlewares (for AddPrefixPath), cert-manager Certificates, and Secrets.
Each consuming app needs its own SA + RoleBinding in its own namespace.
apiVersion: v1
kind: ServiceAccount
metadata:
name: custom-domains-provisioner
namespace: dloizides
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: custom-domains-provisioner
namespace: dloizides
rules:
# Per-host Ingress (create on verify, delete on clear/revoke).
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["create", "get", "list", "delete"]
# cert-manager Certificate + the backing TLS Secret. cert-manager does NOT set
# ownerReferences from the Certificate to the Ingress, so deprovision deletes all
# three explicitly. `list` on secrets is deliberately omitted so a compromised SA
# can't enumerate non-TLS secrets — it must already know the resource name.
- apiGroups: ["cert-manager.io"]
resources: ["certificates"]
verbs: ["get", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "delete"]
# Traefik addPrefix Middleware — ONLY needed when RoutingMode == AddPrefixPath.
# Omit this rule for host-routed (SPA/API) backends.
- apiGroups: ["traefik.io"]
resources: ["middlewares"]
verbs: ["create", "get", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: custom-domains-provisioner
namespace: dloizides
subjects:
- kind: ServiceAccount
name: custom-domains-provisioner
namespace: dloizides
roleRef:
kind: Role
name: custom-domains-provisioner
apiGroup: rbac.authorization.k8s.io
Then set serviceAccountName: custom-domains-provisioner on the API Deployment.
The Role is namespace-local (not a ClusterRole); the blast radius is "these
resource kinds, this namespace, this one ServiceAccount."
Security notes
- DNS failures (NXDOMAIN, timeout, no overlap, wrong token) are reported as
IsVerified = false, never thrown — and never falsely pass. - The TXT strategy compares the actual record value (DnsClient.NET), closing the
hole in the old
System.Net.Dnsstub that accepted any resolving subdomain. - The provisioner constructs every resource name from the (validated) domain, so it can only ever touch per-custom-domain resources, never arbitrary cluster objects.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- DnsClient (>= 1.8.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.1 | 91 | 6/13/2026 |