CustomDomains.AspNetCore 1.0.1

dotnet add package CustomDomains.AspNetCore --version 1.0.1
                    
NuGet\Install-Package CustomDomains.AspNetCore -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="CustomDomains.AspNetCore" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CustomDomains.AspNetCore" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="CustomDomains.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add CustomDomains.AspNetCore --version 1.0.1
                    
#r "nuget: CustomDomains.AspNetCore, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package CustomDomains.AspNetCore@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=CustomDomains.AspNetCore&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=CustomDomains.AspNetCore&version=1.0.1
                    
Install as a Cake Tool

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) and TxtOwnershipToken (a real TXT value comparison at _saas-verify.{domain}). This replaces Kefi's System.Net.Dns resolver and Katalogos's non-functional stub.
  • Pluggable Kubernetes provisioner (KubernetesIngressProvisioner) that emits a per-host Ingress with cert-manager auto-TLS, and — only for the AddPrefixPath routing mode — a Traefik addPrefix Middleware. A NoopCustomDomainProvisioner covers 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 Traefik addPrefix Middleware (rewrites //{PathPrefix}/{TenantSlug}/) plus an Ingress that references it via the traefik.ingress.kubernetes.io/router.middlewares annotation. 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 the Host: 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/from CustomDomainStatus in the adapter (do NOT renumber live data — Kefi's Active = 2 vs Katalogos's Active = 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 Failed domain).

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.Dns stub 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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 82 6/13/2026