Edge and DNS verification — the four checks after a change at the edge¶
Status: in use. Moved here from issue #357 on the owner's instruction: a checklist that can never close is not a ticket. The four checks and their success criteria are unchanged from the issue. The run log at the bottom replaces the issue's dated comments, and the 2026-09-07 run is seeded there as the first entry so the format is demonstrated rather than described.
Scope: four read-only checks that say whether the public site and the public API still answer after something at the edge moved. Every command below is a read; nothing here changes anything.
Authority: this page does not authorize a change. It is what you run after one.
When this comes due¶
Only on a trigger. There is no schedule, and adding one would be the wrong
instrument — the Worker probe of ADR 0024
already asks /api/health every five minutes, and these checks exist to catch
what a change just did, not to watch for drift.
Run all four after any of:
- a DNS change in the
hybridcloudworks.comzone, whether from an apply ofinfra/frontend.tfor by hand in the Cloudflare dashboard; - a Cloudflare rule change — the origin-secret transform rule, a WAF or rate-limiting rule, Bot Fight Mode, or a plan change that makes any of those available;
- a Static Web App custom-domain change, including a re-validation of the apex;
- a Functions custom-domain change, including a rebind of
api-azure.hybridcloudworks.com.
Taking a baseline before an unrelated apply is a legitimate second use — the 2026-09-07 run below was one — but it is not required, and such a run is recorded with its trigger stated as none.
Read this first: only one hostname is proxied¶
The obvious expectation — Cloudflare in front of the whole zone — is wrong here, and an operator holding it will raise a finding on every single run.
| Hostname | Answers from | Proxied | Declared in |
|---|---|---|---|
hybridcloudworks.com (apex) |
Azure, the Static Web App | no | not Terraform-managed |
www.hybridcloudworks.com |
Azure, CNAME into azurestaticapps.net |
no | not Terraform-managed |
api-azure.hybridcloudworks.com |
Cloudflare | yes | infra/frontend.tf, cloudflare_dns_record.azure_functions |
docs.hybridcloudworks.com |
GitHub Pages | no | infra/frontend.tf, cloudflare_dns_record.docs_pages |
podcast.hybridcloudworks.com |
Podbean, CNAME to pbcdn2.podbean.com |
no | infra/frontend.tf, cloudflare_dns_record.podcast_podbean |
go.hybridcloudworks.com |
Linkie, CNAME to app.linkie.bio |
no | infra/frontend.tf, cloudflare_dns_record.linkie_go |
notes.hybridcloudworks.com |
Cloudflare, in front of Obsidian Publish (CNAME to publish-main.obsidian.md) |
yes | infra/frontend.tf, cloudflare_dns_record.notes_obsidian_publish |
asuid.api-azure.hybridcloudworks.com |
nothing — a TXT record holding Azure's ownership proof | n/a | infra/frontend.tf, cloudflare_dns_record.azure_functions_domain_verification |
_cf-custom-hostname.go.hybridcloudworks.com |
nothing — a TXT record holding Linkie's ownership proof for go |
n/a | infra/frontend.tf, cloudflare_dns_record.linkie_go_verification |
infra/frontend.tf declares exactly seven Cloudflare records. ADR 0024
(written when there was one) names api-azure in as many words as the one
proxied hostname; notes is the second, added for Obsidian Publish (#1350).
Four things about that table are deliberate rather than oversight:
- The apex and
wwware not in Terraform. A Static Web App root domain validates against a token Azure generates when the validation starts, so pinning one in state would drift the moment Azure reissued it. They are created by hand, per cutover runbook step 3b. docsis DNS-only. GitHub issues and renews a Pages certificate by checking that the CNAME resolves to*.github.io; a proxied record answers with Cloudflare's addresses instead, and the domain sticks at "certificate pending".podcastis DNS-only, and a subdomain. Podbean provisions the certificate for the show's website the same way. Its "Use a domain I own" page prefillswww. A CNAME there would hand the site to the podcast host. The podcast takespodcast.hybridcloudworks.com, neverwwwor the apex.gois DNS-only, and a subdomain. Linkie issues the certificate for a custom domain itself, so the name has to resolve toapp.linkie.bio. Its Domain settings page lists rows named@andwwwbeside the TXT. Nothing there says whether those names are relative togoor to the zone, and read as the zone a CNAME at either would hand the site to Linkie. So only what Linkie's own help article asks of a subdomain is declared: thegoCNAME and the TXT. If Linkie ever needs awwwrow forgo, it iswww.go.hybridcloudworks.com, never the zone's ownwww.notesis the exception: it is proxied. Obsidian does not issue a certificate for a custom name. Its help page (Custom domains) asks for a proxied CNAME topublish-main.obsidian.mdand the zone's SSL/TLS mode set to Full, so Cloudflare terminates the visitor's connection. Anotesanswer carrying aCF-RAYheader is therefore the design, and an answer without one is the finding. The zone's SSL/TLS mode is not in Terraform: a redirect loop means it reads Flexible (Obsidian's page says so), and a 526 means it reads Full (strict) and Cloudflare could not validate Obsidian's certificate for this name (Error 526). A 525 is a different fault: the TLS handshake with Obsidian failed under Full or Full (strict) (Error 525), so it does not say which of the two is set; read the mode before choosing anything, and expect the cause to be on Obsidian's side. Fix a loop or a 526 with a rule scoped tonotes, never by lowering the mode for the whole zone, whichapi-azurealso sits behind.
So the apex resolving to Azure, answering with no CF-RAY header, and
presenting a DigiCert-issued certificate is the design. An apex behind
the Cloudflare proxy is the finding, not an apex in front of it.
Issue #357 originally said success looked like "DNS resolves to Cloudflare, not directly to Azure". Measured against the zone as a whole, that has never been true and was never meant to be. The 2026-09-07 run corrected it, and this section is that correction: the old wording would have read as a red result on every future run, which is how a checklist becomes something people stop running.
The four checks¶
PowerShell, from the owner's workstation. Not from a cloud VM, a GitHub
runner or a hosted shell — Cloudflare's Bot Fight Mode answers datacenter
clients with a 403 interstitial, which is a reporting failure that looks
exactly like an outage. That is the whole reason the availability probe is a
Cloudflare Worker (ADR 0024), and why scripts/check-unresolved-secrets.mjs
reads ARM rather than asking /api/health.
1. DNS resolves the way the split says it should¶
'hybridcloudworks.com','www.hybridcloudworks.com','api-azure.hybridcloudworks.com','podcast.hybridcloudworks.com','go.hybridcloudworks.com','notes.hybridcloudworks.com' | ForEach-Object { Resolve-DnsName -Name $_ } | Select-Object Name, Type, IPAddress, NameHost
Resolve-DnsName -Name '_cf-custom-hostname.go.hybridcloudworks.com' -Type TXT | Select-Object Name, Type, Strings
'https://hybridcloudworks.com/','https://api-azure.hybridcloudworks.com/api/health','https://podcast.hybridcloudworks.com/','https://go.hybridcloudworks.com/','https://notes.hybridcloudworks.com/' | ForEach-Object { $u = $_; $r = $null; try { $r = Invoke-WebRequest -Uri $u -UseBasicParsing -ErrorAction Stop } catch { $r = $_.Exception.Response }; if ($r) { [pscustomobject]@{ Url = $u; Status = [int]$r.StatusCode; Server = ($r.Headers['Server'] -join ','); CfRay = ($r.Headers['CF-RAY'] -join ',') } } else { [pscustomobject]@{ Url = $u; Status = 'no response'; Server = ''; CfRay = '' } } }
The HTTPS line catches a non-2xx answer and reports its status and headers instead of stopping, because notes answers with an error page until the name is claimed in Obsidian, and that error page's CF-RAY is exactly what the check is after. It reads the headers of the caught response the way Windows PowerShell 5.1 exposes them; in PowerShell 7 the caught response is a different type, so run it in powershell.exe.
-UseBasicParsing is deliberate and is not there for PowerShell 7, where it
does nothing at all — measured on 7.6.5, it is accepted silently, with no
deprecation warning and no effect. It is there for Windows PowerShell 5.1,
which still ships with Windows and is what powershell.exe starts: without the
flag, Invoke-WebRequest parses the response with the Internet Explorer
engine, and on a host where IE's first-run configuration has never been
completed it throws instead of returning. That failure names the browser
engine, not the site, so it reads as the site being down when nothing is wrong
— which is precisely the reporting failure this runbook exists to prevent.
Costing nothing in the shell you are probably using and saving a false alarm in
the other one, it stays.
Success. The apex returns an A record outside Cloudflare's ranges and
www returns a CNAME into *.azurestaticapps.net; neither answer carries a
CF-RAY header or Server: cloudflare. api-azure returns two A records
inside 104.16.0.0/13 and 172.64.0.0/13 — both published Cloudflare ranges —
plus AAAA records, and it answers with Server: cloudflare and a CF-RAY.
notes returns two A records inside Cloudflare's ranges, as api-azure does, and its HTTPS answer carries Server: cloudflare and a CF-RAY; the status is not part of the check, since an unclaimed name answers with an error page (an answer with no CF-RAY would be the finding). podcast returns a CNAME to pbcdn2.podbean.com and answers with no
CF-RAY, because it is DNS-only and Podbean serves it. Until the domain is
entered in Podbean and its certificate is issued, that HTTPS request fails: an
unfinished setup, not an outage. go returns a CNAME to app.linkie.bio and
answers with no CF-RAY either; until Linkie's Domain settings read Live
(up to 48 hours after the records applied), its HTTPS request can fail the
same way. The TXT query returns one TXT record whose Strings is Linkie's
ownership value for go; a CNAME that resolves with no TXT behind it is an
unfinished setup that will not turn Live, so check the TXT before reading a
failed HTTPS request as the documented wait. While Linkie has not yet
registered the hostname, a browser gets Cloudflare error 1014, "CNAME
Cross-User Banned" instead of the page (seen 2026-10-24, four minutes after
the records applied). Cloudflare's page for that error says a CNAME between
accounts works only when the owner of the target uses Cloudflare for SaaS, so
this reads as Linkie's side not yet activating go, not as a fault in the
records; it clears when Linkie's Domain settings read Live. Key on the ranges and the headers, never on a
literal address: the Static Web App's address is Azure's to change.
If the headers are ambiguous, confirm the certificate in a browser at
https://hybridcloudworks.com/. The padlock should show CN=hybridcloudworks.com
issued by DigiCert on a GeoTrust chain — the Static Web App's managed
certificate, not a Cloudflare edge certificate.
Not a failure. A disagreement within a few minutes of a DNS change is
propagation, not drift. Resolve-DnsName reads the local client cache first,
so run Clear-DnsClientCache and try again, then ask a public resolver
directly:
'hybridcloudworks.com','www.hybridcloudworks.com','api-azure.hybridcloudworks.com' | ForEach-Object { Resolve-DnsName -Name $_ -Server 1.1.1.1 } | Select-Object Name, Type, IPAddress, NameHost
Give it the record's TTL before calling it a finding: five minutes on the
unproxied records Terraform manages, whatever the Cloudflare dashboard shows on
the hand-made apex and www, and less than that on api-azure, which is
TTL-automatic because a proxied record accepts no other value.
2. The site answers¶
Success: 200.
How it fails, and what each failure means. PowerShell throws on a non-2xx
rather than printing the number, so a failure arrives as a red exception —
read StatusCode off its response before concluding anything. A
404 immediately after a custom-domain change is the Static Web App binding
still validating, not the site being down. A 403 here would be Cloudflare,
and that is itself the finding, because the apex is not supposed to be proxied
at all.
3. The API is healthy¶
Success: status is ok, service is hcw-functions, and
unresolvedSecrets matches the number of entries in EXPECTED_UNRESOLVED in
scripts/check-unresolved-secrets.mjs (3 as of 2026-11-01:
AZURE_SPEECH_KEY, CLOUDFLARE_ANALYTICS_TOKEN and CODER_BROKER_TOKEN).
generation names the CI run and commit that stamped the running
configuration, which is worth comparing against the deploy you just made when
the trigger was a Functions change.
A count above zero can be the healthy answer, and an operator who does not
know that will chase it. The count is Key Vault references that arrived as
the literal @Microsoft.KeyVault(…) string instead of a resolved secret. Each
entry in EXPECTED_UNRESOLVED is one of those on purpose, with its reason
beside it: Azure AI Speech is the written, tested fallback TTS path,
deliberately unprovisioned because standing one up is a spend decision nobody
has made, and the other two wait on a token the owner has not minted yet. Azure
surfaces every app setting twice, plain and APPSETTING_-prefixed; since
1490 the health endpoint counts such a pair once, so the number is settings,¶
not aliases (before that it read 2 for the one Speech setting, and the table below records that older reading).
Any other number is actionable, and a matching number is not yet a pass: the
same count can hide a swap (one expected name starting to resolve while another
setting stops). Which references are unresolved is deliberately not in this
anonymous response; the names are on the authenticated Integrations page at
https://hybridcloudworks.com/admin/integrations, and Actions →
Monitor Unresolved Secrets answers the same question from ARM without
traversing Cloudflare at all and compares the names with the list itself. Call
this step passed only when the unresolved names are exactly the names on
EXPECTED_UNRESOLVED: the monitor says so as "nothing unexpected" with no stale
entry.
The Health page shows the same
answer on its Pipeline Readiness card. Names off the list are in red and
make the card Critical. Names on it are listed under Unresolved on purpose,
with each one's reason when you hover it, and do not. The page and the monitor
read one list, functions/src/lib/expected-unresolved.js, since
#1512.
This line once pointed at /admin/ops-health, which at the time carried no
names: getOpsHealthSnapshot returned readiness, the digest, alerts and
operational signals, and nothing about secrets. Its readiness block has
carried the names since T-720. The Integrations page reads them another way,
from cms/secrets, which is the credential surface — the old API Keys
page, now merged into Integrations. There an unresolved reference shows the
same gray Not set light as a secret nobody ever seeded, because
computeSecretState cannot tell the two apart from inside the worker: both
mean process.env is holding the literal @Microsoft.KeyVault(…) string or
nothing at all.
A 404 on every route is not a DNS or edge failure, though it presents like
one. It is the Functions host answering with no functions registered, or App
Service finding no site bound to api-azure.hybridcloudworks.com. Both have
the same signature, and neither is fixed here — see
Alerting and support.
4. A public read returns data, not a challenge¶
Invoke-RestMethod -Uri 'https://api-azure.hybridcloudworks.com/api/public/podcasts?provider=azure&limit=1' | ConvertTo-Json -Depth 3
Success: JSON with success true. items: [] and total: 0 is a pass —
no feed is configured yet, which is a content question and not an edge one.
What this check asks is whether an anonymous caller reaches the application at
all.
HTML instead of JSON is a Cloudflare challenge page: Bot Fight Mode has decided the caller is a datacenter client. Re-run from the workstation before treating it as an outage.
A 403 on every anonymous request — the whole public API refusing, not one route — is the origin lock, and it means the shared secret is out of step. It has to match in three places at once:
- the
cloudflare_origin_secretworkspace variable in HCP Terraform, https://app.terraform.io/app/hcw/workspaces/hcw-azure/variables; - Key Vault
CF-ORIGIN-SECRETinkv-site-prod-cus-01, which the Function App reads as theCF_ORIGIN_SECRETapp setting (infra/functionapp.tf); - the Cloudflare transform rule that stamps
x-hcw-origin-secret(cloudflare_ruleset.origin_secretininfra/frontend.tf).
The third is written by Terraform from the first, so the repair is to make (1)
and (2) agree and re-apply — never to edit the rule in the Cloudflare
dashboard, which the next apply would overwrite. Check when the vault side last
moved, without the value ever reaching the screen (secret list returns
metadata only, no values):
az keyvault secret list --vault-name kv-site-prod-cus-01 -o json | ConvertFrom-Json | Where-Object { $_.name -eq 'CF-ORIGIN-SECRET' } | Select-Object name, @{n='updated';e={$_.attributes.updated}}
TODO.md carries this as an accepted risk with the same consequence stated:
the value must change in all three places in one window, and a partial rotation
is a full outage of the public API rather than a degradation. See
TODO and accepted risks.
The daily DNS audit¶
The four checks above run on a trigger. Between triggers, one thing watches
the zone itself: the
Cloudflare DNS audit
workflow, added 2026-10-18 as the LAB-1 interim (#948). Until that evening
the lab host's Caddy held a token with DNS edit on hybridcloudworks.com
(ADR 0032); the challenge names
are now delegated into the lab zone hybridcloudinsights.com, the runtime
token is scoped to that zone alone and the production-zone token is revoked
(#1279). Cloudflare has no notification type for audit-log or DNS-record
events on any plan, so a poller of the account audit log stands in for the
notification that does not exist, and it keeps watching the production zone
after the cut-over.
What it checks. Daily at 05:50 UTC, scripts/cloudflare-dns-audit.mjs
reads the account audit log (Audit Logs v2,
GET /accounts/{account_id}/logs/audit, paged through in full) from two
hours before the workflow's previous successful scheduled run — never
less than 26 hours back, never more than 14 days, and 26 hours when there is
no previous success — so a failed or missed run widens the next window
rather than leaving a gap. Manual runs (workflow_dispatch) never move that
resume point, so a narrow since by hand cannot hide the time before it; a
longer outage is read in manual windows, each judged on its own. It keeps the
create, update and delete events on DNS records in the production zone, and
judges each against the allowlist at the top of the script. Anything the
allowlist does not name is UNEXPECTED. A run that could not read the log
fails, on purpose: a quiet run means the window was read and every change
was expected, never that the token was missing.
The allowlist, and how to extend it. Five entries, each with its reason
beside it in the script. They are bound to what the audit log really carries,
read on the first live run (2026-10-18): for this account's API tokens the
entries have no token_name or token_id (the API reference lists both
as optional), so a token is told by its actor type and context, the request's
user agent, and — for the Caddy token — the address its client-IP filter pins
it to.
| Entry | Allows | Identified by |
|---|---|---|
hcp-terraform |
every DNS-record action by the HCP Terraform workspace's token | an account-owned API token (actor.type account, actor.context api_token) whose request came from the Cloudflare Terraform provider (user agent terraform-provider-cloudflare/…), not from the lab host's address, while an apply of hcw/hcw-azure was in progress by HCP Terraform's own run record (read with the TFC_TOKEN secret the repository already holds; two minutes before applying-at to five after the apply ended — applied-at, or errored-at / canceled-at / force-canceled-at for an apply that stopped early, which still wrote until then; the 6 and 10 Oct 2026 edits were exactly those). A user agent is a header anyone can send, so the apply is the identity. Without TFC_TOKEN the entry is inactive; the token name hcp-terraform-cloudflare-dns is matched too, should the log ever carry it |
terraform-owner-token |
the owner's own terraform runs from the workstation with a user-owned token (the 2026-10-06 00:26Z edits) — kept as a separate entry so they stay visible: the workspace is the normal path |
a user-owned API token (actor.type user, actor.context api_token) under the CLOUDFLARE_DNS_AUDIT_OWNER_EMAIL e-mail with the provider's user agent |
caddy-acme |
the Caddy runtime token, only on a TXT record named _acme-challenge.<lab name> (_acme-challenge.lab.hybridcloudworks.com or _acme-challenge.coder.lab.hybridcloudworks.com); an A record from it, or a challenge for www, is the finding the poller exists for |
an API-token request from the lab host's address in the CLOUDFLARE_CADDY_HOST_IP repository variable (the token's client-IP filter allows that address alone), or the token name in CLOUDFLARE_CADDY_TOKEN_NAME should the log carry it |
owner-dashboard |
the owner's own edits in the Cloudflare dashboard (the apex and www records are hand-made by design, above) |
a dash session whose e-mail is the CLOUDFLARE_DNS_AUDIT_OWNER_EMAIL repository variable |
cloudflare-dcv |
Cloudflare's own certificate validation: a TXT record named exactly _acme-challenge.hybridcloudworks.com (the apex, which validates the universal certificate for the apex and its wildcard), created and deleted minutes later when that certificate renews, which starts 30 days before it expires (Certificate validity periods). The zone is a full setup, and for one Cloudflare "handles DCV automatically on your behalf using a TXT record" (Changing DCV method, read 2026-11-02; the record's name is the _acme-challenge.<domain> of the TXT method). First seen 2026-10-26 (#1421). A challenge for any other name (www, a lab name), an update, or a system actor on any other record, stays UNEXPECTED |
an actor whose only field is type system, with no context, id, e-mail, address or token fields, no request method and no user agent. Cloudflare's audit-log reference defines system as changes made automatically by Cloudflare systems; a token's request is authenticated and lands as account or user with context api_token, so a token cannot take this shape. That shape was read from the live entries of 2026-10-26, 10-27 and 10-30, not promised by the documentation, which says a system entry "may include associated user context": if a renewal entry ever carries more than type, the entry stops matching and the issue re-opens; re-read the shape before widening. Needs no variable; always active |
A Terraform user agent is a header anyone can send, which is why
hcp-terraform needs an apply in progress and why both Terraform entries
exclude the lab host's address: a request from there is the Caddy token
(Cloudflare lets that token work from nowhere else) and is judged by
caddy-acme, whatever its user agent says. terraform-owner-token rests on
the actor e-mail the log carries for a user-owned token, an authenticated
identity rather than a header.
An entry whose variable is unset is inactive, and the report says so: its
edits then read UNEXPECTED, which costs an issue to read and never a missed
edit. A new legitimate actor is a new entry with its reason, added to
ALLOWLIST in scripts/cloudflare-dns-audit.mjs with a case in
scripts/cloudflare-dns-audit.test.mjs, in a reviewed pull request; an
existing entry is never widened in place.
What it needs. The CLOUDFLARE_AUDIT_LOG_TOKEN repository secret, a
Cloudflare API token with Account → Account Settings → Read on the one
account and nothing else (that is the permission the audit-log endpoint
accepts; the token catalogue has no separate audit-log read), and the
CLOUDFLARE_ACCOUNT_ID repository variable. Optional: CLOUDFLARE_ZONE_ID
(the zone is matched by name regardless), the three allowlist variables
above, and the TFC_TOKEN secret the repository already holds for
tfc-plan-check.yml, which arms hcp-terraform (the job sits in the
automation environment for the two secrets, as the drift monitor does).
All of them are in
Required inputs, §4.2 and §4.3.
Running it by hand. Open the workflow page linked above → Run
workflow; leave since and before empty for the default window, or set
either as YYYY-MM-DD or an RFC3339 timestamp (UTC) to read a wider or older
window, up to the 18 months Cloudflare retains. Untick open_issue while
tuning the allowlist: the run then only reports, and for each UNEXPECTED
change the summary adds the shape of the entry (the actor's field names as
Cloudflare sent them, the request method, the user agent) so a new allowlist
entry is written to what the audit log really carries. A scheduled run always
escalates. The report is the run's
summary. Success looks like a green run whose summary reads
N DNS-record changes on the zone, **0 unexpected** and lists all four
allowlist entries as active. A red run is a read failure, and its summary
says which: a missing secret or variable, an expired token (HTTP 401 or
403), or an API failure.
What an issue from it means. The workflow opens
Cloudflare DNS audit: unexpected record change (<date>), labelled
Security and live-check, and comments on it rather than opening another
while one is open. Each UNEXPECTED row names the time, the actor (a token by
name, a dashboard user by masked e-mail, with the client address), the
action, the record's type and name, and the audit log entry id. Record
contents are not in the report by design. Then:
- Open the entry in the dashboard at
https://dash.cloudflare.com/?to=/:account/audit-log(search the entry id) and read what was written. - The Caddy token on anything but a lab
_acme-challengeTXT, or an actor you do not recognise: treat it as the token compromise ADR 0032 accepted the risk of. Roll the token first (DNS token stolen; the Caddy token is rotated through the lab host's Vault, Labs host), then revert the record, then run the four checks above and record the run below. - An edit you recognise and the allowlist does not name (a new workflow's token, say): revert nothing, add the entry with its reason as described above, and close the issue naming the pull request.
Recording a run¶
This page is the durable record — issue #357 held the first runs and is closed once this runbook merges. Add a row for every run, newest last, and a short section under the table when the run found something or corrected something. A run that found nothing is still recorded: the value of the log is knowing when the estate was last confirmed, and by which trigger.
| Date | Trigger | 1 DNS | 2 Site | 3 Health | 4 Public read | Notes |
|---|---|---|---|---|---|---|
| 2026-09-07 | None — baseline before the Cosmos exporter apply (#231) | pass | pass | pass | pass | Corrected the DNS criterion, and confirmed unresolvedSecrets: 2 as the expected state |
2026-09-07 — baseline, and the criterion that was wrong¶
Read-only, from the owner's workstation. Three checks passed as written. The fourth did not fail: the criterion it was measured against was wrong, and correcting it is what produced the topology section above.
| Check | Observed |
|---|---|
| DNS | apex A → 172.169.198.52; www CNAME → calm-ground-0d0e6a010.7.azurestaticapps.net; api-azure → 104.21.48.160 and 172.67.154.84 plus two AAAA |
| Site | HEAD / → 200 |
| Health | status: ok, service: hcw-functions, generation: gate2-a8fd3c8, unresolvedSecrets: 2 |
| Public read | {"success":true,"items":[],"total":0} — a real API answer, not a challenge page |
api-azure answered with Server: cloudflare and a CF-RAY header. The apex
and www answered with neither, and the apex presented a DigiCert GeoTrust
certificate for CN=hybridcloudworks.com — the Static Web App's managed
certificate. That split is the design, not drift.
Running scripts/check-unresolved-secrets.mjs against the live ARM response
for func-site-prod-cus-01 reported one reference unresolved as intended
(AZURE_SPEECH_KEY) out of 42, and exited 0.
One thing this run says about #127, which proposes Cloudflare Pro for the
managed WAF rulesets: a zone-level WAF protects proxied traffic, and today the
only proxied hostname is api-azure. The site itself would not sit behind it.
That does not settle #127 either way — the API is the surface that takes
untrusted input — but it should be decided with the topology in view rather
than assumed.
Related¶
- ADR 0024 — why
api-azureis the one proxied hostname, and why reachability is watched by a Worker - Availability probe — the five-minute signal these checks sit alongside
- Alerting and support — what pages an operator, and the 404-on-every-route failure that no rule catches
- DNS token stolen — the roll, when the daily DNS audit above names a token on a record it may not touch
- Cutover runbook, step 3b
— how the apex and
wwwwere bound, and why they are not in Terraform infra/frontend.tf— every record, the custom hostname binding, and the origin-secret ruleset, each beside its reason