From 17c9502fa37da6da17c2bd66c28a1775a5e6eab0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 13:21:51 +0000 Subject: [PATCH] moshpit-host.sh: host a customer's Moshpit name on a shared box One-time setup behind a $5/month Moshpit server: key-only account, SSH key, ~/apps//public, and setup-origin.sh with that webroot, this box as the target, signed with the box key from renew.env. The customer then ships with `moshcode deploy `. Validation + --dry-run tested without root. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++++ bun.lock | 20 +++++++ scripts/moshpit-host.sh | 107 +++++++++++++++++++++++++++++++++++++ tests/moshpit-host.test.ts | 51 ++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 bun.lock create mode 100755 scripts/moshpit-host.sh create mode 100644 tests/moshpit-host.test.ts diff --git a/README.md b/README.md index fb3b69b..43b167a 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,21 @@ resolved normally, which is also how a name reaches IPv4-only clients. Nothing is proxied on the server side. `moshpit-proxy` runs on the *visitor's* machine — it is the thing that checks the pin on behalf of a browser that cannot. +### Hosting names for other people (a Moshpit server) + +`scripts/moshpit-host.sh` is the one-time setup for a customer's name on a shared box: +their account (key-only), `~/apps//public`, and `setup-origin.sh` with that +folder as the webroot and this box as the target, signed with the box's key in +`/etc/moshpit/renew.env` (so the name must be held by that key's account). + +```sh +sudo sh scripts/moshpit-host.sh alice shop.alice --key "ssh-ed25519 AAAA... alice@laptop" +``` + +From then on the customer deploys from their own machine with +`moshcode deploy shop.alice --user alice`, which rsyncs the site into that folder and +runs its `deploy.sh`, if any, inside the box's `deploy-gate`. + ## Publishing a pin (site operators) ```sh diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ea92006 --- /dev/null +++ b/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@profullstack/moshpit-proxy", + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/scripts/moshpit-host.sh b/scripts/moshpit-host.sh new file mode 100755 index 0000000..342bf42 --- /dev/null +++ b/scripts/moshpit-host.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Host a Moshpit name for a customer on this box: their account, their folder, the name's certificate. +# +# sudo sh scripts/moshpit-host.sh alice shop.alice --key "ssh-ed25519 AAAA... alice@laptop" +# +# This is the one-time half of a Moshpit server ($5 a month, unlimited sites). It +# makes the Unix account if it is new (key-only, password locked), adds the SSH key, +# creates ~/apps//public, and runs setup-origin.sh for the name with that +# folder as its webroot and this box as the name's target. After that the customer +# ships with `moshcode deploy ` from their own machine, as often as they like, +# and nothing here needs to run again. Another name for the same customer is another +# run of this script. +# +# The registry signs with the key in $RENEW_ENV, the box's own: the name must be held +# by that key's account, because the daily renewal (moshpit-renew.sh) signs with it too. +# Re-running is safe: the account, key and folder are kept, and setup-origin.sh reuses +# the name's key so its pin does not move. +set -eu + +RENEW_ENV="${MOSHPIT_RENEW_ENV:-/etc/moshpit/renew.env}" +SETUP="${MOSHPIT_SETUP_ORIGIN:-$(dirname "$0")/setup-origin.sh}" +TARGET="${MOSHPIT_HOST_TARGET:-$(hostname -f)}" +HOMES="${MOSHPIT_HOMES:-/home}" + +die() { printf 'moshpit-host: %s\n' "$*" >&2; exit 1; } + +usage() { + cat >&2 < [--key ""] [--dry-run] + + the customer's account on this box (made if new) + the Moshpit name to serve from /home//apps//public + --key an SSH public key to let in (added once; repeat the run to add more) + +environment: MOSHPIT_RENEW_ENV, MOSHPIT_SETUP_ORIGIN, MOSHPIT_HOST_TARGET (default: hostname -f) +EOF +} + +case "${1:-}" in -h|--help|'') usage; exit 0 ;; esac +USER_NAME="$1"; NAME="${2:-}"; shift; shift 2>/dev/null || true +KEY=""; DRY_RUN=0 +while [ $# -gt 0 ]; do + case "$1" in + --key) KEY="${2:?--key needs a public key}"; shift ;; + --dry-run) DRY_RUN=1 ;; + *) usage; die "unknown option: $1" ;; + esac + shift +done + +printf '%s' "$USER_NAME" | grep -Eq '^[a-z_][a-z0-9_-]{0,31}$' || die "not a valid account name: $USER_NAME" +# The same shape `moshcode deploy` accepts: lowercase labels, at least one dot, none empty. +printf '%s' "$NAME" | grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || die "not a Moshpit name: $NAME" +case "$NAME" in *.pit.world) die "$NAME is the relay's address; pass the name itself (${NAME%.pit.world})" ;; esac +if [ -n "$KEY" ]; then + printf '%s' "$KEY" | grep -Eq '^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh.com) [A-Za-z0-9+/=]+( .*)?$' \ + || die "--key does not look like one SSH public key line" +fi + +HOME_DIR="$HOMES/$USER_NAME" +SITE="$HOME_DIR/apps/$NAME" + +if [ "$DRY_RUN" = 1 ]; then + echo "account $USER_NAME ($(id -u "$USER_NAME" >/dev/null 2>&1 && echo exists || echo new))" + echo "folder $SITE/public" + echo "origin sh $SETUP $NAME --webroot $SITE/public --api-key --target $TARGET" + exit 0 +fi + +[ "$(id -u)" = 0 ] || die "run as root (it makes accounts and nginx blocks)" +[ -f "$SETUP" ] || die "$SETUP is missing" +[ -f "$RENEW_ENV" ] || die "no $RENEW_ENV: this box has no registry key, so it cannot sign or point $NAME" +# shellcheck disable=SC1090 +. "$RENEW_ENV" +[ -n "${MOSHPIT_API_KEY:-}" ] || die "$RENEW_ENV has no MOSHPIT_API_KEY" + +if ! id -u "$USER_NAME" >/dev/null 2>&1; then + useradd --create-home --home-dir "$HOME_DIR" --shell /bin/bash "$USER_NAME" + passwd --lock "$USER_NAME" >/dev/null + echo "made account $USER_NAME (key-only)" +fi + +if [ -n "$KEY" ]; then + install -d -m 700 -o "$USER_NAME" -g "$USER_NAME" "$HOME_DIR/.ssh" + AUTH="$HOME_DIR/.ssh/authorized_keys" + touch "$AUTH" + grep -qxF "$KEY" "$AUTH" || printf '%s\n' "$KEY" >> "$AUTH" + chown "$USER_NAME:$USER_NAME" "$AUTH"; chmod 600 "$AUTH" +fi + +# nginx reads the webroot as another user: the home must be traversable, not listable. +chmod o+x "$HOME_DIR" +install -d -o "$USER_NAME" -g "$USER_NAME" "$HOME_DIR/apps" "$SITE" "$SITE/public" + +sh "$SETUP" "$NAME" --webroot "$SITE/public" --api-key "$MOSHPIT_API_KEY" --target "$TARGET" + +# setup-origin.sh writes a placeholder index.html as root; the folder is the customer's. +chown -R "$USER_NAME:$USER_NAME" "$SITE" + +cat < { + test("dry run names the folder and the setup-origin call", async () => { + const r = await sh(["alice", "shop.alice", "--dry-run"]); + assert.equal(r.code, 0, r.stderr); + assert.match(r.stdout, /folder\s+\/home\/alice\/apps\/shop\.alice\/public/); + assert.match(r.stdout, /setup-origin\.sh shop\.alice --webroot \/home\/alice\/apps\/shop\.alice\/public .*--target dev3\.example\.com/); + }); + + test("refuses a bad account, a non-name, and the relay address", async () => { + for (const [args, why] of [ + [["Alice", "shop.alice"], /not a valid account name/], + [["alice", "shopalice"], /not a Moshpit name/], + [["alice", "shop..alice"], /not a Moshpit name/], + [["alice", "shop.alice.pit.world"], /relay's address/], + [["alice", "shop.alice", "--key", "not a key"], /does not look like one SSH public key/], + ] as const) { + const r = await sh([...args, "--dry-run"]); + assert.notEqual(r.code, 0); + assert.match(r.stderr, why); + } + }); + + test("refuses a real run without root", { skip: process.getuid?.() === 0 }, async () => { + const r = await sh(["alice", "shop.alice"]); + assert.notEqual(r.code, 0); + assert.match(r.stderr, /run as root/); + }); +});