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
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,38 @@ loopback.
| `MOSHPIT_PROXY_TOFU` | off | see below |
| `MOSHPIT_PROXY_DIR` | `~/.moshpit` | |

## Install

```sh
curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh
```

Installs under `~/.local`, needs no root for the code, and finishes by setting up
your browsers — asking once, in plain language, before it changes anything:

```
Moshpit needs to add a security key to this
computer so your browser trusts .moshpit sites.
It only works for .moshpit and cannot affect any
other website.

Continue? [Y/n]
```

`--no-trust` installs the code only. `--uninstall` removes both.

Already have the code? `moshpit-trust` does the browser setup on its own, and
`moshpit-trust --status` says what is set up without changing anything. It is
idempotent — running it twice is a no-op, not a duplicate.

> **Nothing in this section applies to [TronBrowser](https://tronbrowser.dev)**,
> which verifies registry pins natively. No setup, no proxy, no local root.

## Trusting the local root

`moshpit-trust` automates everything below; this section is what it does and why,
for anyone who would rather do it by hand or wants to know what changed.

The proxy generates a root on first run at `~/.moshpit/ca/ca.crt` and prints its
fingerprint. Two things make installing it a much smaller ask than a shared CA:

Expand All @@ -86,15 +116,33 @@ That second property is tested, not asserted — `tests/ca.test.ts` signs a
with `permitted subtree violation`.

```sh
# Linux (NSS: Chrome, Firefox)
# Linux (NSS: Chrome, Chromium, Edge)
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crt
# macOS
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.moshpit/ca/ca.crt
```

`C,,` is server-trust only — not code signing, not mail.

**Firefox is not covered by either command.** It carries its own NSS database per
profile on every platform, including macOS, so the keychain does nothing for it:

```sh
certutil -d sql:~/.mozilla/firefox/<profile> -A -t "C,," -n "Moshpit Local CA" -i ~/.moshpit/ca/ca.crt
```

On current Ubuntu the default Firefox is the snap, whose profiles live under
`~/snap/firefox/common/.mozilla/firefox/` instead. `moshpit-trust` covers the
snap and flatpak paths as well, which is most of why doing this by hand tends to
half-work.

`certutil` itself is not installed by default on Debian or Ubuntu — the command
above fails on a machine that *does* have the store it points at. Install
`libnss3-tools` (Debian/Ubuntu), `nss-tools` (Fedora/RHEL) or `nss` (Arch, brew).

Node, Python and Java keep their own trust stores — `NODE_EXTRA_CA_CERTS`,
`certifi`, `cacerts` respectively. Installing into the OS store does not cover
them.
them, and neither does `moshpit-trust`.

## Publishing a pin (site operators)

Expand Down
158 changes: 158 additions & 0 deletions bin/moshpit-trust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env node
// One command, no vocabulary. The word "certificate" appears nowhere a user reads.
//
// This exists because the honest barrier to Moshpit was never that people
// distrust a locally-generated root — it is that nobody should have to read
// `certutil -d sql:$HOME/.pki/nssdb -A -t "C,,"` to open a web page. The
// mechanism cannot go away for stock browsers; the ceremony can.
//
// There is still exactly one consent moment. Adding a root to someone's trust
// store without telling them is what malware does, and "the user didn't have to
// think about it" is not a reason to skip asking. What gets removed is the
// jargon, not the disclosure.

import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { createLocalCa } from "../lib/ca.ts";
import { loadConfig } from "../lib/config.ts";
import {
defaultEnv, discoverStores, install, probeTooling, status, uninstall,
type Store,
} from "../lib/trust.ts";

const argv = new Set(process.argv.slice(2));
const wantsHelp = argv.has("--help") || argv.has("-h");
const assumeYes = argv.has("--yes") || argv.has("-y");
const wantsRemove = argv.has("--uninstall") || argv.has("--remove");
const wantsStatus = argv.has("--status");

if (wantsHelp) {
console.log(`moshpit-trust — let this computer's browsers open Moshpit sites

moshpit-trust set it up (asks first)
moshpit-trust --yes set it up without asking
moshpit-trust --status show what is set up, change nothing
moshpit-trust --uninstall undo it

Run this once. It only affects ${namespaceLabel()} and cannot affect any other website.`);
process.exit(0);
}

function namespaceLabel(): string {
const tlds = loadConfig().tlds.map((t) => `.${t}`);
return tlds.length === 1 ? tlds[0]! : `${tlds.slice(0, -1).join(", ")} and ${tlds.at(-1)}`;
}

const config = loadConfig();
const env = defaultEnv();
const stores = discoverStores(env);

if (stores.length === 0) {
console.error("No browsers found on this computer that need setting up.");
console.error("If you use Firefox, launch it once first — it creates its storage on first run.");
process.exit(1);
}

// ---------------------------------------------------------------- status

if (wantsStatus) {
const rows = await Promise.all(stores.map((s) => status(s, env)));
for (const row of rows) {
console.log(` ${row.installed ? "✓" : "·"} ${row.store.label} — ${row.detail}`);
}
const ready = rows.filter((r) => r.installed).length;
console.log(
ready === rows.length
? `\nAll set. Try https://scrambled.${config.tlds[0] ?? "moshpit"}`
: `\n${ready} of ${rows.length} set up. Run \`moshpit-trust\` to finish.`,
);
process.exit(0);
}

// ------------------------------------------------------------- uninstall

if (wantsRemove) {
const results = await Promise.all(stores.map((s) => uninstall(s, env)));
for (const r of results) console.log(` ${r.ok ? "✓" : "✗"} ${r.store.label} — ${r.detail}`);
const failed = results.filter((r) => !r.ok);
console.log(failed.length ? "\nSome entries could not be removed." : "\nRemoved. Moshpit sites will stop opening.");
process.exit(failed.length ? 1 : 0);
}

// --------------------------------------------------------------- install

// The tooling check comes before the consent prompt on purpose: asking someone
// to agree to something and *then* failing on a missing package wastes the one
// moment of attention this command gets.
const tooling = await probeTooling(env);
const needsCertutil = stores.some((s) => s.kind === "nss");
if (needsCertutil && !tooling.certutil) {
console.error(`Missing a small system package that browsers need for this.

${tooling.installHint}

Then run \`moshpit-trust\` again.`);
process.exit(1);
}

const needsRoot = stores.some((s) => s.needsRoot);

if (!assumeYes) {
if (!stdin.isTTY) {
console.error("Nothing to read an answer from. Re-run with --yes if you already know what this does.");
process.exit(1);
}
console.log(`
Moshpit needs to add a security key to this
computer so your browser trusts ${namespaceLabel()} sites.
It only works for ${namespaceLabel()} and cannot affect any
other website.
`);
for (const store of stores) console.log(` · ${store.label}`);
if (needsRoot) console.log("\n You will be asked for your password.");

const rl = createInterface({ input: stdin, output: stdout });
const answer = (await rl.question("\n Continue? [Y/n] ")).trim().toLowerCase();
rl.close();
if (answer && !/^y(es)?$/.test(answer)) {
console.log("\n Cancelled. Nothing was changed.");
process.exit(1);
}
}

// The root has to exist before it can be trusted. Creating it here means the
// setup command works on a machine that has never started the proxy.
const ca = createLocalCa({ dir: `${config.dir}/ca`, tlds: config.tlds });
await ca.ensure();

// Serially, not in parallel: these prompt for a password, and two prompts
// racing for the same terminal is how a setup step becomes unusable.
const results: Array<Awaited<ReturnType<typeof install>>> = [];
for (const store of stores) results.push(await install(store, ca.rootCertPath(), env));

console.log("");
for (const r of results) {
const mark = r.ok ? "✓" : "✗";
console.log(` ${mark} ${r.store.label}${r.changed ? "" : ` — ${r.detail}`}`);
}

const failed = results.filter((r) => !r.ok);
if (failed.length === results.length) {
console.error("\n Setup did not work. Nothing on this computer was changed.");
process.exit(1);
}

const sample = `https://scrambled${config.tlds[0] ? `.${config.tlds[0]}` : ""}`;
if (failed.length) {
console.log(`\n Mostly ready — ${failed.length} browser(s) could not be set up.`);
} else {
console.log(`\n ✓ Ready. Try ${sample}`);
}

// Said last because it is the thing that actually blocks a page from loading,
// and a person who just ran a setup command will read the final line.
console.log(" Moshpit sites need the resolver and proxy running too: `moshpit-proxy`");

process.exit(failed.length ? 1 : 0);

export type { Store };
147 changes: 147 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/bin/sh
# Install moshpit-proxy, and set this computer up to open Moshpit sites.
#
# curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh
#
# Installs to your home directory and needs no root for the code itself. The
# setup step asks once before changing anything, and on macOS will ask for your
# password because the system store needs it.
#
# If piping a script from the internet into a shell makes you uneasy, good — read
# it first:
#
# curl -fsSL .../install.sh -o install.sh && less install.sh && sh install.sh
#
set -eu

REPO="profullstack/moshpit-proxy"
REF="${MOSHPIT_REF:-main}"
PREFIX="${MOSHPIT_PREFIX:-${XDG_DATA_HOME:-$HOME/.local/share}/moshpit-proxy}"
BINDIR="${MOSHPIT_BIN:-$HOME/.local/bin}"
ACTION="install"
RUN_TRUST=1
ASSUME_YES=""

RED=''; BOLD=''; DIM=''; OFF=''
if [ -t 2 ]; then RED=$(printf '\033[31m'); BOLD=$(printf '\033[1m'); DIM=$(printf '\033[2m'); OFF=$(printf '\033[0m'); fi

say() { printf '%s\n' "$*" >&2; }
step() { printf '%s==>%s %s\n' "$BOLD" "$OFF" "$*" >&2; }
warn() { printf '%swarning:%s %s\n' "$RED" "$OFF" "$*" >&2; }
die() { printf '%serror:%s %s\n' "$RED" "$OFF" "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }

while [ $# -gt 0 ]; do
case "$1" in
--uninstall) ACTION="uninstall" ;;
--no-trust) RUN_TRUST=0 ;;
--yes|-y) ASSUME_YES="--yes" ;;
--prefix) PREFIX="${2:?--prefix needs a path}"; shift ;;
--bin) BINDIR="${2:?--bin needs a path}"; shift ;;
--ref) REF="${2:?--ref needs a git ref}"; shift ;;
-h|--help)
cat >&2 <<EOF
usage: install.sh [options]

