Skip to content

Releases: moshcoder/moshcode

v0.18.0 — a DNS switch that can be undone

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 11:53
26ff0ee

moshcode dns enable used to be a one-way write. It put a file in
/etc/systemd/resolved.conf.d/, printed a command for you to run, and had no
opinion about whether any of it worked. If it didn't, the machine was left
routing every lookup at something that wasn't answering — and the way that
fails is the worst shape available: dig works, the browser doesn't, and
nothing reads as an error.

It is now a switch that can be undone.

enable

Preflights, and refuses rather than guesses. Another drop-in setting DNS=
is a trap, not a fallback: systemd-resolved rotates away from a failing server
and never rotates back, so one restart of the bridge moves every query to the
other server permanently — and that server answers NXDOMAIN for every Moshpit
name. DNS looks healthy while the whole namespace is dead. A stale listener on
127.0.0.1:5354 does the same thing by beating a 0.0.0.0 bind and answering
nothing. Both are named and both stop the run.

The checks are on behaviour, not identity. A stranger holding the port only
blocks if it fails a forwarding probe — otherwise it is a working bridge
somebody started by hand, and refusing would make --force the normal way to
run the command, which is how a safety check stops being one.

Verifies both directions. A Moshpit name and a clearnet name. The clearnet
half is the one that matters: it proves the bridge forwards rather than
swallows, and its absence is what "you broke my DNS" means.

Rolls back on any failure — including a failed apply, not only a failed
verification. Half-written routing is the exact state this exists to prevent.

Starts the bridge before pointing routing at it. The old order left a
window where catch-all sent every lookup to a port with nothing behind it.

disable

Replays a restore point written before enable moved anything: every
drop-in that steers a query, whole content, including which files did not exist
so they are removed rather than left empty. It lives in /var/lib/moshcode/,
outside resolved.conf.d/ — a restore point stored in the one directory other
tooling templates and wipes wholesale is not a restore point.

Detects when there is no manifest. It used to remove one hardcoded filename
and report success. On a machine where something else wrote the drop-in — a
different installer, a different name — routing stayed fully in place and the
command said it was gone. Foreign files are now named, attributed where the
writer is known, and need --remove-foreign; without it the command exits
non-zero, because routing is still in place.

Nothing routed and no manifest is a clean no-op. Restarting
systemd-resolved is a real outage, however brief, and paying it to undo
something never done buys nothing.

Also

--help now renders from the command schema rather than an 87-line literal
that had already drifted, in the CLI and in the pit's /help.

v0.17.2 — a key per name

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 10:44
94c4b1b

A security fix, and it is the reason to take this release.

