Skip to content

feat(settings): add flat public hostname strategy#146

Open
bherila wants to merge 3 commits into
gotempsh:mainfrom
bherila:feature/flat-public-hostnames-upstream-pr
Open

feat(settings): add flat public hostname strategy#146
bherila wants to merge 3 commits into
gotempsh:mainfrom
bherila:feature/flat-public-hostnames-upstream-pr

Conversation

@bherila

@bherila bherila commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Motivation

Closes #139.

Description (reworked per review feedback)

The original global "hostname strategy" setting has been replaced with a per-managed-domain capability, configured where the DNS provider is set up rather than in global platform settings.

  • Per-domain mode: dns_managed_domains.generated_hostname_mode (standard | flat). Default standard, opt-in per domain — no global switch that retroactively rewrites every environment's URLs.
  • Simple toggle: the free-form environment/service/deployment templates are removed — just Standard vs Flat. In practice only the per-service hostname layout differs (service-env vs env-service).
  • Provider-gated: providers advertise a flat_hostnames capability (Cloudflare = true); the UI surfaces/recommends Flat only where it helps, so it stays out of the way for everyone else.
  • Preview → confirm → apply: switching a domain to Flat shows an impact preview (changed generated hostnames + the DNS records a sync would reconcile + the token's zone-access state) before an explicit, breaking apply that recomputes hostnames and reloads routes. Custom domains, is_calculated=false deployment domains, project_custom_domains, and custom_routes are never touched.
  • Per-hostname Cloudflare DNS sync (opt-in per domain, sync_generated_records): with a global edge_target configured, apply reconciles one proxied record per generated hostname against the provider zone (A/AAAA for an IP target, else CNAME), only ever removing records Temps generated.
  • Token zone-access flag: Cloudflare 401/403 now surfaces as a distinct permission error; verifying a managed domain records zone_access_ok/zone_access_error so a mis-scoped token is flagged in the UI instead of failing mysteriously.
  • The centralized generator stays in temps-core; a PublicHostnameResolver (registered by temps-dns) resolves the per-domain mode for the route table and deployments handler without a crate-dependency cycle.

Migration / compatibility

  • New migration adds the columns above and backfills flat onto existing managed domains for any instance that had the removed global public_hostnames.strategy = flat, so behavior doesn't silently revert on upgrade.
  • Adds a global edge_target setting (where synced DNS records point). The old global public_hostnames setting (strategy + templates) is removed.

Testing

  • cargo test -p temps-core public_hostname --lib (9 pass), cargo test -p temps-dns --lib (224 pass)
  • cargo check --bin temps (full binary) clean
  • web typecheck clean for touched files
  • Note: the web API client (web/src/api/client) was hand-updated for the new fields/endpoints; regenerate with bun openapi-ts against a running backend before a web release.

@bherila bherila force-pushed the feature/flat-public-hostnames-upstream-pr branch from b3ccaa3 to 98b659d Compare June 19, 2026 20:26
@dviejokfs

Copy link
Copy Markdown
Contributor

I am against this type of domain structure since it disables the ability to have dynamic environments with automatic HTTPS. This feature will be for advanced users. Can you explain what's the workflow you envision to mitigate this?

@bherila

bherila commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

This will be useful for all users on Cloudflare's free or Pro plan.

Otherwise, if you use Cloudflare's reverse proxy, the free wildcard certificate covers *.yourdomain.com ... but nested subdomains like a.b.yourdomain.com will only work on the $200+ plans.

However, a-b.yourdomain.com is totally fine and works with Cloudflare without requiring any additional plan subscriptions. I'm currently testing this out on a real server.

@dviejokfs

dviejokfs commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

But you could point the wildcard to your server without WAF and then add individual ones with the proxy enabled.

If you don't want to have any non protected CF pointing to Temps you could add individual ones to Temps and then enable the firewall for these ones.

I don't want to make it more complicated for users to decide what url strategy to use, though I think there's value to have a configurable template for the domain generation.

The issue is consistency

If you have a lot of environments and change the url strategy you end up in a messy situation where some projects/environments end up with one URL strategy and others with another.

Or worse, you change the URL strategy and all the projects/environments are updated, breaking external applications

@bherila

bherila commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

I agree it shouldn't be a required choice- the default is probably ok. But given the current defaults, Cloudflare users will run into this unexpectedly, by default, when they add Cloudflare. I know it sure took me by surprise, and a bit of debugging to figure out what was actually going on there. That wasn't a very user-friendly experience either!

At the end of the day I really just don't want to reveal my server's ip at all, it kind of defeats the point of proxying some but not all subdomains...

@bherila

bherila commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Would getting rid of the template string settings and just having the drop down menu be more user-friendly in your opinion? Another option is possibly using the Cloudflare integration itself to detect when a user's plan doesn't support nested subdomains and transparently apply the naming policy, but that could also lead to confusion and might require an API key with more permissions than would otherwise be needed. Yet another alternative could be to allow the user to select their Cloudflare plan when they configure that plugin. Many options...

…e zone sync

Rework the global "flat public hostname strategy" (PR gotempsh#146) into a
per-managed-domain capability configured where the DNS provider lives,
addressing the maintainer's concern about cluttering global settings with an
abstract URL strategy.

- temps-core: simplify the generator — `PublicHostnameStrategy` carries the
  layout methods; drop the global `PublicHostnameSettings`/templates and the
  `AppSettings.public_hostnames` field. Add `PublicHostnameResolver` trait so
  routes/deployments resolve the per-domain mode without depending on temps-dns.
  Add a global `edge_target` setting for DNS record sync.
- temps-entities/migrations: add `generated_hostname_mode`,
  `sync_generated_records`, `zone_access_ok`, `zone_access_error` to
  `dns_managed_domains`; backfill `flat` onto existing managed domains when the
  removed global strategy was `flat`.
- temps-dns: add a `flat_hostnames` provider capability (Cloudflare = true);
  surface Cloudflare 401/403 as `PermissionDenied` and a `check_zone_access`
  helper; per-domain update/preview/apply endpoints; per-hostname DNS zone
  reconciliation (A/AAAA for an IP edge target, else CNAME) that only removes
  Temps-generated records; route reload + audit on apply.
- Only the per-service hostname layout differs between Standard and Flat, so the
  resolver is threaded into just the deployments handler and the route table;
  all other sites use the strategy-independent Standard methods.
- web: remove the global hostname-strategy UI (add `edge_target`); per-domain
  Flat/Sync toggles, token zone-access banner, and a preview→confirm dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bherila added a commit to bherila/temps that referenced this pull request Jun 22, 2026
…e zone sync

Rework the global "flat public hostname strategy" (PR gotempsh#146) into a
per-managed-domain capability configured where the DNS provider lives,
addressing the maintainer's concern about cluttering global settings with an
abstract URL strategy.

- temps-core: simplify the generator — `PublicHostnameStrategy` carries the
  layout methods; drop the global `PublicHostnameSettings`/templates and the
  `AppSettings.public_hostnames` field. Add `PublicHostnameResolver` trait so
  routes/deployments resolve the per-domain mode without depending on temps-dns.
  Add a global `edge_target` setting for DNS record sync.
- temps-entities/migrations: add `generated_hostname_mode`,
  `sync_generated_records`, `zone_access_ok`, `zone_access_error` to
  `dns_managed_domains`; backfill `flat` onto existing managed domains when the
  removed global strategy was `flat`.
- temps-dns: add a `flat_hostnames` provider capability (Cloudflare = true);
  surface Cloudflare 401/403 as `PermissionDenied` and a `check_zone_access`
  helper; per-domain update/preview/apply endpoints; per-hostname DNS zone
  reconciliation (A/AAAA for an IP edge target, else CNAME) that only removes
  Temps-generated records; route reload + audit on apply.
- Only the per-service hostname layout differs between Standard and Flat, so the
  resolver is threaded into just the deployments handler and the route table;
  all other sites use the strategy-independent Standard methods.
- web: remove the global hostname-strategy UI (add `edge_target`); per-domain
  Flat/Sync toggles, token zone-access banner, and a preview→confirm dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bherila

bherila commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Reworked this based on @dviejokfs's feedback — the global URL-strategy setting is gone. Summary of what changed (pushed to this branch):

  • No global strategy or templates. The Standard↔Flat choice now lives per managed domain (dns_managed_domains.generated_hostname_mode), configured where you set up the DNS provider — not in global platform settings. The free-form templates are removed; it's just a Standard/Flat toggle.
  • Default Standard, opt-in per domain. There's no platform-wide switch that retroactively rewrites every environment's URLs. You opt a specific managed domain into Flat — which directly addresses the consistency concern (no more "change the strategy and everything flips at once").
  • Provider-gated, not a generic knob. Providers advertise a flat_hostnames capability and the UI only surfaces/recommends Flat where it actually helps (Cloudflare Universal SSL). Other providers never see it, so it doesn't become a confusing choice for everyone.
  • Explicit preview → confirm before the breaking change. Switching a domain to Flat shows an impact preview (which generated hostnames change, plus any DNS records that would be reconciled, plus the token's zone-access state) and requires confirmation — so you can't silently break external apps. Custom domains are never touched.
  • Optional per-hostname Cloudflare DNS sync (opt-in per domain). With a configured edge target, applying reconciles one proxied record per generated hostname against the zone (A/AAAA for an IP, else CNAME), only ever removing records Temps generated.
  • Token scope flag. Cloudflare 401/403 now surfaces as a permission error, and verifying a domain records whether the token can manage the zone, so a mis-scoped token is flagged in the UI rather than failing mysteriously.

@dviejokfs on pointing the wildcard at the origin without WAF and adding individual proxied records: that works, but it exposes the origin IP via the unproxied wildcard, which is the thing I'm trying to avoid. The per-domain opt-in plus per-hostname proxied sync keeps every public host behind the proxy without needing the higher-tier plan for nested certs — and because it's opt-in and default-off, it doesn't change anything for users who don't want it.

Tested: cargo test -p temps-core public_hostname --lib and -p temps-dns --lib (224 pass), and the full temps binary builds.

Two safety bugs found testing against a live zone (careowner.com):

- The stale-record cleanup deleted any single-label record under the managed
  domain that Temps didn't generate — which would have deleted app.careowner.com
  (prod, on another VM) and every other apex record. Deletion is now limited to
  records Temps tagged as managed (`metadata["comment"] == temps:managed`); the
  cloudflare crate exposes no record comment, so this is a no-op there and
  pre-existing/user records are NEVER deleted. The sync only creates/updates.
- The sync enumerated all environments, so it tried to point prod-env hosts at
  the staging/preview edge_target. Production environments (slug/name
  prod|production) are now excluded.

Also: add a proper create-vs-update path (previously existing records were never
updated), and enumerate from environments.subdomain instead of
environment_domains (which can hold user custom FQDNs).

Adds CF-free unit tests via an in-memory mock DnsProvider covering: untagged
records are never deleted, only tagged-stale are deleted, create/update/no-op
transitions, dry-run writes nothing, A/AAAA/CNAME selection, and prod exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bherila

bherila commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: DNS-sync safety fix + CF-free test coverage

Pushed d4775d24 addressing a safety issue found while testing the Cloudflare zone sync against a live zone, plus test automation so the reconcile logic can be validated without hitting the Cloudflare API.

The bug

The first cut of the per-hostname sync computed deletes too aggressively: a dry-run against a real zone (careowner.com) would have deleted the production record app.careowner.com (and ~10 other untagged records) and created a *-prod record pointing at the staging edge IP. Root causes:

  1. Deletion treated any untagged single-label record under the base as "stale".
  2. Production environments weren't excluded from the generated host set.

The fix (crates/temps-dns/src/services/hostname_sync.rs)

  • Deletes are now tag-scoped: only records carrying the temps:managed tag are ever deleted. Untagged / user-created records are never touched. (Cloudflare records can't carry the tag through the provider abstraction, so on Cloudflare the sync is effectively create/update-only — the safest behavior.)
  • Production environments are excluded (is_production_env matches prod/production on slug or name); deleted environments are skipped.
  • Generated host enumeration now uses environments.subdomain.
  • Records are upserted via set_record (create-or-update), comparing existing content against the configured edge target.

Test automation (no Cloudflare needed)

Added #[cfg(test)] mod tests with an in-memory MockProvider implementing DnsProvider, covering 7 cases incl. the regressions above:

  • reconcile_never_deletes_untagged_records
  • production_environments_are_excluded
  • reconcile_deletes_only_tagged_stale_records
  • reconcile_creates_missing_and_skips_correct
  • reconcile_dry_run_writes_nothing
  • desired_content_picks_record_type, relative_name_strips_suffix

All 7 pass (cargo test --lib -p temps-dns hostname_sync).

Live verification (against the real careowner.com Cloudflare zone)

  • Dry-run preview now shows only the two staging creates (careowner-staging, careowner-preview → A 35.163.83.53), zero deletes.
  • Applied the sync; re-running the preview returns 0 changes (idempotent — records confirmed present in the live zone via list_records).
  • app.careowner.com (production) unchanged before/after; careowner-prod (a pre-existing health-check record) correctly excluded and untouched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support flattened public hostname templates for proxied wildcard TLS providers

2 participants