#!/bin/sh # Installs Kliper on this machine, the customer-managed edition (#1106): # # curl -fsSL https://get.kliper.dev | sudo sh # # or read it before running it: # # curl -fsSLO https://get.kliper.dev/install.sh # sudo sh install.sh # # It asks for the registry key and the license from Kortlabs, the address Kliper is # opened at and the first admin, or reads them from the environment (sudo -E keeps them): # KLIPER_REGISTRY_KEY, KLIPER_LICENSE, KLIPER_URL, KLIPER_TLS (auto or internal, for an # https address), KLIPER_ADMIN_EMAIL, KLIPER_ADMIN_NAME, KLIPER_DIR (default /opt/kliper), # KLIPER_VERSION (default: the latest release) and KLIPER_IGNORE_REQUIREMENTS=yes (a # machine below 4 CPUs, 8 GB RAM, 15 GB free disk, without asking). It checks the machine, signs in to the # registry, fetches the install kit, checks every Kliper image against Kortlabs' public # key, writes .env, starts everything and creates the first admin. It never touches an # existing install. README.md in the kit covers the rest. # # Everything runs inside main, called on the last line, so a download cut short runs nothing. set -eu REGISTRY=ghcr.io/kortlabs # cosign v3.1.4 (amd64 and arm64), run as a container so nothing is installed for it COSIGN_IMAGE=ghcr.io/sigstore/cosign/cosign:v3.1.4@sha256:0f06154836d2de34f887a5c273506d52ca43edaa1d9e53e6510fa78a48f69c2d # deploy/selfhost/cosign.pub: Kortlabs signs every Kliper image with the matching private key COSIGN_PUB='-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJIyGEZSzj79sCxj2bNnmGRfubx7l 8WXd4xvk5lAs4fgA0obFgmIKfiz4EU0CtIYeAN+SYCD947CfMqCKl98h4A== -----END PUBLIC KEY-----' say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } die() { printf '\nkliper install: %s\n' "$*" >&2; exit 1; } # Answers come from the terminal: under curl | sh, standard input is this script. # Fails when there is no terminal, so an unattended run must set the variable instead. prompt() { # $1 = question, $2 = default, $3 = "secret" to hide the answer (: /dev/null || return 1 if [ "${3:-}" = secret ]; then printf '%s: ' "$1" >/dev/tty stty -echo /dev/tty else if [ -n "${2:-}" ]; then printf '%s [%s]: ' "$1" "$2" >/dev/tty; else printf '%s: ' "$1" >/dev/tty; fi IFS= read -r answer /dev/null 2>"$WORK/verify.log" \ || { cat "$WORK/verify.log" >&2; die "$1 is not signed by Kortlabs: stopping"; } say " signed by Kortlabs: ${1%@*}" } digest_of() { # the registry digest of a pulled image docker inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$1" | grep "^${1%:*}@" | head -1 } main() { WORK=$(mktemp -d) trap 'rm -rf "$WORK"; (stty echo /dev/null || true' EXIT trap 'exit 130' INT TERM step "Checking this machine" [ "$(uname -s)" = Linux ] || die "Kliper installs on Linux" [ "$(id -u)" = 0 ] || die "run it as root: curl -fsSL https://get.kliper.dev | sudo sh" for c in curl openssl awk; do command -v "$c" >/dev/null 2>&1 || die "$c is required: install it, then run this again"; done if ! command -v docker >/dev/null 2>&1; then a=$(prompt "Docker is not installed. Install it with Docker's own script, get.docker.com (y/n)" Y) || die "Docker is not installed" case "$a" in [Nn]*) die "install Docker with Compose v2, then run this again" ;; esac curl -fsSL https://get.docker.com | sh fi docker info >/dev/null 2>&1 || die "Docker is installed but not running (systemctl start docker)" docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is missing (the docker-compose-plugin package)" DIR=${KLIPER_DIR:-/opt/kliper} # Data exists once the stack has started; a new .env would lock it out. An attempt that # stopped before starting left none, so running again is safe. project=${COMPOSE_PROJECT_NAME:-kliper} [ -z "$(docker volume ls -q --filter "label=com.docker.compose.project=$project")" ] \ || die "Docker already holds the data of a Kliper install (volumes ${project}_*), which only its own .env opens. Upgrade it (README.md in its deploy/selfhost), or remove it first" mkdir -p "$DIR" cpus=$(nproc) mem_gb=$(awk '/^MemTotal/ { printf "%d", $2 / 1048576 + 0.5 }' /proc/meminfo) images=$(docker info --format '{{.DockerRootDir}}') disk_gb=$(df -Pk "$images" | awk 'NR == 2 { printf "%d", $4 / 1048576 }') say " $cpus CPUs, $mem_gb GB RAM, $disk_gb GB free for Docker ($images)" short= [ "$cpus" -ge 4 ] || short="$short 4 CPUs," [ "$mem_gb" -ge 8 ] || short="$short 8 GB RAM," [ "$disk_gb" -ge 15 ] || short="$short 15 GB free disk," if [ -n "$short" ]; then say " Kliper wants${short%,}." a=${KLIPER_IGNORE_REQUIREMENTS:-} [ -n "$a" ] || a=$(prompt "Install anyway (y/n)" N) || a=N case "$a" in [Yy]*) ;; *) die "stopped: the machine is below the requirements" ;; esac fi step "Settings" KEY=${KLIPER_REGISTRY_KEY:-} [ -n "$KEY" ] || KEY=$(prompt "Registry key from Kortlabs" "" secret) || die "set KLIPER_REGISTRY_KEY" [ -n "$KEY" ] || die "the registry key is required" case "$KEY" in github_pat_*) die "that is a fine-grained token, which the registry does not accept: create a token (classic) with only read:packages" ;; esac LICENSE=${KLIPER_LICENSE:-} [ -n "$LICENSE" ] || LICENSE=$(prompt "License from Kortlabs (empty: Free plan limits)" "" secret) || LICENSE= # Pasted from a mail or a .env line: spaces, line ends, quotes and the variable name go LICENSE=$(printf '%s' "$LICENSE" | tr -d " \t\r\n\"'") LICENSE=${LICENSE#KLIPER_LICENSE=} case "$LICENSE" in *[!A-Za-z0-9._-]*) die "that is not a Kliper license" ;; esac # The prompt hides what is pasted: say what was read. The backend checks the signature. if [ -n "$LICENSE" ]; then p=${LICENSE%%.*} case $((${#p} % 4)) in 2) p="$p==" ;; 3) p="$p=" ;; esac lic=$(printf '%s' "$p" | tr '_-' '/+' | openssl base64 -d -A 2>/dev/null) || lic= field() { printf '%s' "$lic" | sed -n "s/.*\"$1\":\"\{0,1\}\([^\",}]*\).*/\1/p"; } [ -n "$(field licensee)" ] || die "that is not a Kliper license" say " license: $(field licensee), plan $(field plan), $(field seats) seats, until $(field expires)" else say " no license: the install runs on Free plan limits" fi KIT_TAG=${KLIPER_VERSION:-latest} # Compose would take these from the environment over .env unset KLIPER_VERSION KLIPER_LICENSE URL=${KLIPER_URL:-} [ -n "$URL" ] || URL=$(prompt "Address Kliper is opened at (https://kliper.yourfirm.com; http://127.0.0.1:18080 tries it on this machine only)" http://127.0.0.1:18080) || URL=http://127.0.0.1:18080 case "$URL" in https://*) TLS=${KLIPER_TLS:-} [ -n "$TLS" ] || TLS=$(prompt "Certificate: auto (public, from Let's Encrypt; needs the DNS name and port 443) or internal (Caddy's own authority)" auto) || TLS=auto ;; http://*) TLS= ;; *) die "the address starts with http:// or https://" ;; esac EMAIL=${KLIPER_ADMIN_EMAIL:-} [ -n "$EMAIL" ] || EMAIL=$(prompt "First admin's email") || die "set KLIPER_ADMIN_EMAIL" case "$EMAIL" in *@*.*) ;; *) die "not an email address: $EMAIL" ;; esac NAME=${KLIPER_ADMIN_NAME:-} [ -n "$NAME" ] || NAME=$(prompt "First admin's full name") || die "set KLIPER_ADMIN_NAME" [ -n "$NAME" ] || die "the admin's name is required" printf '%s\n' "$COSIGN_PUB" > "$WORK/cosign.pub" # The registry accepts any user name with a key; the signature checks get their own copy. mkdir -m 700 "$WORK/docker" printf '{"auths":{"ghcr.io":{"auth":"%s"}}}\n' "$(printf 'kliper:%s' "$KEY" | openssl base64 -A)" > "$WORK/docker/config.json" step "Signing in to the registry" printf '%s' "$KEY" | docker login ghcr.io -u kliper --password-stdin >/dev/null 2>"$WORK/login.log" \ || { cat "$WORK/login.log" >&2; die "the registry refused the key; it must be a token (classic) with read:packages"; } say " signed in (the key stays in root's Docker settings for upgrades)" step "Fetching the install kit" docker pull -q "$REGISTRY/kliper-kit:$KIT_TAG" >/dev/null || die "no install kit $KIT_TAG in the registry" kit=$(digest_of "$REGISTRY/kliper-kit:$KIT_TAG") verify "$kit" cid=$(docker create "$kit") docker cp "$cid:/kit/." "$DIR/" >/dev/null || { docker rm "$cid" >/dev/null; die "could not copy the install kit into $DIR"; } docker rm "$cid" >/dev/null VERSION=$(cat "$DIR/deploy/selfhost/VERSION") say " Kliper $VERSION in $DIR" step "Writing the settings (.env)" cd "$DIR/deploy/selfhost" umask 077 if [ -n "$TLS" ]; then ./gen-env.sh "$URL" "$TLS" > "$WORK/env"; else ./gen-env.sh "$URL" > "$WORK/env"; fi # Without it, no password can be set at all, the first admin's included. if curl -fsS -m 10 -o /dev/null https://api.pwnedpasswords.com/range/00000 2>/dev/null; then breach=on; else breach=off; say " api.pwnedpasswords.com is out of reach: the password breach check is off" fi KLIPER_LICENSE_VALUE=$LICENSE BREACH=$breach awk ' /^KLIPER_LICENSE=/ { print "KLIPER_LICENSE=" ENVIRON["KLIPER_LICENSE_VALUE"]; next } /^PASSWORD_BREACH_CHECK=/ { print "PASSWORD_BREACH_CHECK=" ENVIRON["BREACH"]; next } { print }' "$WORK/env" > .env say " $DIR/deploy/selfhost/.env (keep it: the data depends on its keys)" step "Downloading Kliper $VERSION (about 9 GB the first time)" docker compose pull for app in backend frontend; do verify "$(digest_of "$REGISTRY/kliper-$app:$VERSION")"; done step "Starting (about two minutes the first time)" docker compose up -d --quiet-pull >"$WORK/up.log" 2>&1 || { cat "$WORK/up.log" >&2; die "the stack did not start: docker compose ps, docker compose logs "; } i=0 until docker compose exec -T backend node -e "fetch('http://127.0.0.1:3001/api/auth/get-session').then(r => process.exit(r.status < 500 ? 0 : 1), () => process.exit(1))" >/dev/null 2>&1; do i=$((i + 1)) [ "$i" -lt 90 ] || die "the backend did not answer within three minutes: docker compose logs backend" sleep 2 done say " running" step "Creating the first admin" docker compose exec -T backend node backend/dist/scripts/create-admin.js --email "$EMAIL" --name "$NAME" \ || die "the admin was not created; once the cause is fixed: docker compose exec backend node backend/dist/scripts/create-admin.js --email
--name \"\"" step "Done" say " Open $URL and sign in as $EMAIL with the temporary password above." say " Then create the firm, change the password and invite the team." case "$URL" in http://127.0.0.1* | http://localhost*) port=${URL#http://} case "$port" in *:*) port=${port##*:} ;; *) port=80 ;; esac say " This address answers on this machine only. From another computer, tunnel to it:" say " ssh -L $port:127.0.0.1:$port @ then open $URL there" ;; esac say " Mail (invitations, codes) and Cortex need SMTP_* and OPENAI_API_KEY in" say " $DIR/deploy/selfhost/.env, then: cd $DIR/deploy/selfhost && docker compose up -d" say " Backups, upgrades and logs: $DIR/deploy/selfhost/README.md" } main "$@"