One TLS key per name, not one per ending (#253)

keyPaths keyed certificates off the ending, justified in its own comment as "that is the granularity the registry stores". That is the opposite of what migration 009 stores, and of why it stores it:

Per name rather than per TLD, and that is forced by 008: names under a TLD are sold, so blue.eggs can belong to someone who does not own .eggs. Hanging keys off the TLD would let its operator publish a key for a name they already sold — impersonating a buyer inside the namespace they bought into.

A shared per-ending key is that hole in private-key form:

  • the ending's operator holds the private key for every name they have sold, and
  • every buyer holds a key that signs for every other buyer.

009 closed this at the pin layer. Keying certificates off the TLD reintroduced it one layer down, where it is worse — a pin can be withdrawn, a distributed private key cannot. The certificate's DNS:*.<tld> SAN, which had each buyer's certificate asserting authority over its neighbours, went with it.

It also unbreaks nginx -t on any box that ran the old moshcode site: those wrote conf.d blocks pointing at /etc/ssl/moshpit/<tld>.crt while the certificates on disk are per-name, so nginx could not reload. Nothing goes down when that happens — nginx keeps its last good config — which is precisely why it can sit unnoticed until a reboot takes every site on the box with it.

Also in this release

  • #254fix(prd): the index link keeps working for a PRD file name that was not slugified.
  • #252ci(vu1nz): scan workflow re-rendered at vu1nz-scan@1.0.2.

Upgrading

Boxes that already ran the old moshcode site are not cleaned up by installing this. They still have conf.d blocks referencing per-ending certificate paths, and will fail nginx -t until those are removed or regenerated. On dev.profullstack.com they were moved to /root/moshpit-confd-backup/; sites-enabled already served every name correctly.

Names already serving with per-name certificates — which is what setup-origin.sh has written all along — need nothing.

838 tests pass.

🤖 Generated with Claude Code

v0.17.1 — the plan actually runs

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 09:27
11e8586

v0.17.0 shipped moshcode site --tls on top of a bug older than it: the CLI passed no command runner, and the execution loop skipped every run step in silence, then printed "is live on this machine".

So nginx -t never validated, systemctl reload never reloaded — and with --tls, openssl never created the key. The config was written referencing a certificate that did not exist, and the pin could not be published because there was no certificate to read it from.

Now the plan runs. A missing runner is a failure rather than a skip, and a failed command prints the first line of its stderr instead of just its name.

v0.17.0 — a pointed name serves itself

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 08:45
d49f411

A pointed Moshpit name now actually serves — over TLS, authenticated by its own published key, with nobody editing a config file.

Serving a name

#243 — the gateway follows an origin's HTTPS upgrade, authenticated by its pin. This is the one that closed the gap. An origin that redirects :80 to HTTPS is the ordinary shape for a Moshpit name, and forwarding that redirect to a browser is useless — the browser can neither resolve the ending nor validate a certificate for it. pit.moshcode.sh/n/<name> returned a 301 to nowhere while the site sat there working. The gateway now follows the upgrade itself and checks the origin against the pin its owner published. No CA will issue for an ending outside the DNS root, so the pin does the entire job a chain normally does.

Only to the same name, and only to HTTPS: the Location is written by whoever claimed the name, so following it anywhere else would re-open the SSRF hole checkTarget() exists to close, after the check has already passed.

#247moshcode site serves TLS and publishes the pin. The certificate and the pin are part of the command people already run, rather than a second thing to remember. Forgetting the pin left a name refused by every client while the box looked healthy.

#244 — a name's pin is computed and published with no human in the loop.

DNS

#242 — CNAME, MX and TXT answered from the record set, not just addresses. #241 adds a DNS Records tab for the names you hold. #245 makes the resolver tests compare every question type rather than only A, which is how a whole class of these could regress unnoticed.

Accounts

#240 — a new API key is hidden behind a Reveal toggle. Only a hash of a key is stored, so the one moment it is visible is the feature; it now survives a reload and is not carried in a URL.

Verified against the live deployment

chovy.hacker, seo.rank and alt.2600 resolve and serve, each presenting a key that matches its published pin. A name claimed and pointed with nothing done on the origin box was serving a minute later.

Known issue

moshpit-name-drift.test.mjs fails (3 tests) — the vendored namespace rules accept hyphenated TLDs and the published @moshcoder/moshpit-name@0.2.0 does not. It predates every commit in this release and is not caused by any of them, but it is real drift and wants fixing before the next one.

Also landed after this tag and therefore not in v0.17.0: #248, which reads a numeric ending as a hostname rather than as IPv4.

🤖 Generated with Claude Code

v0.16.8 — an nginx block nginx will load

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 06:31
152e8e0

moshcode doh --nginx <name> emitted listen 443 ssl with no ssl_certificate. nginx refuses to load that outright — nginx -t fails and a reload takes every other site on the box down. certbot could not rescue it either: certbot --nginx needs a loadable vhost before it can issue the certificate that would make it loadable. A cycle with no entry point, on exactly the path the emitted comment recommended.

Now emits the port-80 half, which stands on its own; certbot rewrites it in place and owns renewal afterwards. --tls emits the certified form for a host provisioned some other way, with the listener and the certificate emitted together.

v0.16.7 — bad input stops here

Choose a tag to compare

@ralyodio ralyodio released this 03 Aug 06:23
9eeec66

Eleven changes, and all but two are the same shape: a command that took a bad
input and did something anyway. That is the theme of this release.

Wrong input now stops instead of proceeding

Each of these previously accepted something invalid and failed later, somewhere
else, in a way that did not name the cause:

  • moshcode doh --port and moshcode dns — a port that cannot be bound is
    explained, not crashed on
  • moshcode update --interval — an interval systemd would reject is caught here
    rather than by a unit that silently never fires
  • moshcode skills — an unknown flag was being installed as the skill source
  • moshcode site — a starter that does not exist is refused, instead of serving
    an empty document root
  • moshcode template install — a nested template.json was dropped on the floor
  • mosh() in moshscript — no longer claims it launched a browser it never opened

Redirection is safe now

moshcode <verb> > file on a build without that verb wrote the help banner into
the file. Point it at an nginx config and nginx refuses to start, with an error
four layers away from the typo that caused it.

Help you asked for still goes to stdout. Help you got because a verb was wrong
goes to stderr, behind a one-line reason, and exits non-zero — so the redirect
leaves an empty file and an && chain stops.

Smaller

  • moshcode template install --dry-run — see the plan before it writes
  • moshcode pwd --json and moshcode prd --json — machine-readable output

v0.16.6 — DNS over HTTPS

Choose a tag to compare

@ralyodio ralyodio released this 01 Aug 20:03
053ec57

DNS over HTTPS for Moshpit names, in both halves.

moshcode doh [--port N]       run the resolver on loopback
moshcode doh --nginx <name>   print the reverse-proxy block for it

Why it exists

A browser with Secure DNS enabled never asks the system resolver. So a machine running the bridge perfectly still cannot open a Moshpit name — curl resolves it and the browser does not, on the same box, in the same second.

The browser extension had been masking that by redirecting to /n/<name> before any lookup happened. An incognito window, where extensions are disabled, removed the mask and showed the browser had never been resolving Moshpit names at all.

Turning Secure DNS off fixes it and trades away encryption for every other lookup. A DoH endpoint that knows both namespaces is the only arrangement where encryption stays on and Moshpit names still work.

This is not specific to one browser: Chrome, Edge, Brave and Firefox all ship DoH on or prompted, so "curl works, the browser does not" is the shape this failure takes on a correctly configured machine.

How it is built

On the existing resolution path rather than beside it, so the claimed-ending gate, the /64 client grouping, the token buckets and the backoff bans are the same code as the UDP side and cannot drift from it. Both transports RFC 8484 requires are accepted, because browsers mostly POST and resolvers mostly GET.

Refusals are a DNS REFUSED rather than an HTTP error: a client that asked for a DNS message and receives a 429 mostly throws, where REFUSED is something every resolver already knows how to retry past. Deliberately not NXDOMAIN, which claims the name does not exist and may be cached against every other record type.

TLS is not this process's job

A resolver that manages its own certificate goes down when that certificate expires, and every machine pointed at it loses all DNS — not a Moshpit name, all of it. So this binds loopback and something that already holds 443 terminates TLS in front of it.

That also means it must not be reachable directly: a DoH endpoint exposed without its proxy is an open resolver without the rate limits the proxy was going to apply.

Guards default on

Unlike the bridge, which listens on loopback and has one client. This is meant to be reachable, and scanners find an unprotected open resolver within hours of it being published:

  • 20 queries a second per client, burst 40
  • bans doubling from a minute, capped at a day
  • answers capped at 1232 bytes

Generous for a person and tight for a script. Tunable with --rate, --burst, --ban-seconds and --max-response; --no-guards turns them off and says so loudly.

The trade, unchanged

DoH hides lookups from the network and hands them, in full, to whoever runs the endpoint. That is a move of trust, not a removal of it — which is why this keeps no per-query record of who asked what.

v0.16.5 — check before installing

Choose a tag to compare

@ralyodio ralyodio released this 01 Aug 18:43
afb9b7f

moshcode update now checks before it installs.

moshcode update --check              is there a newer release?
moshcode update --if-newer           install only if there is
moshcode update --timer [--install]  check on a schedule, 15min default
moshcode update --timer --interval 1h

Until now every run re-fetched Node, bun and the release tarball whether or not anything had changed. That is fine as a thing you type and wrong as a thing a timer runs every fifteen minutes: minutes of network and disk to discover that nothing changed.

The parts that decide whether it is safe to automate

Versions compare in order, not by string inequality. A rolled-back release must not read as an upgrade: a published 0.16.3 against an installed 0.16.4 means the machine is ahead, and reinstalling would be a downgrade nobody asked for. 0.9.0 against 0.10.0 is the same trap in the other direction.

An unreachable feed means do nothing. Offline, rate-limited and broken all give the same answer, because a timer that reinstalls on every failed check hammers a machine that is merely offline.

Persistent=true, so a laptop asleep at the scheduled moment checks once on waking rather than skipping until the next interval — which on a laptop can be days.

The unit runs update --if-newer, not bare update. Pointing it at the latter is exactly how a fifteen-minute timer becomes a fifteen-minute reinstall.

Before enabling the installing form

Automatic updates hand a bad release to every machine on the timer within the interval, with nobody in the loop. A release that breaks DNS arrives unattended on every box that is watching.

Running --check on the timer and --if-newer by hand is the conservative version, and worth a few releases before switching.

v0.16.4 — never route everything at a bridge that cannot forward

Choose a tag to compare

@ralyodio ralyodio released this 01 Aug 18:38
d055075

v0.16.3 could take a machine off the internet. This release stops it. If you are on v0.16.3, take this before running dns enable again.

What v0.16.3 did

The check that decided whether to write catch-all routing asked whether upstreams were discoverable — a fact about the machine — and inferred from that the bridge would forward. Those are different questions, and where they diverge the result is total:

Global: ~.                     every lookup goes to the bridge
DNS Servers: 127.0.0.1:5354    and the bridge is the only global server

Nothing to fall through to. Every name on the machine went to a bridge that could not forward, and nothing came back — ssh, curl, everything.

A server whose provider drop-in happens to put real upstreams in the same scope survives the identical config. That is why this was not caught before it shipped: the machine available to test on was the one shape where the bug is invisible.

The fix

The bridge is asked directly, before anything is written. Catch-all now requires either a bridge that demonstrably forwards, or no bridge at all — in which case dns enable starts ours, which does.

The probe uses a three-label name on purpose. A two-label name is a Moshpit name to any build: an older bridge answers it with the parking address, and that answer would read as working forwarding. A two-label probe would have passed on the exact machine that broke.

Falling back to per-ending routing is worse — it truncates at the resolver's cap, silently — but its worst case is Moshpit names not resolving, against a machine that cannot reach anything at all. The output now says which was chosen and why:

routing each ending by name — a bridge is already running on this port
and does not forward — stop it first, then re-run

Also in this release

mcp and skills exit non-zero when an engine fails to install.

If v0.16.3 already broke a machine

Two commands, needing neither DNS nor moshcode:

rm /etc/systemd/resolved.conf.d/moshpit.conf      # as root
systemctl restart systemd-resolved                # as root

Upgrading

moshcode update          # as root
moshcode dns enable      # as root

If a bridge from an older build holds port 5354, dns enable will now say so and decline catch-all rather than routing everything at it. Stop that bridge and re-run to get catch-all routing.

v0.16.3 — catch-all routing, and a command to serve a name

Choose a tag to compare

@ralyodio ralyodio released this 01 Aug 18:00
3ebadd8

The release where catch-all DNS routing actually engages, plus a command that installs the web-server config for a name.

Catch-all routing works now

It has never engaged on a systemd-resolved machine, which is the platform it was built for.

discoverUpstreams read /etc/resolv.conf. On systemd-resolved that file is a stub:

/etc/resolv.conf                    nameserver 127.0.0.53      ← the stub
/run/systemd/resolve/resolv.conf    nameserver 67.207.67.3     ← the real uplinks
                                    nameserver 67.207.67.2

The loopback filter dropped 127.0.0.53 — correctly, since forwarding to the thing that is asking us is a loop. So discovery came back empty and the conditional did exactly what it was designed to do: fall back to routing every claimed ending by name, which systemd-resolved then truncates at around 1,090 entries without an error a caller can see.

The safety property worked. The feature was unreachable.

systemd's own uplink file is now consulted first, so dns enable writes Domains=~. — one line that never grows — and the bridge forwards everything that is not a claimed ending upstream.

Unchanged: loopback everywhere still means no upstreams, which still keeps per-ending routing rather than pointing every lookup at a bridge with nowhere to forward.

moshcode site <name>

Serving a name is four lines of web-server config, and every one of them is a line people get wrong the same way. This writes them.

moshcode site blue.eggs                     # show what would be installed
moshcode site blue.eggs --install           # write the config
moshcode site blue.eggs --install --reload  # and make it live
moshcode site blue.eggs --proxy 3000        # reverse-proxy a local port

What it gets right, in each case because getting it wrong is silent:

  • No redirect to HTTPS. A box's default vhost usually sends everything there, and for an ending outside the DNS root that is a redirect to a page no certificate can exist for. Through the gateway it is worse, because the status is forwarded without the Location header — a 301 pointing nowhere at all.
  • An exact server_name, which is what beats the default vhost.
  • Both address families. Resolver users arrive over IPv6, because that is what the name points at; everyone else arrives via pit.moshcode.sh, which fetches server-side.
  • Port 80 only, because a DNS record has nowhere to put a port.

The server is detected by asking who holds port 80, not by looking for a config directory. A box can carry /etc/nginx from a package installed years ago while Caddy is the thing answering, and writing nginx config there succeeds at every step and serves nothing — the worst outcome available, since everything reports success.

A new site is seeded with a starter page rather than left empty, because an empty root serves 404 and a 404 is indistinguishable from a broken install at exactly the moment someone is trying to tell those apart. Never over an existing site.

moshcode serves nothing itself. This writes a config file; nginx or Caddy does the serving.

Upgrading

moshcode update                                   # as root
moshcode dns enable                               # as root
moshcode site <name> --install --reload           # as root

If a bridge from an older build is already listening on port 5354, dns enable will report "bridge already running" and leave it there. An older bridge does not forward, so stop it first.