Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions examples/templates/caddy-proxy/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Caddy in front of a service already running on this box, published at a
# Moshpit name.
#
# The `http://` is required and is not a style choice. A Moshpit ending is not
# in the public DNS root, so no certificate authority will issue for it — leave
# the scheme off and Caddy will try to provision a certificate, fail, and never
# bring the site up. Everything served at a Moshpit name is plain HTTP.
#
# Nothing here resolves the name. The visitor's resolver did that; by the time a
# request arrives Caddy has only a Host header to match on, which is why the
# site address must be the name exactly as it is registered.

http://{$MOSHPIT_NAME:foo.whatever} {
reverse_proxy {$APP_ADDR:127.0.0.1:8080}

log {
output file /var/log/caddy/moshpit-service.log
}
}

# Every subdomain too. This block answers only once the name publishes a
# wildcard record — DNS Records tab in the Pit, the `*.` option with an AAAA
# at this box — because until then `anything.foo.whatever` resolves to nothing
# and no request reaches Caddy to match. Uncomment both together.
#
# The app sees which subdomain was asked for in the Host header, and Caddy
# matches exactly one label deep: `api.foo.whatever` answers,
# `a.b.foo.whatever` does not.
#
# http://*.{$MOSHPIT_NAME:foo.whatever} {
# reverse_proxy {$APP_ADDR:127.0.0.1:8080}
#
# log {
# output file /var/log/caddy/moshpit-service.log
# }
# }
104 changes: 104 additions & 0 deletions examples/templates/caddy-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# caddy-proxy

A name in front of something that is already running. No app, no database, no
runtime to keep alive — Caddy answers the Moshpit name and hands every request
to a local service on `127.0.0.1:8080` (or wherever `APP_ADDR` says).

This is the template for "I have a thing on this box, put a name on it": a
dev server, a dashboard, grafana, a game panel, anything that already listens
on loopback.

## The part that surprises people

Three machines' worth of concerns, and they fail independently:

| | needs the resolver? | what it does |
|---|---|---|
| the box serving the name | **no** | Caddy matches a `Host` header, nothing more |
| the registry | — | holds the address the name points at |
| every visitor | **yes** | `sudo moshcode dns enable`, or the name resolves to nothing |

Nothing on the server ever resolves its own name. That is why there is no DNS
software in this template.

## Deploying

1. **Point the name at the box.** In the Pit, set `points at` to its public
IPv6 address — bare, no scheme, no brackets, no port:

```sh
ip -6 addr show scope global | grep inet6
```

Pick the globally routable one. An `fd..`/`fc..` address is unique-local
(Tailscale and friends live there) and the registry refuses it, because a
name pointed at one resolves somewhere only you can reach.

2. **Serve it.** The service stays bound to loopback — Caddy is its only
client, and binding it publicly publishes it on a port nothing
virtual-hosts.

```sh
export MOSHPIT_NAME=foo.whatever
export APP_ADDR=127.0.0.1:8080 # the default; change only if the service differs
sudo cp Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo ufw allow 80/tcp
```

3. **Reach it,** on any machine that should see the name:

```sh
sudo moshcode dns enable
sudo cp deploy/moshcode-dns.service /etc/systemd/system/ # survives reboot
sudo systemctl enable --now moshcode-dns
```

## Every subdomain at once

One name covers one hostname. To answer `anything.foo.whatever` too — one
service per subdomain, or a wildcard tenant app — do both halves, in either
order, because neither works without the other:

1. In the Pit's **DNS Records** tab, publish an **AAAA** record on the
`*.foo.whatever` option pointing at the same box. Until that exists the
subdomains resolve to nothing and no request ever reaches Caddy.
2. Uncomment the wildcard block at the bottom of the Caddyfile and reload.
Caddy matches exactly one label deep, and the app reads which subdomain was
asked for from the `Host` header.

`foo.whatever` itself is not covered by a wildcard — keep the apex block (and
its own AAAA or `points at`) for that. This is how DNS wildcards work, not a
choice Caddy made.

## Verifying, one layer at a time

A failure at any layer looks identical in a browser, so do not start there.

```sh
# Server only — no DNS involved. Proves Caddy, the firewall, and the service.
curl -6 -H "Host: foo.whatever" http://[YOUR:V6:ADDR]/

# Resolver only. Proves the registry and the bridge.
moshcode dns resolve foo.whatever

# Both.
curl -6 http://foo.whatever/
```

If the first works and the last does not, it is DNS. If the first fails, stop
looking at DNS.

## Known limits

