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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/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
Expand Down
20 changes: 20 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 107 additions & 0 deletions scripts/moshpit-host.sh
Original file line number Diff line number Diff line change
@@ -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/<name>/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 <name>` 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 <<EOF
usage: moshpit-host.sh <user> <name> [--key "<ssh public key>"] [--dry-run]

<user> the customer's account on this box (made if new)
<name> the Moshpit name to serve from /home/<user>/apps/<name>/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 <from $RENEW_ENV> --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 <<EOF

$NAME is hosted here for $USER_NAME. From their machine:

moshcode deploy $NAME --host $TARGET --user $USER_NAME

It opens at https://$NAME/ on Moshpit DNS and https://$NAME.pit.world/ everywhere else.
EOF
51 changes: 51 additions & 0 deletions tests/moshpit-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// What `moshpit-host.sh` refuses, and what it would do.
//
// The real run makes accounts and nginx blocks as root, so what is exercised here is
// everything before that: validation and --dry-run both run without privileges.

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";

const run = promisify(execFile);
const script = fileURLToPath(new URL("../scripts/moshpit-host.sh", import.meta.url));

async function sh(args: string[]) {
try {
const { stdout, stderr } = await run("sh", [script, ...args], { env: { ...process.env, MOSHPIT_HOST_TARGET: "dev3.example.com" } });
return { code: 0, stdout, stderr };
} catch (err: any) {
return { code: err.code ?? 1, stdout: String(err.stdout || ""), stderr: String(err.stderr || "") };
}
}

describe("moshpit-host.sh", () => {
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/);
});
});
Loading