--uninstall remove the install, and undo the browser setup
--no-trust install the code only, skip the browser setup
--yes do not ask before the browser setup
--prefix <dir> where the code goes (default: $PREFIX)
--bin <dir> where the shims go (default: $BINDIR)
--ref <tag|branch|sha> what to install (default: main)

environment: MOSHPIT_PREFIX, MOSHPIT_BIN, MOSHPIT_REF
EOF
exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
shift
done

if [ "$ACTION" = "uninstall" ]; then
if [ -x "$PREFIX/bin/moshpit-trust.ts" ] && have node; then
step "undoing the browser setup"
node "$PREFIX/bin/moshpit-trust.ts" --uninstall || warn "could not undo the browser setup"
fi
step "removing $PREFIX"
rm -rf "$PREFIX"
for shim in moshpit-proxy moshpit-pin moshpit-trust; do rm -f "$BINDIR/$shim"; done
say "Removed."
exit 0
fi

# ---------------------------------------------------------------- runtime

have node || die "node is required (v24 or newer)"

# Checked by capability rather than by version string, for the same reason
# moshpit-transport does it: a Node built without the pieces we need passes a
# version check and then fails at the first connection. Dynamic SNI and TLS 1.3
# are the two this proxy cannot work without.
node -e '
const tls = require("node:tls");
const [maj] = process.versions.node.split(".").map(Number);
if (maj < 24) { console.error("node " + process.versions.node + " is too old"); process.exit(1); }
if (typeof tls.createSecureContext !== "function") { console.error("node:tls is incomplete"); process.exit(1); }
' || die "this node cannot run the proxy — install Node 24 or newer"