- **No HTTPS, ever.** No CA will issue for an ending outside the DNS root. That
rules out secure cookies, service workers, and WebCrypto in the browser. The
`http://` in the Caddyfile is what stops Caddy trying and failing.
- **Only machines running the resolver can reach the name.** Not phones, not a
colleague who has not installed it, not webhooks.
`pit.moshcode.sh/n/foo.whatever` is the URL for people who installed nothing.
- **Subdomains are opt-in.** `foo.whatever` works out of the box;
`www.foo.whatever` works only with the wildcard record described above.
- **Port 80 only** on the resolver path. A DNS record carries an address and
has nowhere to put a port, which is why Caddy listens on 80 and the
`host:port` part lives here, not in the registry.
39 changes: 39 additions & 0 deletions examples/templates/caddy-proxy/deploy/moshcode-dns.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# The Moshpit resolver, kept running across reboots.
#
# `moshcode dns enable` sets up two halves: a systemd-resolved drop-in that
# routes Moshpit endings at the bridge, and the bridge process itself. The
# drop-in is a file and survives a reboot on its own. The process does not —
# so after a restart the routing still points at a port with nothing behind it,
# and every Moshpit name stops resolving with no obvious cause. This unit is
# the missing half.
#
# sudo cp deploy/moshcode-dns.service /etc/systemd/system/
# sudo systemctl enable --now moshcode-dns
#
# Install this on machines that need to REACH Moshpit names. A box that only
# serves one does not need it — Caddy answers whatever Host header arrives and
# never resolves its own name.

[Unit]
Description=Moshpit DNS bridge
After=network-online.target
Wants=network-online.target
Before=systemd-resolved.service

[Service]
Type=simple
# Port 5354 is unprivileged, so this does not need root. The trade-off is that
# the parking responder cannot take port 80 and falls back to the public
# parking address — which only affects names that point nowhere yet.
ExecStart=/usr/bin/env moshcode dns start --port 5354
Restart=always
RestartSec=2

DynamicUser=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes

[Install]
WantedBy=multi-user.target
8 changes: 8 additions & 0 deletions examples/templates/caddy-proxy/template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "caddy-proxy",
"description": "Caddy proxying a Moshpit name to a service already running on the box — no app, no database, just the name in front",
"vars": {
"MOSHPIT_NAME": "the registered name to serve, e.g. foo.whatever",
"APP_ADDR": "where the local service listens, loopback only (default 127.0.0.1:8080)"
}
}
101 changes: 98 additions & 3 deletions src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
// testable without binding a port.

import dgram from "node:dgram";
import { isIP } from "node:net";
import { isIP, connect as netConnect } from "node:net";
import { Resolver } from "node:dns/promises";

export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
export const DEFAULT_PARKING_HOST = "moshcoding.com";
export const DEFAULT_PORT = 5354;
export const DEFAULT_HOST = "127.0.0.1";
// Where the pinned-TLS proxy listens. Not configurable from DNS: an A record
// cannot carry a port, so the proxy has to be on 443 for a browser to reach it
// at all — its installer moves it there for exactly this reason.
export const PROXY_PORT = 443;

