Releases: moshcoder/moshcode
Release list
v0.16.8 — an nginx block nginx will load
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
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 --portandmoshcode dns— a port that cannot be bound is
explained, not crashed onmoshcode update --interval— an interval systemd would reject is caught here
rather than by a unit that silently never firesmoshcode skills— an unknown flag was being installed as the skill sourcemoshcode site— a starter that does not exist is refused, instead of serving
an empty document rootmoshcode template install— a nestedtemplate.jsonwas dropped on the floormosh()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 writesmoshcode pwd --jsonandmoshcode prd --json— machine-readable output
v0.16.6 — DNS over HTTPS
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
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
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
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.
v0.16.2 — a resolver that can defend itself
Two changes, both prerequisites for a resolver that strangers can reach.
The bridge listens on IPv6, not only forwards to it
It picked the socket family per upstream when forwarding and always bound udp4 when listening — a strange shape for a namespace whose targets must be IPv6: the resolver for a v6-only network could not be reached over v6.
The family now follows the address it is asked to bind, so :: serves both from one socket. The default stays loopback IPv4, so upgrading does not widen what a machine answers on — serving anything else is a deliberate act.
The resolver can defend itself
An open forwarding resolver is a DDoS amplifier before it is anything else, and the attack needs no botnet: one host spoofs a victim's source address, sends a small query, and the resolver mails the large answer to the victim.
That shape rules out most defences. The source address is a lie, so blocking "the client" punishes the victim; there is no session to fingerprint and no user agent to read. What is left is bounding the amplification one query can buy, and what one source can extract before we stop answering it:
- ANY is refused (RFC 8482) — a 30-byte question for every record a name holds, which is the lever the attack pulls
- A token bucket per source decides what counts as an offence
- Offences earn a ban that doubles each time, capped, and decaying after a clean spell. A flat limit is a toll an attacker pays and returns from; backoff makes persistence expensive while a client that misbehaves once loses a minute
- Oversized forwarded answers are truncated with TC, sending a real client to TCP — where the handshake makes a spoofed source useless. Legitimate case retries, abusive case dead-ends
Refusals are REFUSED rather than silence: a dropped packet costs a real client a full resolver timeout before it tries elsewhere, and costs an attacker nothing.
IPv6 clients are grouped by /64, and this is the part that decides whether any of it works. A single v6 address is free to change: any host worth banning holds a /64 at minimum, so a per-address ban is defeated by incrementing — silently, because the bans look like they are being applied. IPv4 stays per-address, because a /24 routinely spans unrelated customers behind carrier NAT.
Both tables are bounded and evict least-recently-seen. The key space is attacker-controlled, so an unbounded map is the vulnerability rather than the mitigation.
Nothing changes for an existing install
The listener default is unchanged and the guards require explicit configuration. This releases the capability for a deployment to use; it is not a behaviour change you would feel.
If Moshpit names still fail to resolve on your machine, v0.16.1 is the release that fixes that.
v0.16.1 — routing stopped being a list, for real this time
v0.16.0 shipped the catch-all routing machinery and wired none of it in, so upgrading to it changed nothing. This is the version that actually changes routing.
What v0.16.0 got wrong
dns enable still called resolvedConf(tlds) — every claimed ending on one Domains= line. systemd-resolved accepted about 1,090 of them and dropped the rest with no error a caller could see, so moshcode dns resolve <name> answered while curl <name> could not resolve. The v0.16.0 notes said routing had stopped being a list. It had not.
What changes now
Routing is written conditionally, never assumed:
| upstreams found | routing written | worst case |
|---|---|---|
| yes | Domains=~. — one line, nothing to truncate |
Moshpit names fail |
| no | the per-ending list, exactly as before | Moshpit names fail |
Upstreams are read from /etc/resolv.conf before routing is switched, because afterwards it may point at the bridge and the real servers are no longer discoverable from it. Loopback entries are dropped so the bridge cannot forward to itself. Neither branch can take a machine's DNS with it — pointing every lookup at a bridge that cannot forward is a far worse failure than a Moshpit name that does not resolve.
With upstreams configured the bridge answers claimed endings and relays everything else byte for byte, failing open in every direction: an unknown ending set means "not ours", and silent upstreams are SERVFAIL rather than NXDOMAIN, so a lookup is retried elsewhere instead of cached as nonexistent.
Also: the resolver stays up when its socket errors after bind.
Why the list had to go
4,593 endings in the morning, 4,882 by the afternoon, against a resolver cap near 1,090. Every claim made it worse, which is why the fix is not "raise the limit".
Upgrading
moshcode update # as root
moshcode dns enable # 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 in place — an older bridge does not forward, so stop it first.
v0.16.0 — routing stopped being a list
install.sh resolves releases/latest, so the twelve commits since v0.15.0 have been unreachable to moshcode update — including the reason curl <name> still failed after enabling DNS.
Routing stopped being a list
dns install wrote every claimed ending onto one Domains= line. systemd-resolved caps how many search domains it accepts:
| written | 4,586 |
| accepted | 1,090 |
| rejected | 3,496, one journal line each |
systemd-resolved: Failed to add search domain '~zombies', ignoring: Argument list too long
Taken alphabetically, so endings past the cut were configured on disk and absent from the resolver — moshcode dns resolve answered while curl could not, with nothing in between to say why. Every new ending made the cut deeper.
Domains=~. is one entry that never grows. The bridge now forwards anything that isn't a claimed ending upstream, byte for byte, and fails open in every direction: an unknown ending set means "not ours", silent upstreams are SERVFAIL rather than NXDOMAIN, and loopback nameservers are dropped so it can't forward to itself.
moshcode dns status now reports what the resolver accepted, not what was written — the check that had been comparing two numbers we control and agreeing with itself.
Also in this release
- IPv6 end to end — the bridge answers
AAAA, and anAquery for a v6-only name is NODATA rather than NXDOMAIN, which had been denying the name for the whole page load. The/n/gateway brackets v6 hosts, which it previously built as invalid URLs. moshcode template— scaffolds a Moshpit-hosted service;bun-caddy-sqliteandcaddy-staticbundled. Nothing in a template executes on install.- Letters and digits only — a dash was the cheapest way to mint a look-alike of an ending someone else holds, in a namespace one level deep with nothing to appeal into.
- An ending has its own page at
/n/<ending>, showing its pointers and relatives instead of a dead end.
Upgrade
moshcode update # as root
moshcode dns enable # as root
v0.15.0 — curl the name
install.sh resolves releases/latest, so the ten commits merged since v0.14.0 were unreachable to anyone installing — including the reason curl <name> didn't work.
The headline is parking
A parked name always resolved somewhere, but the address it pointed at was a host that routes by Host header and answers "Application not found" for a name it has never heard of. curl scrambled.eggs resolved and then died one layer up.
It couldn't be fixed there — the platform rejects a Moshpit ending as a custom domain, and no public CA will certify a TLD outside the DNS root. The bridge is already running locally for the name to resolve at all, so it now serves the answer too:
$ moshcode dns start
parked names → http://127.0.0.1:80 → https://pit.moshcode.sh/n/<name>
$ curl -i scrambled.eggs
HTTP/1.1 302 Found
location: https://pit.moshcode.sh/n/scrambled.eggs
Port 80 needs privileges; a failed bind falls back to the old behaviour with a clear message rather than refusing to start. --parking-port N moves it, --no-parking-http turns it off.
Underneath it, a quieter one
fetchTlds read the first page of the ending list and stopped. The registry answers 200 rows and reports the real total, but 200 rows look exactly like a complete list of 200. .eggs sat past that line, so dns install wrote a config that didn't route it and the name failed to resolve — a DNS-shaped symptom three layers from the cause.
It was hiding 94% of the namespace: 3707 endings, 200 visible.
Also in this release
dns resolvereports a parked name's page in the Pit instead of an IP that answers for nobody, with--opento go there- the pit's
/n/pages are crawlable — robots.txt, a generated sitemap, and canonical tags naming the pit host rather than the app host it shares a service with - the claim box takes a whole name, claiming the ending first when it's free and minting the name under it
- the endings list pages instead of stopping at 200, the paste field reads names as well as endings, related endings keep the name you're reading, and integrations ship JSON support matrices
Upgrading
curl -fsSL https://moshcode.sh/install.sh | sh
Then, as root (the resolver edits system DNS, and port 80 is privileged):
moshcode dns install --write && systemctl restart systemd-resolved
moshcode dns start