have openssl || die "openssl is required (the local key is generated with it)"

# ---------------------------------------------------------------- fetch

step "installing $REPO@$REF to $PREFIX"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT INT TERM

url="https://codeload.github.com/$REPO/tar.gz/$REF"
if have curl; then
curl -fsSL "$url" -o "$tmp/src.tgz" || die "download failed: $url"
elif have wget; then
wget -qO "$tmp/src.tgz" "$url" || die "download failed: $url"
else
die "need curl or wget"
fi

mkdir -p "$tmp/src"
tar -xzf "$tmp/src.tgz" -C "$tmp/src" --strip-components=1 || die "could not unpack the download"

rm -rf "$PREFIX"
mkdir -p "$(dirname "$PREFIX")"
mv "$tmp/src" "$PREFIX"

# ---------------------------------------------------------------- shims

mkdir -p "$BINDIR"
for shim in moshpit-proxy moshpit-pin moshpit-trust; do
[ -f "$PREFIX/bin/$shim.ts" ] || continue
cat > "$BINDIR/$shim" <<EOF
#!/bin/sh
exec node "$PREFIX/bin/$shim.ts" "\$@"
EOF
chmod +x "$BINDIR/$shim"
done

case ":$PATH:" in
*":$BINDIR:"*) ;;
*) warn "$BINDIR is not on your PATH — add it to use \`moshpit-proxy\` by name" ;;
esac

# ---------------------------------------------------------------- setup

if [ "$RUN_TRUST" = "1" ]; then
step "setting up your browsers"
# Straight to the terminal, not through this script's stdin: when the whole
# installer arrived via `curl | sh`, stdin is the script itself and a prompt
# would read the rest of the file as the answer.
if [ -n "$ASSUME_YES" ] || [ ! -r /dev/tty ]; then
node "$PREFIX/bin/moshpit-trust.ts" ${ASSUME_YES:+--yes} || warn "browser setup did not finish — run \`moshpit-trust\` yourself"
else
node "$PREFIX/bin/moshpit-trust.ts" < /dev/tty || warn "browser setup did not finish — run \`moshpit-trust\` yourself"
fi
fi

say ""
say "${BOLD}Installed.${OFF}"
say " ${DIM}start the proxy:${OFF} moshpit-proxy"
say " ${DIM}redo the setup:${OFF} moshpit-trust"
say " ${DIM}undo everything:${OFF} install.sh --uninstall"
Loading
Loading