Commit Graph
56 Commits
Author SHA1 Message Date
jw 76eb7cfc69 fix(dynadot): read an apex MX that Dynadot returns typed "stealth"
Dynadot's records endpoint returns suchshop.lol's apex MX with record_type
"stealth", the mail host in record_value1 and the preference in record_value2.
Sending that record back, which any preserve-unmanaged write has to do, is
rejected by Dynadot with "Please enter a valid stealth forwarding url looks like
this: http://subdomain.example.com". The POST replaces the whole zone, so the
rejection takes every other record in the batch with it. Six DMARC report
authorizations could not be published for exactly this reason, and no amount of
correctness in the manifest would have helped.

Confirmed rather than inferred. The apex returns three records, stealth/txt/a,
and the apex serves three records, MX/TXT/A. Matching them one to one gives
stealth = MX 10 mail.suchshop.lol; the txt and a records match their live values
exactly, and there is no fourth record and no forwarding configured. Three
separate reads across two days returned the same thing.

The correction is narrow because the two shapes cannot overlap. A genuine stealth
forward stores a URL in value1 and leaves value2 empty, which is the format
Dynadot's own error message asks for. A hostname in value1 with an integer
preference in value2 is not a valid stealth record in any configuration, so the
reader treats that combination, and only that combination, as MX.

