diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc50074 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +name: ci +on: + push: + branches: [main] + pull_request: +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: shellcheck + run: shellcheck install.sh bin/rig commands/*.sh test/cli.sh + - name: cli tests + run: bash test/cli.sh diff --git a/README.md b/README.md index 18d5cf3..c3ff72d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ -# deployor -Box-plumbing CLI: curl-install on a pristine Debian server, bootstrap it into a hardened tailnet-joined node +# rig + +A CLI that turns a **pristine Debian server into a hardened, tailnet-joined +node** — one curl, one command. A second command installs a version-pinned +Coolify on a control-plane box. + +Philosophy (shared with [claudebox](https://github.com/heavy-duty/claudebox)): +**public tool, private state**. rig carries plumbing logic only — no +hostnames, no bindings, no secrets, nothing about *your* infrastructure. It +takes arguments, does its work, and stores no credential, ever. + +## Install + +```sh +curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash +``` + +Installs the tree to `~/.local/share/rig` and links `rig` onto your +PATH (`/usr/local/bin` when root). Re-run any time to upgrade. + +## Commands + +### `rig bootstrap ` + +Run as root on the fresh box (over SSH). Convergent — safe to re-run; a +second run changes nothing. + +```sh +rig bootstrap control-plane --hostname my-coolify-box +rig bootstrap workload --hostname my-prod-box +``` + +- `--hostname ` — tailnet hostname (default: the role name) +- `--ts-tag ` — tailnet tag to advertise (default: `tag:server`) + +What it does: installs `curl ca-certificates unattended-upgrades` (and +enables periodic unattended upgrades); writes an sshd hardening drop-in +(`PermitRootLogin prohibit-password`, `PasswordAuthentication no`); installs +tailscale and joins your tailnet. + +**The pre-auth key:** provide it via the `TS_AUTHKEY` env var or type it at +the interactive prompt. Use a **single-use, tagged, short-expiry** key. It +lives in process memory only — rig never writes a credential to disk. + +The two roles are identical today except the default hostname; they exist +because control-plane and workload boxes diverge over time, and because the +next command applies to exactly one of them. + +### `rig coolify install --version ` + +Control-plane box only. Installs Coolify at exactly the pinned version with +`AUTOUPDATE=false` — your deploy tooling is verified against an API surface; +the platform must never move underneath it on its own. Upgrading is an +explicit re-run with a new pin. The pin is required; there is no default. + +## What rig deliberately does NOT do + +- **Provider firewalls** — Docker publishes ports past host firewalls, so + the real boundary is your cloud provider's firewall, configured outside + this tool. +- **Fetch your config** — boxes never receive repo credentials. Everything + rig needs arrives as arguments or an interactive prompt. +- **Manage deployments** — deploy manifests/executors are separate concerns. + (Planned: the `apply`/`diff` executor half joins rig as commands that + run on operator machines, never on boxes.) + +## Testing + +`bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The +end-to-end rehearsal is a throwaway VM/container: pristine Debian → install → +`bootstrap workload` with a real single-use key → assert the sshd drop-in, +tailnet join, and a no-op second run → destroy, remove the node from the +tailnet. diff --git a/bin/rig b/bin/rig new file mode 100755 index 0000000..286ab12 --- /dev/null +++ b/bin/rig @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" + +usage() { + cat <<'EOF' +usage: rig [args] + +commands: + bootstrap [--hostname ] [--ts-tag ] + OS plumbing on a pristine Debian box: hardening, unattended-upgrades, + tailscale join. Prompts for a single-use tailnet pre-auth key + (TS_AUTHKEY env overrides the prompt). Run as root. + coolify install --version + Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. + +install/upgrade: + curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash +EOF +} + +cmd="${1:-}" +case "$cmd" in + bootstrap) + shift + exec "$ROOT/commands/bootstrap.sh" "$@" + ;; + coolify) + shift + sub="${1:-}" + if [ "$sub" != "install" ]; then + usage >&2 + exit 2 + fi + shift + exec "$ROOT/commands/coolify-install.sh" "$@" + ;; + -h|--help|help) + usage + exit 0 + ;; + "") + usage >&2 + exit 2 + ;; + *) + printf 'rig: unknown command: %s\n' "$cmd" >&2 + usage >&2 + exit 2 + ;; +esac diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh new file mode 100755 index 0000000..4b82fc5 --- /dev/null +++ b/commands/bootstrap.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# rig bootstrap — OS plumbing for a pristine Debian box. +# Convergent: safe to re-run; a second run changes nothing. +set -euo pipefail + +log() { printf 'rig-bootstrap: %s\n' "$*"; } +warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig bootstrap [--hostname ] [--ts-tag ] + + --hostname tailnet hostname (default: the role name) + --ts-tag tailnet tag to advertise (default: tag:server) + +Provide the single-use tailscale pre-auth key via the TS_AUTHKEY env var, or +enter it at the interactive prompt. It is used once and never written to disk. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +ROLE="${1:-}" +case "$ROLE" in + control-plane|workload) shift ;; + -h|--help) usage; exit 0 ;; + "") usage >&2; die "role required (control-plane|workload)" 2 ;; + *) die "unknown role: $ROLE (want control-plane|workload)" 2 ;; +esac + +TS_HOSTNAME="$ROLE" +TS_TAG="tag:server" +while [ $# -gt 0 ]; do + case "$1" in + --hostname) + [ $# -ge 2 ] || die "--hostname needs a value" 2 + TS_HOSTNAME="$2"; shift 2 ;; + --ts-tag) + [ $# -ge 2 ] || die "--ts-tag needs a value" 2 + TS_TAG="$2"; shift 2 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- guards ------------------------------------------------------------------ +[ "$(id -u)" -eq 0 ] || die "must run as root" +if [ -r /etc/os-release ]; then + # shellcheck source=/dev/null + . /etc/os-release + case "${ID:-} ${ID_LIKE:-}" in + *debian*) ;; + *) warn "not a Debian-family system (ID=${ID:-unknown}); proceeding anyway" ;; + esac +else + warn "cannot read /etc/os-release; proceeding anyway" +fi + +# --- pre-auth key (env override, else prompt; never touches disk) ------------ +if [ -z "${TS_AUTHKEY:-}" ]; then + read -rsp "tailscale pre-auth key (single-use, tagged, <=1h expiry): " TS_AUTHKEY + echo +fi +[ -n "$TS_AUTHKEY" ] || die "empty pre-auth key" + +# --- packages ---------------------------------------------------------------- +export DEBIAN_FRONTEND=noninteractive +log "installing base packages" +apt-get update -qq +apt-get install -y -qq curl ca-certificates unattended-upgrades + +# enable periodic unattended upgrades (canonical file; idempotent overwrite) +cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF' +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Unattended-Upgrade "1"; +EOF + +# --- sshd hardening (restart only when the drop-in actually changed) --------- +DROPIN=/etc/ssh/sshd_config.d/99-rig.conf +TMP="$(mktemp)" +cat > "$TMP" <<'EOF' +PermitRootLogin prohibit-password +PasswordAuthentication no +EOF +if ! cmp -s "$TMP" "$DROPIN" 2>/dev/null; then + install -m 0644 "$TMP" "$DROPIN" + systemctl restart ssh + log "sshd hardening drop-in installed" +else + log "sshd hardening drop-in already in place" +fi +rm -f "$TMP" + +# --- tailscale ---------------------------------------------------------------- +if ! command -v tailscale >/dev/null 2>&1; then + log "installing tailscale" + curl -fsSL https://tailscale.com/install.sh | sh +fi +if tailscale status >/dev/null 2>&1; then + log "tailnet already joined; skipping tailscale up" +else + log "joining tailnet as ${TS_HOSTNAME} (${TS_TAG})" + tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" --advertise-tags="$TS_TAG" +fi + +log "done — role ${ROLE}, hostname ${TS_HOSTNAME}" +if [ "$ROLE" = "control-plane" ]; then + log "next: rig coolify install --version " +fi diff --git a/commands/coolify-install.sh b/commands/coolify-install.sh new file mode 100755 index 0000000..4e6d915 --- /dev/null +++ b/commands/coolify-install.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# rig coolify install — pinned Coolify install; AUTOUPDATE=false so the +# platform never self-updates underneath its operators. Upgrades are an +# explicit act. +set -euo pipefail + +log() { printf 'rig-coolify: %s\n' "$*"; } +die() { printf 'rig-coolify: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig coolify install --version + +Installs Coolify at exactly (e.g. 4.1.2) with AUTOUPDATE=false. +Control-plane box only. The version pin is required — you state the floor +your tooling is verified against; there is no default. +EOF +} + +VERSION="" +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || die "--version needs a value" 2 + VERSION="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done +if [ -z "$VERSION" ]; then + usage >&2 + die "--version is required" 2 +fi + +[ "$(id -u)" -eq 0 ] || die "must run as root" + +export AUTOUPDATE=false +log "installing coolify ${VERSION} (AUTOUPDATE=false)" +curl -fsSL https://cdn.coollabs.io/coolify/install.sh -o /tmp/coolify-install.sh +bash /tmp/coolify-install.sh "$VERSION" +log "coolify ${VERSION} installed with AUTOUPDATE=false" +log "next: your bootstrap runbook (admin user, API token, GitHub App, S3 destination)" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e10f9db --- /dev/null +++ b/install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +# rig installer — intended for: curl -fsSL .../install.sh | bash +# +# Downloads the rig repo tarball, installs the whole tree under $DEST, +# and puts a `rig` symlink on PATH via $BINDIR. Re-run any time to +# upgrade. + +REPO="${RIG_REPO:-heavy-duty/rig}" +REF="${RIG_REF:-main}" +DEST="${RIG_HOME:-$HOME/.local/share/rig}" +if [ "$(id -u)" -eq 0 ]; then + BINDIR="${RIG_BIN:-/usr/local/bin}" +else + BINDIR="${RIG_BIN:-$HOME/.local/bin}" +fi + +log() { printf 'rig-install: %s\n' "$*"; } +warn() { printf 'rig-install: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-install: ERROR: %s\n' "$*" >&2; exit 1; } + +# --- prerequisites ----------------------------------------------------------- +command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +command -v tar >/dev/null 2>&1 || die "tar is required but was not found." + +# --- temp workspace ---------------------------------------------------------- +TMPDIR="$(mktemp -d)" +cleanup() { rm -rf "$TMPDIR"; } +trap cleanup EXIT + +URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + +log "installing rig ($REPO@$REF)" +log "downloading $URL" +curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz" \ + || die "failed to download $URL" + +log "extracting archive" +tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ + || die "failed to extract archive" + +# GitHub archives extract to a single top-level dir like rig-/ +EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'rig-*' | head -n1)" +[ -n "$EXTRACTED" ] || die "could not find extracted rig-* directory in archive" +[ -f "$EXTRACTED/bin/rig" ] || die "archive does not contain bin/rig — is $REPO@$REF correct?" + +# --- atomically replace $DEST -------------------------------------------------- +log "installing into $DEST" +rm -rf "$DEST" +mkdir -p "$(dirname "$DEST")" +mv "$EXTRACTED" "$DEST" + +chmod +x "$DEST/bin/rig" "$DEST"/commands/*.sh + +# --- put rig on PATH ------------------------------------------------------ +mkdir -p "$BINDIR" +ln -sf "$DEST/bin/rig" "$BINDIR/rig" +log "linked $BINDIR/rig -> $DEST/bin/rig" + +# --- PATH check ---------------------------------------------------------------- +case ":$PATH:" in + *":$BINDIR:"*) : ;; + *) + warn "$BINDIR is not on your PATH." + warn " add: export PATH=\"$BINDIR:\$PATH\"" + ;; +esac + +log "done — try: rig --help" diff --git a/test/cli.sh b/test/cli.sh new file mode 100644 index 0000000..df81a0f --- /dev/null +++ b/test/cli.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Dependency-free CLI assertions. Run: bash test/cli.sh +# Deliberately no `set -e` — the harness asserts on failing commands. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PASS=0 FAIL=0 + +# check +# Runs cmd, asserts exit code and (if non-empty) that combined output +# contains want_substr. +check() { + local desc="$1" want="$2" substr="$3"; shift 3 + local out rc + out="$("$@" 2>&1)"; rc=$? + if [ "$rc" -ne "$want" ]; then + echo "FAIL: $desc — exit $rc, wanted $want" + printf '%s\n' "$out" | sed 's/^/ /' + FAIL=$((FAIL + 1)); return + fi + if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then + echo "FAIL: $desc — output missing '$substr'" + printf '%s\n' "$out" | sed 's/^/ /' + FAIL=$((FAIL + 1)); return + fi + echo "ok: $desc"; PASS=$((PASS + 1)) +} + +check "no args shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" +check "--help exits 0" 0 "usage:" "$ROOT/bin/rig" --help +check "help exits 0" 0 "usage:" "$ROOT/bin/rig" help +check "unknown command exits 2" 2 "unknown command" "$ROOT/bin/rig" frobnicate +check "bare coolify shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" coolify + +check "bootstrap: role required, exit 2" 2 "role required" "$ROOT/commands/bootstrap.sh" +check "bootstrap: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap.sh" --help +check "bootstrap: unknown role exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato +check "bootstrap: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap.sh" workload --nope +check "bootstrap: hostname needs value" 2 "needs a value" "$ROOT/commands/bootstrap.sh" workload --hostname +if [ "$(id -u)" -ne 0 ]; then + check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload +else + echo "skip: bootstrap non-root refusal (running as root)" +fi + +check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" +check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help +check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version +check "coolify: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/coolify-install.sh" --nope +if [ "$(id -u)" -ne 0 ]; then + check "coolify: refuses non-root" 1 "must run as root" "$ROOT/commands/coolify-install.sh" --version 4.1.2 +else + echo "skip: coolify non-root refusal (running as root)" +fi + +echo "---" +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]