export function parseDnsPort(input) {
const raw = String(input ?? "").trim();
Expand Down Expand Up @@ -610,8 +614,46 @@ export function mayHaveCname({ exists, address }) {
* round trip — which is the same bargain the old path struck for CNAMEs, held
* to here so the common case did not get slower in exchange for being right.
*/
/**
* Is something actually listening where we are about to send every name?
*
* The guard that makes proxy mode safe to offer at all. Pointing every live
* Moshpit name at a loopback address is exactly as good as the thing behind it:
* with a proxy there, all of them work in a stock client; with nothing there,
* all of them break at once, and the resolver looks healthy while doing it —
* `dig` answers 127.0.0.1 and every connection is refused.
*
* So this is checked before the mode is allowed on, and rechecked rather than
* remembered: a proxy that dies after the resolver started is the same outage
* as one that was never running.
*/
export function proxyReachable(address, port = 443, { connect = null, timeoutMs = 1500 } = {}) {
return new Promise((resolve) => {
let socket;
const done = (ok) => {
try { socket?.destroy(); } catch { /* already gone */ }
resolve(ok);
};
try {
const net = connect || netConnect;
socket = net({ host: address, port });
// Deliberately not unref'd. This timer is the only thing that guarantees
// the promise settles at all, and an unref'd one does not hold the loop
// open — so a connect that stalls without keeping a handle alive let the
// process reach an idle event loop with this still pending, which node
// reports as a cancelled await rather than the `false` the caller needs.
// It cannot outlive the probe: both settle paths clear it.
const timer = setTimeout(() => done(false), timeoutMs);
socket.once("connect", () => { clearTimeout(timer); done(true); });
socket.once("error", () => { clearTimeout(timer); done(false); });
} catch {
resolve(false);
}
});
}

export async function addressAnswer(name, options = {}) {
const { parkingAddress, wantsV6 = false } = options;
const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });

const result = await resolveName(name, options);
Expand All @@ -620,10 +662,31 @@ export async function addressAnswer(name, options = {}) {

// Parking is checked before anything the registry published: a parked name's
// whole job is to reach the page explaining that it is for sale.
//
// It is also checked before the proxy, deliberately. A parked name has no
// origin and no published pin, so handing it to a proxy whose entire job is
// to verify one would turn "this name is for sale" into a TLS error.
if (result.status === "parked") {
return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata");
}

// Every live name answers the local proxy, whatever the registry says its
// target is — that is the point. The proxy reads the SNI, checks the origin's
// key against the registry pin, and re-signs with a root this machine
// generated, which is the only way a stock client can be told the result: no
// CA will ever sign for a Moshpit name.
//
// Answering the origin instead is what left the proxy running on loopback
// with nothing ever routed to it, so every name arrived at a stock client as
// a self-signed certificate no matter what was installed.
if (proxyAddress) {
const forFamily = wantsV6 ? proxyAddress.v6 : proxyAddress.v4;
// A proxy that only speaks one family is NODATA for the other, not a
// fabricated address: answering ::1 for a v4-only listener is a connection
// refused that looks like the site is down.
return forFamily ? plan("address", { address: forFamily, proxied: true }) : plan("nodata");
}

const address = targetAddress(result.target);
if (address) return plan("address", { address });

Expand Down Expand Up @@ -940,6 +1003,7 @@ export function createServer(options = {}) {
// names it is authoritative for.
upstreams = [],
tldSet = null,
proxyAddress = null,
forwardTimeoutMs = 3000,
// Off by default: a loopback bridge has one client and rate limiting it is
// pure cost. These matter when the socket is reachable by strangers, which
Expand Down Expand Up @@ -1065,7 +1129,7 @@ export function createServer(options = {}) {
if (policy) ({ exists } = policy);
} else {
const plan = await addressAnswer(query.name, {
...options, wantsV6: query.type === TYPE_AAAA,
...options, wantsV6: query.type === TYPE_AAAA, proxyAddress,
}).catch(() => null);
exists = Boolean(plan?.exists);
if (plan?.kind === "records") {
Expand Down Expand Up @@ -2012,6 +2076,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
verify = verifyResolution,
bridgeStatus = daemonStatus,
startBridge = startDaemon,
proxyReachableImpl = proxyReachable,
autoTrustImpl = createAutoTrust,
stopBridge = stopDaemon,
dropins = readDropins,
Expand Down Expand Up @@ -2159,6 +2224,35 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
: " this bridge has nothing to answer for and nothing to forward to");
}

// Proxy mode: answer every live name with the local pinned-TLS proxy rather
// than its origin, so a stock client gets a certificate it can verify.
const proxyIndex = rest.indexOf("--proxy");
let proxyAddress = null;
if (proxyIndex >= 0) {
const given = rest[proxyIndex + 1];
const host = given && !given.startsWith("-") ? given : null;
const candidates = host ? [host] : ["127.0.0.1", "::1"];
const reachable = [];
for (const candidate of candidates) {
if (await proxyReachableImpl(candidate, PROXY_PORT)) reachable.push(candidate);
}
if (!reachable.length) {
// Refused rather than warned. With the mode on and nothing behind it,
// every Moshpit name on the machine resolves and then refuses the
// connection — a total outage that reads as "the sites are down".
out(`! nothing is listening on ${candidates.map((c) => `${c}:${PROXY_PORT}`).join(" or ")}`);
out(" --proxy points every live Moshpit name there, so turning it on now would");
out(" break all of them at once rather than fix their certificates.");
out(" start moshpit-proxy first: https://github.com/profullstack/moshpit-proxy");
return 1;
}
proxyAddress = {
v4: reachable.find((a) => isIP(a) === 4) || null,
v6: reachable.find((a) => isIP(a) === 6) || null,
};
out(`proxying every live name to ${reachable.join(", ")}:${PROXY_PORT} — certificates are verified there`);
}

// The same two error codes the parking server above already explains, on
// the port this command exists to bind. Without this they arrived as an
// unhandled rejection — bin/moshcode calls main() with no top-level catch —
Expand All @@ -2185,6 +2279,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
parkingAddress: park,
upstreams,
tldSet,
proxyAddress,
onQuery: ({ name, address, forwarded }) => {
out(` ${name} → ${address || "NXDOMAIN"}`);
// Only a name that actually resolved to something of ours. A forwarded
Expand Down
Loading
Loading