The end-to-end read test already carried a copy of this zone and asserted the
bogus type was preserved verbatim, calling it "an unusual type is still a
record". That assertion described the bug. It now requires the apex to read as
MX with its preference intact.
2026-08-26 12:46:21 -04:00
84e4c62fbd Retry a dropped Dynadot connection (#7)
A full-zone write died with SSL UNEXPECTED_EOF_WHILE_READING. Not our request:
three plain requests to their API from one machine gave 000, 200, 000 within a
few seconds. Their endpoint is simply unreliable, and the operator was being
made the retry loop.

Only a connection that produced no response is retried. An HTTP status is an
answer, however unwelcome, and repeating a request the server already judged
turns one refusal into several.

Retrying a POST is safe here specifically because set_hosts replaces the whole
zone with add_dns_to_current_setting false, so the same body applied twice
leaves the same zone and a reply lost on the way back costs a duplicate write
and nothing else. The comment says plainly that this does not transfer to an
endpoint that appends, and a test pins both halves of that reasoning.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-25 08:50:08 -04:00
b041a78d29 Write Dynadot records under the names Dynadot expects (#6)
The write path sent value1 and value2. Dynadot uses record_value1 and
record_value2 in both directions and answered with "if record_type is entered,
record_value1 must be entered", which reads like a missing value rather than a
misnamed one.

I corrected the reader for exactly this last time and left the writer alone,
having concluded the two spellings differed. They do not. The unit test agreed
with the code rather than with Dynadot, so it passed while every write was
refused.

That is now twice, in opposite directions, with a green suite each time. So this
adds a round trip: read a zone, write it straight back, and assert nothing
changed. It feeds the writer exactly what the reader produced, so a spelling the
two do not share shows up as a value that vanished, and it fails against the
code as it was.

Worth the care because set_hosts replaces the whole zone. A record the reader
drops or the writer misnames is a record deleted from a live domain, and the
first zone we are writing is a shop.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-25 08:06:20 -04:00
eb86a9df23 Read a Dynadot zone the way Dynadot actually reports one (#5)
A live zone was unreadable for two reasons, and the second was hidden behind
the first.

The TTL arrives as a string. "300" failed isinstance(ttl, int), while the write
path already coerced the same value with int(record.ttl): reading was stricter
than writing, so we refused zones we were perfectly capable of writing. A bool
is now excluded explicitly, because True is an int in Python and would have been
read as a TTL of 1 and then rejected for being out of range rather than for
being a bool.

Records arrive as record_value1 and are written back as value1. The reader knew
only the write spelling, the one it never receives, so every record on a real
zone came back incomplete. Both are accepted, response spelling first.

Either alone makes every Dynadot zone unreadable, which is why neither had been
noticed: no Dynadot zone had ever been read successfully.

This matters more than a parse error usually would. set_hosts replaces the whole
zone with what the caller passes, so a value the reader drops is a record
deleted from a live domain. The test is shaped from an actual response rather
than from the documentation, and asserts that all six records survive with their
values intact, including an MX preference and a record type as unusual as
STEALTH.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 11:30:17 -04:00
04fcd20954 Send Dynadot's headers through http.client so the names survive (#4)
The previous fix did not work. Assigning Request.headers preserves the name in
the object, and urllib then title-cases every header inside do_open, immediately
before writing them to the socket. X-Request-ID still went out as X-Request-Id,
which Dynadot rejects, so nothing changed.

I verified that fix by inspecting Request.header_items() and it passed. A data
structure holding the right string is not evidence that the right string was
sent, and that is the whole of my mistake.

No spelling survives .title() as X-Request-ID, so urllib cannot be made to send
it. The request goes out through http.client, which writes header names
verbatim. The urllib.request.Request stays as the unit handed to an injected
opener so the existing tests keep inspecting what would actually be
transmitted, and a 4xx is re-raised as urllib.error.HTTPError so the caller's
error handling, which reads the body to surface Dynadot's own explanation,
is untouched.

The new test captures the headers at the socket rather than on the Request, and
fails if the opener is put back to urllib.request.urlopen. That is the assertion
the previous attempt should have made.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 11:04:03 -04:00
77c9e12e2d Send X-Request-ID with the case Dynadot requires (#3)
Every Dynadot request this provider has ever made was rejected with "the
X-Signature provided is not valid". The signature was correct. The header name
was not.

urllib capitalises header names when they go through add_header, which the
Request constructor does for everything passed to it, so X-Request-ID left as
X-request-id. Header names are case-insensitive under RFC 9110 and Dynadot's
gateway is not: it matches X-Request-ID exactly, did not find the rewritten
form, and computed the signature with an empty request id while we computed ours
with the UUID. A correct signature over a different string, reported as a bad
signature.

That message sends you to the algorithm and the credentials, which is where I
spent the evening. The header we were plainly sending is the last thing anyone
would suspect.

Assigning the mapping after construction bypasses add_header and preserves the
name. Confirmed against the live API: exact X-Request-ID is accepted, and
X-Request-Id one letter different is rejected.

Two tests, both of which fail against the previous code: the wire name keeps its
case, and the id in the signature is the id in the header. The second is the
invariant that actually broke, and it can break again without the first.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 10:41:47 -04:00
a4b8a8729b Surface Dynadot's refusal instead of only its status code (#2)
A 400 from Dynadot arrived as "HTTP 400" and nothing else. Dynadot explains the
refusal in the response body and we were discarding it, so the operator was left
with a number that does not distinguish "the domain is not in this account" from
"the signature is wrong" from "the caller address is not allowlisted". All three
are 400 and all three have different fixes.

The body is bounded to 400 characters and quoted as remote text. No credential
can appear in it: the key travels in an Authorization header and the signature
is computed rather than echoed.

Reading the body is best-effort. A stream that cannot be read must not cost the
status code as well, so the failure to read is swallowed and the code is still
raised, which is what the second test pins.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 09:01:51 -04:00
John Winter MurphyandGitHub 644652b186 Fix Namecheap non-MX preference normalization
Ignore Namecheap MXPref placeholders outside MX records, add round-trip regression coverage, and keep the declared pytest gate collectable.
2026-08-01 14:31:05 -04:00
jwinterm 3fe9c472ef feat(dns): allow exact protected-name record exceptions 2026-07-31 16:06:46 -04:00
jwinterm 0c6bbace07 feat: add guarded Dynadot DNS reconciliation 2026-07-31 15:07:41 -04:00
jwinterm 502e5bd587 feat: reconcile preserve-unmanaged DNS manifests 2026-07-30 07:43:17 -04:00
jwinterm 4767b7965a fix: emit real micron, and validate values interpolated into daemon configs
render_micron was a one-line alias to the Gemtext renderer, so every .mu file
AMPG produced was Gemtext. A Nomad Network browser renders those markers as
raw punctuation. Micron is a different markup: headings are > by depth, links
are inline as `[label`url] rather than standalone lines, emphasis is a toggle
pair, and literal blocks are fenced with `=.

Content is now escaped as well. Micron's control character is the backtick, and
>, <, #, and - are structural at column zero, so a vendor named ">Free Beer"
would have become a section heading on the published page. The line-start check
applies to the emitted line rather than each text chunk, since whether a
character is structural depends on it landing at column zero.

Separately, torrc and tunnels.conf are line-oriented with no quoting or escape
mechanism, so a newline in an interpolated value injects an arbitrary directive:
a tunnel_name of "web\nhost = attacker.example" silently repoints the tunnel.
tunnel_name and keys_file are now validated at the point of interpolation.
_validate_site_id covers the init path only, so a config loaded from disk never
passed through it. There is nothing to escape with here, so these reject rather
than sanitize.

Generated systemd units had no User= and no hardening, so an operator following
the install steps as root got a root-run daemon. Units now carry User=/Group=
when a service account is configured, a visible warning when not, and a baseline
of hardening directives chosen to not break a daemon whose state directory may
live anywhere.

Two tests asserted Gemtext markers inside a .mu file and so had encoded the bug.
2026-07-28 16:03:40 -04:00
jwinterm bb63585098 Add signed service manifest contract 2026-07-11 11:05:48 -04:00
jwinterm 1651514c8e Version browser context fixture contract 2026-07-11 10:20:54 -04:00
jwinterm 84d72cb2ed Declare browser profile isolation 2026-07-11 09:34:28 -04:00
jwinterm f0d26933e6 Model transport provider sources 2026-07-08 10:12:59 -04:00
jwinterm e272ec1afb Recognize distro i2pd daemon process 2026-07-08 09:14:13 -04:00
jwinterm c38eab9c28 Fix I2P web tunnel deployment shape 2026-07-08 09:11:32 -04:00
jwinterm 234a510798 Add DNS provider flow and demo site split 2026-07-07 12:41:52 -04:00
jwinterm 7f3631a624 Add Micron renderer and AMPGateway site target 2026-07-07 11:34:54 -04:00
jwinterm a143cd9342 Add package deploy apply stage 2026-07-07 09:37:36 -04:00
jwinterm 8b58bfb588 Add health deploy apply stage 2026-07-07 08:01:20 -04:00
jwinterm 8f4724089b Add address capture deploy apply stage 2026-07-07 05:56:47 -04:00
jwinterm c3d44a5e5b Add start stage deploy apply 2026-07-07 05:29:34 -04:00
jwinterm f276eb470b Add supervisor stage deploy apply 2026-07-06 21:47:30 -04:00
jwinterm b2bf06e050 Add free domain DNS onboarding hints 2026-07-06 18:52:57 -04:00
jwinterm 1dd601b0e4 Add state stage deploy apply 2026-07-06 18:49:34 -04:00
jwinterm a7500dacbc Add clearnet DNS reachability planning 2026-07-06 18:16:33 -04:00
jwinterm f4a1355212 Add guided deploy plan 2026-07-06 18:06:42 -04:00
jwinterm db5d9737c0 Add site onboarding config generator 2026-07-06 17:55:19 -04:00
jwinterm 0426ca673c Require approved artifact digests for apply 2026-07-06 16:30:40 -04:00
jwinterm fbfddbf2bf Add apply preflight gate 2026-07-06 12:29:08 -04:00
jwinterm a921f158c3 Plan managed supervisor actions 2026-07-06 12:17:05 -04:00
jwinterm 3063e61676 Plan managed state artifact copies 2026-07-06 11:49:36 -04:00
jwinterm b268e93634 Add managed state contract 2026-07-06 11:06:11 -04:00
jwinterm 12983ca134 Add address capture to activation plan 2026-07-06 10:30:42 -04:00
jwinterm bf2a171ec6 Capture transport addresses in state 2026-07-06 07:00:04 -04:00
jwinterm c52e700b6f Add fixture-based health plans 2026-07-06 06:02:58 -04:00
jwinterm 1641f2009d Write managed install artifacts 2026-07-06 05:50:12 -04:00
jwinterm 035ab4ac81 Add managed daemon install plans 2026-07-06 05:42:41 -04:00
jwinterm a3ad5c900b Add deployment profiles 2026-07-06 05:28:35 -04:00
jwinterm 034b463611 Scope operations by selected protocol 2026-07-06 05:01:45 -04:00
jwinterm 83847b205b Add dry-run apply activation plan 2026-07-06 04:54:03 -04:00
jwinterm 27f3805c19 Add transport status and doctor preflight 2026-07-06 04:47:48 -04:00
jwinterm 2e9307ba26 Add local preview fixture manifests 2026-07-06 04:24:30 -04:00
jwinterm 407d4d106a Add route manifest contract validation 2026-07-06 04:07:10 -04:00
jwinterm c1d8337365 Explain route exposure decisions 2026-07-06 03:58:47 -04:00
jwinterm bd782ed7ed Gate route fixtures by protocol tier 2026-07-06 03:51:14 -04:00
jwinterm 2ca73ca431 Import app route manifests 2026-07-06 03:49:34 -04:00
jwinterm cf0b89b572 Expand fixture manifests with route policies 2026-07-06 03:41:04 -04:00