mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-29 08:12:40 +00:00
3302 lines
132 KiB
Bash
3302 lines
132 KiB
Bash
#!/bin/sh
|
||
#
|
||
# curl -fsSL https://warmbly.com/install.sh | sh
|
||
#
|
||
# Installs Warmbly on this machine: pull the published release images, write a
|
||
# real .env and a compose file, start the stack, print the link that claims it.
|
||
# No git clone, no Go, Rust, Node or Elixir toolchain, nothing compiled here.
|
||
#
|
||
# sh install.sh --wizard ask where the data lives, what is kept and for
|
||
# how long, and how it is backed up
|
||
# sh install.sh --dry-run print the exact files it would write, touch nothing
|
||
# sh install.sh --help every flag, and the environment variable for each
|
||
#
|
||
# On reading this before running it: that is the right instinct, and it is why
|
||
# the checksum is published next to it. The short version of what it does:
|
||
#
|
||
# * checks for docker, and offers to install it rather than doing so quietly
|
||
# * creates one directory (default /opt/warmbly) and writes only inside it
|
||
# * generates five secrets with openssl, writes them 0600, prints the two
|
||
# that are unrecoverable and waits for you to say you have copied them
|
||
# * resolves the newest release once and PINS it in .env, so every later
|
||
# `docker compose up` in that directory is the same version. Never :latest
|
||
# * runs `docker compose pull` and `docker compose up -d`
|
||
#
|
||
# It asks for sudo only for the steps that need it, names them when it does,
|
||
# and never wants to be piped into sudo itself. Re-running it reconfigures in
|
||
# place: it does not regenerate secrets, does not touch an existing data root,
|
||
# and refuses a directory it did not create unless you pass --force.
|
||
#
|
||
# Verify before running, if you would rather:
|
||
#
|
||
# curl -fsSLO https://warmbly.com/install.sh
|
||
# curl -fsSLO https://warmbly.com/install.sh.sha256
|
||
# sha256sum -c install.sh.sha256
|
||
# less install.sh && sh install.sh
|
||
#
|
||
# https://docs.warmbly.com/development/install/
|
||
|
||
set -eu
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Constants
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
SCRIPT_NAME="Warmbly installer"
|
||
REPO="warmbly/warmbly"
|
||
DEFAULT_REGISTRY="ghcr.io/warmbly/warmbly"
|
||
DEFAULT_DIR="/opt/warmbly"
|
||
DOCS="https://docs.warmbly.com"
|
||
RELEASES_API="https://api.github.com/repos/${REPO}/releases"
|
||
|
||
# The marker file that says this directory is ours. Its absence in a non-empty
|
||
# directory is what --force overrides, so the installer can never take over a
|
||
# path that belongs to something else by accident.
|
||
MARKER=".warmbly-install"
|
||
|
||
# Ports the stack publishes, in the order they are checked for collisions.
|
||
# 3000 is the one that actually collides: half of self-hosted software wants it.
|
||
PORT_BACKEND=8080
|
||
PORT_WEB=5173
|
||
PORT_ADMIN=5174
|
||
PORT_TRACKING=3000
|
||
PORT_REALTIME=4000
|
||
PORT_FORMS=8090
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Answers. Every one is a flag, an environment variable and a wizard question,
|
||
# and the defaults here are exactly what the fast path installs.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
DIR="${WARMBLY_DIR:-$DEFAULT_DIR}"
|
||
HOSTNAME_ANSWER="${WARMBLY_HOST:-localhost}"
|
||
TLS="${WARMBLY_TLS:-none}"
|
||
DATA_ROOT="${WARMBLY_DATA_ROOT:-}" # empty = <dir>/data; "volumes" = docker named volumes
|
||
BLOBS="${WARMBLY_BLOBS:-filesystem}" # filesystem | s3
|
||
COMPONENTS="${WARMBLY_COMPONENTS:-full}" # core | full
|
||
VERSION="${WARMBLY_VERSION:-}" # empty = resolve the newest release
|
||
CHANNEL="${WARMBLY_CHANNEL:-stable}"
|
||
REGISTRY="${WARMBLY_IMAGE_PREFIX:-$DEFAULT_REGISTRY}"
|
||
EXTERNAL_DB="${WARMBLY_DATABASE_URL:-}"
|
||
EXTERNAL_REDIS="${WARMBLY_REDIS_URL:-}"
|
||
MAIL_MODE="${WARMBLY_MAIL:-log}" # log | smtp
|
||
REGISTRATION="${WARMBLY_REGISTRATION:-invite_only}"
|
||
UPDATE_CHECK="${WARMBLY_UPDATE_CHECK:-true}"
|
||
BOOTSTRAP_EMAIL="${WARMBLY_BOOTSTRAP_EMAIL:-}"
|
||
BOOTSTRAP_HASH="${WARMBLY_BOOTSTRAP_PASSWORD_HASH:-}"
|
||
BACKUP_DIR="${WARMBLY_BACKUP_DIR:-}" # empty = no scheduled backup
|
||
BACKUP_KEEP="${WARMBLY_BACKUP_KEEP:-14}"
|
||
BACKUP_SCHEDULE="${WARMBLY_BACKUP_SCHEDULE:-daily}"
|
||
BACKUP_KEYS="${WARMBLY_BACKUP_KEYS:-true}"
|
||
BACKUP_S3="${WARMBLY_BACKUP_S3:-}"
|
||
|
||
# Retention and sync, the data-control answers. They are written into .env as
|
||
# one WARMBLY_SETTINGS_BOOTSTRAP document, applied on first boot and editable
|
||
# in Instance > Instance settings from then on.
|
||
SYNC_BACKFILL_DAYS="${WARMBLY_SYNC_BACKFILL_DAYS:-90}"
|
||
SYNC_BACKFILL_MESSAGES="${WARMBLY_SYNC_BACKFILL_MESSAGES:-5000}"
|
||
SYNC_DAILY_MAILBOX="${WARMBLY_SYNC_DAILY_MAILBOX:-2000}"
|
||
SYNC_DAILY_ORG="${WARMBLY_SYNC_DAILY_ORG:-25000}"
|
||
RET_ENGAGEMENT="${WARMBLY_RETENTION_ENGAGEMENT_DAYS:-365}"
|
||
RET_FORMS="${WARMBLY_RETENTION_FORM_DAYS:-180}"
|
||
RET_AUDIT="${WARMBLY_RETENTION_AUDIT_DAYS:-90}"
|
||
RET_WARMUP_MAIL="${WARMBLY_RETENTION_WARMUP_MAIL_DAYS:-30}"
|
||
RET_WARMUP_EVENTS="${WARMBLY_RETENTION_WARMUP_EVENT_DAYS:-365}"
|
||
|
||
# S3 blob answers, only read when BLOBS=s3.
|
||
S3_BUCKET="${WARMBLY_S3_BUCKET:-}"
|
||
S3_ENDPOINT="${WARMBLY_S3_ENDPOINT:-}"
|
||
S3_REGION="${WARMBLY_S3_REGION:-auto}"
|
||
S3_KEY="${WARMBLY_S3_ACCESS_KEY_ID:-}"
|
||
S3_SECRET="${WARMBLY_S3_SECRET_ACCESS_KEY:-}"
|
||
|
||
# SMTP answers, only read when MAIL_MODE=smtp.
|
||
SMTP_HOST=""; SMTP_PORT=""; SMTP_USER=""; SMTP_PASS=""; SMTP_SECURITY="starttls"
|
||
SMTP_FROM=""
|
||
|
||
# Reverse-proxy answer, only read when TLS=proxy.
|
||
PROXY_CIDRS="${WARMBLY_TRUSTED_PROXIES:-172.16.0.0/12,10.0.0.0/8,192.168.0.0/16}"
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Modes
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
MODE=install # install | uninstall
|
||
WIZARD=0
|
||
DEMO=0
|
||
ASSUME_YES=0
|
||
DRY_RUN=0
|
||
PRINT_ENV=0
|
||
FORCE=0
|
||
PURGE_DATA=0
|
||
USE_COLOR=1
|
||
# Appending is the default. The wizard reads as a transcript, nothing above it
|
||
# is touched, and there is no way for a redraw to land on top of something
|
||
# else. --clear opts into the full-screen version.
|
||
USE_CLEAR=0
|
||
INTERACTIVE=0
|
||
TTY=/dev/tty
|
||
|
||
# Filled in as the run proceeds.
|
||
RESOLVED_TAG=""
|
||
COMPOSE="docker compose"
|
||
SUDO=""
|
||
EXISTING=0
|
||
|
||
# The palette starts empty so that anything reachable before setup_term (--help,
|
||
# an unknown flag) still prints under set -u. setup_term fills it in.
|
||
ESC=""; R=""; B=""; DIM=""; SKY=""; GREEN=""; AMBER=""
|
||
RED=""; GREY=""; WHITE=""; HIDE=""; SHOW=""
|
||
COLS=80; WIDTH=76
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Terminal: colour, cursor, and the drawing primitives everything else uses.
|
||
#
|
||
# Every escape sequence goes through these, so NO_COLOR, a pipe, a dumb
|
||
# terminal and --no-color all degrade to plain text in one place instead of
|
||
# leaking half-rendered ANSI into a log file.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
setup_term() {
|
||
# A wizard needs a keyboard. Piped into sh, stdin is the script itself, so
|
||
# the terminal is reopened explicitly; without one, only the answered
|
||
# (flag or environment) path can run.
|
||
if [ -t 0 ] && [ -t 1 ]; then
|
||
INTERACTIVE=1
|
||
TTY=/dev/stdin
|
||
elif [ -c /dev/tty ] && ( exec 9<>/dev/tty ) 2>/dev/null && [ -t 1 ]; then
|
||
INTERACTIVE=1
|
||
TTY=/dev/tty
|
||
fi
|
||
|
||
if [ -n "${NO_COLOR:-}" ] || [ "${TERM:-dumb}" = "dumb" ] || [ ! -t 1 ]; then
|
||
USE_COLOR=0
|
||
fi
|
||
if [ "$USE_COLOR" = 1 ]; then
|
||
ESC=$(printf '\033')
|
||
R="${ESC}[0m"; B="${ESC}[1m"; DIM="${ESC}[2m"
|
||
SKY="${ESC}[38;5;39m"
|
||
GREEN="${ESC}[38;5;42m"; AMBER="${ESC}[38;5;214m"; RED="${ESC}[38;5;203m"
|
||
GREY="${ESC}[38;5;245m"; WHITE="${ESC}[38;5;255m"
|
||
HIDE="${ESC}[?25l"; SHOW="${ESC}[?25h"
|
||
else
|
||
ESC=""; R=""; B=""; DIM=""; SKY=""; GREEN=""; AMBER=""
|
||
RED=""; GREY=""; WHITE=""; HIDE=""; SHOW=""
|
||
USE_CLEAR=0
|
||
fi
|
||
|
||
# Clearing a terminal that is not ours to clear is never right.
|
||
[ "$INTERACTIVE" = 1 ] || USE_CLEAR=0
|
||
|
||
# Width. Everything drawn inside a redraw loop is fitted to this, so
|
||
# getting it wrong is what makes a menu draw over itself. Three sources,
|
||
# because tput is missing on a minimal image and stty needs the terminal:
|
||
# an exported COLUMNS wins, since that is how a caller says so explicitly.
|
||
COLS=""
|
||
case "${COLUMNS:-}" in ''|*[!0-9]*) ;; *) COLS=$COLUMNS ;; esac
|
||
if [ -z "$COLS" ] && have tput; then
|
||
COLS=$(tput cols 2>/dev/null || true)
|
||
fi
|
||
if [ -z "$COLS" ] && [ "$INTERACTIVE" = 1 ]; then
|
||
COLS=$(stty size <"$TTY" 2>/dev/null | cut -d' ' -f2 || true)
|
||
fi
|
||
case "$COLS" in ''|*[!0-9]*) COLS=80 ;; esac
|
||
# Clamped: a 300 column terminal should not get a 300 column rule, and
|
||
# below the floor the boxes stop being boxes.
|
||
[ "$COLS" -lt 44 ] && COLS=44
|
||
[ "$COLS" -gt 92 ] && COLS=92
|
||
WIDTH=$((COLS - 4))
|
||
}
|
||
|
||
say() { printf '%s\n' "$*"; }
|
||
out() { printf '%b\n' "$*"; }
|
||
outn() { printf '%b' "$*"; }
|
||
|
||
# Restore the cursor whatever happens: a Ctrl-C inside a spinner would
|
||
# otherwise leave the operator's terminal without one.
|
||
cleanup_term() {
|
||
[ "$USE_COLOR" = 1 ] && printf '%b' "$SHOW"
|
||
stty_sane
|
||
}
|
||
stty_sane() {
|
||
[ "$INTERACTIVE" = 1 ] || return 0
|
||
stty sane <"$TTY" 2>/dev/null || true
|
||
}
|
||
trap 'cleanup_term' EXIT
|
||
trap 'cleanup_term; printf "\n"; die "cancelled"' INT
|
||
|
||
# clear_screen is opt-in (--clear). Wiping someone's terminal is a thing to be
|
||
# asked for, not assumed, and a clear that the terminal declines leaves the
|
||
# wizard drawing over whatever was already there. ESC[3J is deliberately not
|
||
# sent: it erases the scrollback buffer, and the conversation above this
|
||
# command is not ours to delete.
|
||
clear_screen() {
|
||
[ "$USE_CLEAR" = 1 ] || return 0
|
||
printf '%b' "${ESC}[H${ESC}[2J"
|
||
}
|
||
|
||
repeat_char() {
|
||
# $1 char, $2 count. printf pads with spaces and sed swaps them for the
|
||
# character: no loop and no fork per character, which is what keeps the
|
||
# redraws smooth. sed rather than tr because tr maps byte to byte and every
|
||
# box-drawing character here is three bytes of UTF-8.
|
||
_n=$2
|
||
[ "$_n" -lt 1 ] && { printf ''; return; }
|
||
printf "%${_n}s" "" | sed "s/ /$1/g"
|
||
}
|
||
|
||
rule() { out "${DIM}$(repeat_char '─' "$WIDTH")${R}"; }
|
||
|
||
# fit truncates plain text to n columns.
|
||
#
|
||
# It is what keeps the in-place redraws honest: a line longer than the terminal
|
||
# wraps onto a second physical row, and every cursor-up in this script counts
|
||
# LOGICAL rows, so one long option hint is enough to make a menu redraw over
|
||
# itself and over whatever was on screen before it. Applied to everything drawn
|
||
# inside a loop; prose printed once is free to wrap.
|
||
fit() {
|
||
_t=$1; _w=$2
|
||
[ "$_w" -lt 12 ] && _w=12
|
||
if [ "${#_t}" -le "$_w" ]; then
|
||
printf '%s' "$_t"
|
||
return 0
|
||
fi
|
||
printf '%.*s...' "$((_w - 3))" "$_t"
|
||
}
|
||
|
||
# A rounded box. Content comes on stdin, one line per row, already coloured.
|
||
# The border colour is $1.
|
||
box() {
|
||
_c="${1:-$DIM}"
|
||
out "${_c}╭$(repeat_char '─' $((WIDTH - 2)))╮${R}"
|
||
while IFS= read -r _line; do
|
||
_plain=$(strip_ansi "$_line")
|
||
# A content line wider than the box turns the border into a ragged
|
||
# edge. Truncating costs the line its colour, which is a better trade
|
||
# than a box that is not one.
|
||
if [ "${#_plain}" -gt $((WIDTH - 4)) ]; then
|
||
_line=$(fit "$_plain" $((WIDTH - 4)))
|
||
_plain=$_line
|
||
fi
|
||
_pad=$((WIDTH - 4 - ${#_plain}))
|
||
[ "$_pad" -lt 0 ] && _pad=0
|
||
printf '%b %s%s %b\n' "${_c}│${R}" "$_line" "$(repeat_char ' ' "$_pad")" "${_c}│${R}"
|
||
done
|
||
out "${_c}╰$(repeat_char '─' $((WIDTH - 2)))╯${R}"
|
||
}
|
||
|
||
# strip_ansi is what makes the borders line up: the padding is computed on the
|
||
# printable text, not on the byte length of a coloured string. ${#} counts
|
||
# bytes in a POSIX shell, so box CONTENT is kept ASCII; the border itself is
|
||
# printed rather than measured, and may be anything.
|
||
strip_ansi() {
|
||
printf '%s' "$1" | sed "s/$(printf '\033')\[[0-9;]*m//g"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# The wordmark. Drawn once at the top of the run, one row at a time so it
|
||
# arrives rather than appears. Falls back to a single bold line when the
|
||
# terminal is not ours to animate.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
banner() {
|
||
if [ "$USE_COLOR" = 0 ] || [ "$COLS" -lt 60 ]; then
|
||
# The block wordmark is 56 columns wide, so on anything narrower it
|
||
# wraps into noise. One line says the same thing.
|
||
say ""
|
||
out " ${B}${SKY}WARMBLY${R} ${DIM}installer${R}"
|
||
say ""
|
||
return
|
||
fi
|
||
printf '%b' "$HIDE"
|
||
say ""
|
||
_i=0
|
||
printf '%s\n' \
|
||
"██ ██ █████ ██████ ███ ███ ██████ ██ ██ ██" \
|
||
"██ █ ██ ██ ██ ██ ██ ████ ████ ██ ██ ██ ██ ██" \
|
||
"██ ███ ██ ███████ ██████ ██ ████ ██ ██████ ██ ███ " \
|
||
"████ ████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ " \
|
||
" ██ ██ ██ ██ ██ ██ ██ ██ ██████ ██████ ██ " \
|
||
| while IFS= read -r _row; do
|
||
_i=$((_i + 1))
|
||
case "$_i" in
|
||
1|2) _tone="${ESC}[38;5;33m" ;;
|
||
3) _tone="${ESC}[38;5;39m" ;;
|
||
*) _tone="${ESC}[38;5;74m" ;;
|
||
esac
|
||
printf ' %b%s%b\n' "$_tone" "$_row" "$R"
|
||
nap 0.03
|
||
done
|
||
printf ' %b%s%b\n\n' "$DIM" "email warmup and cold outreach, on your own machine" "$R"
|
||
printf '%b' "$SHOW"
|
||
}
|
||
|
||
# nap sleeps a fraction of a second where the shell can, and not at all where
|
||
# it cannot. Animation is a nicety; a busybox sleep that rejects "0.03" must
|
||
# not turn into an error under set -e.
|
||
nap() {
|
||
[ "$USE_COLOR" = 1 ] || return 0
|
||
sleep "$1" 2>/dev/null || true
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Messages
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
info() { out " ${SKY}·${R} $*"; }
|
||
ok() { out " ${GREEN}✓${R} $*"; }
|
||
warn() { out " ${AMBER}!${R} $*"; }
|
||
# note is the explanatory prose under a question. It is wrapped to the
|
||
# terminal rather than hand-wrapped to 72 columns, because the hand-wrapped
|
||
# version is ragged on anything narrower and this is the only text in the
|
||
# script long enough to care.
|
||
note() {
|
||
if [ "$COLS" -ge 78 ] || ! have fold; then
|
||
out " ${DIM}$*${R}"
|
||
return 0
|
||
fi
|
||
printf '%s\n' "$*" | fold -s -w $((COLS - 6)) | while IFS= read -r _l; do
|
||
out " ${DIM}${_l}${R}"
|
||
done
|
||
}
|
||
step() { out "\n ${B}$*${R}"; }
|
||
|
||
die() {
|
||
cleanup_term
|
||
out "\n ${RED}✗${R} ${B}$*${R}\n" >&2
|
||
exit 1
|
||
}
|
||
|
||
# fail_with prints the error and, under it, what to do about it. An installer
|
||
# that stops without a next step is a support ticket.
|
||
fail_with() {
|
||
_msg=$1; shift
|
||
cleanup_term
|
||
out "\n ${RED}✗${R} ${B}${_msg}${R}" >&2
|
||
for _l in "$@"; do out " ${DIM}${_l}${R}" >&2; done
|
||
out "" >&2
|
||
exit 1
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# The stepper: where the wizard is, drawn at the top of every question.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
STEP_TITLES="Where it lives|How it is reached|Where the data sits|Keys and secrets|What is kept|Backups|Who gets in|Footprint|Review"
|
||
STEP_TOTAL=9
|
||
STEP_CURRENT=0
|
||
|
||
stepper() {
|
||
STEP_CURRENT=$1
|
||
if [ "$USE_CLEAR" = 1 ]; then
|
||
clear_screen
|
||
else
|
||
# Appending, so the steps need their own separator or they run into
|
||
# each other and into whatever was on screen before the installer.
|
||
say ""
|
||
rule
|
||
fi
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
say ""
|
||
say "Step ${STEP_CURRENT} of ${STEP_TOTAL}: $(step_title "$STEP_CURRENT")"
|
||
say ""
|
||
return
|
||
fi
|
||
say ""
|
||
_line=" "
|
||
_i=1
|
||
while [ "$_i" -le "$STEP_TOTAL" ]; do
|
||
if [ "$_i" -lt "$STEP_CURRENT" ]; then
|
||
_line="${_line}${GREEN}●${R}"
|
||
elif [ "$_i" -eq "$STEP_CURRENT" ]; then
|
||
_line="${_line}${SKY}◉${R}"
|
||
else
|
||
_line="${_line}${DIM}○${R}"
|
||
fi
|
||
[ "$_i" -lt "$STEP_TOTAL" ] && _line="${_line}${DIM}──${R}"
|
||
_i=$((_i + 1))
|
||
done
|
||
out "$_line ${DIM}step ${STEP_CURRENT}/${STEP_TOTAL}${R}"
|
||
out " ${B}${WHITE}$(step_title "$STEP_CURRENT")${R}"
|
||
say ""
|
||
}
|
||
|
||
step_title() {
|
||
printf '%s' "$STEP_TITLES" | cut -d'|' -f"$1"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Spinner. Runs a command in the background and animates until it finishes,
|
||
# then leaves one settled line behind: a tick, or a cross and the log.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
SPIN_FRAMES="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||
|
||
# spin <label> <command...>
|
||
spin() {
|
||
_label=$1; shift
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
say " ... $_label"
|
||
if "$@" >"$LOGFILE" 2>&1; then say " ok $_label"; return 0; fi
|
||
return 1
|
||
fi
|
||
|
||
"$@" >"$LOGFILE" 2>&1 &
|
||
_pid=$!
|
||
printf '%b' "$HIDE"
|
||
_f=1
|
||
while kill -0 "$_pid" 2>/dev/null; do
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
printf '\r %b%s%b %s' "$SKY" "$_frame" "$R" "$_label"
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.08
|
||
done
|
||
wait "$_pid" 2>/dev/null && _rc=0 || _rc=$?
|
||
printf '\r%b' "${ESC}[2K"
|
||
printf '%b' "$SHOW"
|
||
if [ "$_rc" = 0 ]; then
|
||
ok "$_label"
|
||
else
|
||
out " ${RED}✗${R} $_label"
|
||
fi
|
||
return "$_rc"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Progress bar. Used for the pull and for the health wait, where there is a
|
||
# real fraction to show rather than an indeterminate spin.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
# bar <done> <total> <width>
|
||
bar() {
|
||
_done=$1; _total=$2; _w=$3
|
||
[ "$_total" -lt 1 ] && _total=1
|
||
_fill=$((_done * _w / _total))
|
||
[ "$_fill" -gt "$_w" ] && _fill=$_w
|
||
printf '%b%s%b%s%b' "$SKY" "$(repeat_char '━' "$_fill")" "$DIM" "$(repeat_char '━' $((_w - _fill)))" "$R"
|
||
}
|
||
|
||
elapsed_since() {
|
||
_s=$(( $(now_s) - $1 ))
|
||
printf '%dm%02ds' $((_s / 60)) $((_s % 60))
|
||
}
|
||
now_s() { date +%s; }
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Input
|
||
#
|
||
# The wizard reads the keyboard, not stdin: piped into sh, stdin IS the
|
||
# script. Everything below reads $TTY, and every one of them has an answered
|
||
# path so that --yes and the environment variables reach the same code.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
# A terminal that cannot be put into raw mode is not fatal: the menus still
|
||
# draw, they just read a whole line instead of a keypress.
|
||
raw_on() {
|
||
[ "$INTERACTIVE" = 1 ] || return 0
|
||
stty raw -echo <"$TTY" 2>/dev/null || true
|
||
}
|
||
raw_off() {
|
||
[ "$INTERACTIVE" = 1 ] || return 0
|
||
stty -raw echo <"$TTY" 2>/dev/null || true
|
||
}
|
||
|
||
# read_key leaves the decimal code of one keypress in KEY. Arrow keys arrive
|
||
# as three bytes; the escape prefix is consumed here so callers only ever see
|
||
# UP, DOWN, ENTER and the printable code.
|
||
read_key() {
|
||
KEY=$(dd bs=1 count=1 2>/dev/null <"$TTY" | od -An -tu1 | tr -d ' \n')
|
||
[ -n "$KEY" ] || KEY=0
|
||
if [ "$KEY" = 27 ]; then
|
||
_b=$(dd bs=1 count=1 2>/dev/null <"$TTY" | od -An -tu1 | tr -d ' \n')
|
||
if [ "$_b" = 91 ]; then
|
||
_c=$(dd bs=1 count=1 2>/dev/null <"$TTY" | od -An -tu1 | tr -d ' \n')
|
||
case "$_c" in
|
||
65) KEY=UP ;;
|
||
66) KEY=DOWN ;;
|
||
*) KEY=OTHER ;;
|
||
esac
|
||
else
|
||
KEY=ESC
|
||
fi
|
||
fi
|
||
# Normalised here rather than in each caller, because every consumer of
|
||
# read_key is a menu and they should all answer to the same keys.
|
||
case "$KEY" in
|
||
10|13) KEY=ENTER ;;
|
||
32|108) KEY=SELECT ;; # space, l
|
||
107|16) KEY=UP ;; # k, ctrl-p
|
||
106|14) KEY=DOWN ;; # j, ctrl-n
|
||
21) KEY=HALFUP ;; # ctrl-u
|
||
4) KEY=HALFDOWN ;; # ctrl-d
|
||
103) KEY=GKEY ;; # g, as in gg
|
||
71) KEY=END ;; # G
|
||
113) KEY=QUIT ;; # q
|
||
3) KEY=INT ;; # ctrl-c
|
||
esac
|
||
}
|
||
|
||
# menu_hint is the key legend under every menu. It is one rendered row, so it
|
||
# is part of the redraw arithmetic below rather than something printed once.
|
||
MENU_HINT=""
|
||
build_menu_hint() {
|
||
# Two forms, because the long one is 64 columns before any indent and this
|
||
# row is inside the redraw: if it wraps, the menu draws over itself.
|
||
if [ "$COLS" -ge 78 ]; then
|
||
MENU_HINT="${DIM}↑↓ ${WHITE}jk${DIM} move ${WHITE}gg${DIM}/${WHITE}G${DIM} first, last ${WHITE}1-9${DIM} jump ${WHITE}enter${DIM} select ${WHITE}q${DIM} quit${R}"
|
||
else
|
||
MENU_HINT="${DIM}${WHITE}jk${DIM} move ${WHITE}1-9${DIM} jump ${WHITE}enter${DIM} select ${WHITE}q${DIM} quit${R}"
|
||
fi
|
||
}
|
||
|
||
# choose <default-index> <"label|hint"> ... -> CHOICE (1-based)
|
||
#
|
||
# Arrows, j/k, ctrl-n/ctrl-p, ctrl-d/ctrl-u, gg and G all move; a digit jumps
|
||
# straight to an option; enter, space or l takes it; q or escape leaves.
|
||
# Answered runs take the default without drawing anything.
|
||
choose() {
|
||
_default=$1; shift
|
||
CHOICE=$_default
|
||
_count=$#
|
||
# --yes means every unanswered question takes its default, and a menu is a
|
||
# question. Without this the flag only silenced the confirmations, which is
|
||
# not what it says it does. The choice is still echoed: the question above
|
||
# it was printed, and a header with nothing under it reads as a bug.
|
||
if [ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ]; then
|
||
_i=1
|
||
for _opt in "$@"; do
|
||
if [ "$_i" = "$CHOICE" ]; then
|
||
out " ${GREEN}✓${R} ${WHITE}$(fit "${_opt%%|*}" $((COLS - 6)))${R}"
|
||
say ""
|
||
fi
|
||
_i=$((_i + 1))
|
||
done
|
||
return 0
|
||
fi
|
||
build_menu_hint
|
||
# Two rows per option, plus the key legend. Every redraw moves up by this,
|
||
# so the legend has to be counted rather than printed once above.
|
||
_rows=$((_count * 2 + 1))
|
||
_half=$(((_count + 1) / 2))
|
||
|
||
_sel=$_default
|
||
_g=0
|
||
printf '%b' "$HIDE"
|
||
_first=1
|
||
while :; do
|
||
[ "$_first" = 1 ] || printf '%b' "${ESC}[${_rows}A"
|
||
_first=0
|
||
_i=1
|
||
for _opt in "$@"; do
|
||
_label=$(fit "${_opt%%|*}" $((COLS - 6)))
|
||
_hint=${_opt#*|}
|
||
[ "$_hint" = "$_opt" ] && _hint=""
|
||
[ -n "$_hint" ] && _hint=$(fit "$_hint" $((COLS - 8)))
|
||
printf '%b' "${ESC}[2K"
|
||
if [ "$_i" = "$_sel" ]; then
|
||
out " ${SKY}❯${R} ${B}${WHITE}${_label}${R}"
|
||
else
|
||
out " ${GREY}${_label}${R}"
|
||
fi
|
||
printf '%b' "${ESC}[2K"
|
||
if [ -n "$_hint" ]; then
|
||
out " ${DIM}${_hint}${R}"
|
||
else
|
||
say ""
|
||
fi
|
||
_i=$((_i + 1))
|
||
done
|
||
printf '%b' "${ESC}[2K"
|
||
out " $MENU_HINT"
|
||
|
||
raw_on
|
||
read_key
|
||
raw_off
|
||
# gg is the only two-key sequence, so any other key ends a pending g.
|
||
[ "$KEY" = "GKEY" ] || _g=0
|
||
case "$KEY" in
|
||
UP) _sel=$((_sel - 1)); [ "$_sel" -lt 1 ] && _sel=$_count ;;
|
||
DOWN) _sel=$((_sel + 1)); [ "$_sel" -gt "$_count" ] && _sel=1 ;;
|
||
HALFUP) _sel=$((_sel - _half)); [ "$_sel" -lt 1 ] && _sel=1 ;;
|
||
HALFDOWN) _sel=$((_sel + _half)); [ "$_sel" -gt "$_count" ] && _sel=$_count ;;
|
||
GKEY) if [ "$_g" = 1 ]; then _sel=1; _g=0; else _g=1; fi ;;
|
||
END) _sel=$_count ;;
|
||
ENTER|SELECT) break ;;
|
||
QUIT|ESC|INT) printf '%b' "$SHOW"; die "cancelled" ;;
|
||
*)
|
||
# A digit picks that option outright: everyone tries it.
|
||
case "$KEY" in
|
||
49|50|51|52|53|54|55|56|57)
|
||
_n=$((KEY - 48))
|
||
[ "$_n" -le "$_count" ] && { _sel=$_n; break; }
|
||
;;
|
||
esac
|
||
;;
|
||
esac
|
||
done
|
||
printf '%b' "$SHOW"
|
||
CHOICE=$_sel
|
||
|
||
# Collapse the menu to the one line that says what was chosen, so the
|
||
# wizard reads back as a list of decisions rather than a wall of options.
|
||
_chosen=""
|
||
_i=1
|
||
for _opt in "$@"; do
|
||
[ "$_i" = "$CHOICE" ] && _chosen=${_opt%%|*}
|
||
_i=$((_i + 1))
|
||
done
|
||
printf '%b' "${ESC}[${_rows}A"
|
||
_i=0
|
||
while [ "$_i" -lt "$_rows" ]; do
|
||
printf '%b
|
||
' "${ESC}[2K"
|
||
_i=$((_i + 1))
|
||
done
|
||
printf '%b' "${ESC}[${_rows}A"
|
||
out " ${GREEN}✓${R} ${WHITE}$(fit "$_chosen" $((COLS - 6)))${R}"
|
||
say ""
|
||
}
|
||
|
||
|
||
# ask <prompt> <default> -> ANSWER. An empty answer takes the default, which
|
||
# is shown, so pressing enter through the whole wizard is the fast path.
|
||
ask() {
|
||
_prompt=$1; _default=${2:-}
|
||
ANSWER=$_default
|
||
[ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ] && return 0
|
||
if [ -n "$_default" ]; then
|
||
printf ' %b%s%b %b[%s]%b ' "$WHITE" "$_prompt" "$R" "$DIM" "$_default" "$R"
|
||
else
|
||
printf ' %b%s%b %b(optional)%b ' "$WHITE" "$_prompt" "$R" "$DIM" "$R"
|
||
fi
|
||
IFS= read -r _in <"$TTY" || _in=""
|
||
# An `if`, not `[ -n ] && ...`: the latter would be this function's exit
|
||
# status, so pressing enter to take the default would look like a failure
|
||
# to set -e and end the run.
|
||
if [ -n "$_in" ]; then ANSWER=$_in; fi
|
||
}
|
||
|
||
# ask_secret is ask with the echo off, for an SMTP password or an S3 key.
|
||
ask_secret() {
|
||
_prompt=$1
|
||
ANSWER=""
|
||
[ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ] && return 0
|
||
printf ' %b%s%b ' "$WHITE" "$_prompt" "$R"
|
||
stty -echo <"$TTY" 2>/dev/null || true
|
||
IFS= read -r ANSWER <"$TTY" || ANSWER=""
|
||
stty echo <"$TTY" 2>/dev/null || true
|
||
say ""
|
||
}
|
||
|
||
# ask_number keeps asking until the answer is a whole number in range, and
|
||
# says what is wrong inline instead of clamping something the operator meant.
|
||
ask_number() {
|
||
_prompt=$1; _default=$2; _min=$3; _max=$4
|
||
while :; do
|
||
ask "$_prompt" "$_default"
|
||
case "$ANSWER" in
|
||
''|*[!0-9]*) ;;
|
||
*)
|
||
if [ "$ANSWER" -ge "$_min" ] && [ "$ANSWER" -le "$_max" ]; then
|
||
return 0
|
||
fi
|
||
;;
|
||
esac
|
||
[ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ] && { ANSWER=$_default; return 0; }
|
||
out " ${RED}Enter a whole number between ${_min} and ${_max}.${R}"
|
||
done
|
||
}
|
||
|
||
# press_enter holds the screen until the operator is ready.
|
||
#
|
||
# It exists because the wizard clears between steps: without it the banner, the
|
||
# demo notice and anything else above the first question are wiped a fraction
|
||
# of a second after they are drawn, which reads as the whole thing flashing
|
||
# past. Any key continues; q or escape leaves.
|
||
press_enter() {
|
||
_label=${1:-Press enter to begin}
|
||
[ "$INTERACTIVE" = 1 ] || return 0
|
||
[ "$ASSUME_YES" = 1 ] && return 0
|
||
printf ' %b%s%b' "$DIM" "$_label" "$R"
|
||
raw_on
|
||
read_key
|
||
raw_off
|
||
case "$KEY" in
|
||
QUIT|ESC|INT) say ""; die "nothing was changed" ;;
|
||
esac
|
||
clear_line
|
||
return 0
|
||
}
|
||
|
||
# offer_wizard asks a fresh install whether to configure itself, when nothing
|
||
# already answered that.
|
||
#
|
||
# `curl ... | sh` with no flags used to go straight to defaults, which means
|
||
# localhost URLs and a tracking host on localhost: an instance that installs,
|
||
# starts, and sends campaign mail whose links and opt-out address nobody
|
||
# outside that machine can open. It is a terminal in front of a person, so it
|
||
# can just ask. Declining is a real answer and says what it costs.
|
||
#
|
||
# Skipped whenever something else already decided: --wizard, --assume-yes, a
|
||
# non-interactive shell, a re-run over an existing install (its .env is
|
||
# adopted), and any run that writes nothing.
|
||
offer_wizard() {
|
||
[ "$WIZARD" = 0 ] || return 0
|
||
[ "$INTERACTIVE" = 1 ] || return 0
|
||
[ "$ASSUME_YES" = 0 ] || return 0
|
||
[ "$EXISTING" = 0 ] || return 0
|
||
[ "$DEMO" = 0 ] || return 0
|
||
[ "$DRY_RUN" = 0 ] || return 0
|
||
[ "$PRINT_ENV" = 0 ] || return 0
|
||
# An operator who named a host has already answered the question that
|
||
# matters, by flag or by WARMBLY_HOST.
|
||
[ -z "${WARMBLY_HOST:-}" ] || return 0
|
||
[ -z "${HOST_SET:-}" ] || return 0
|
||
|
||
say ""
|
||
note "Nothing has told this install where it will be reached, so it would use"
|
||
note "localhost: a dashboard, a tracking host and an unsubscribe address that"
|
||
note "only work from this machine. Campaign mail carries those addresses."
|
||
say ""
|
||
if confirm "Answer a few questions to set it up properly?" yes; then
|
||
WIZARD=1
|
||
return 0
|
||
fi
|
||
say ""
|
||
warn "Going with the defaults. Every address this instance builds is on"
|
||
note "localhost, so campaign mail would carry a pixel, links and an opt-out"
|
||
note "no recipient can open. Fine to look around; edit .env or re-run with"
|
||
note "--wizard before you send anything real."
|
||
say ""
|
||
return 0
|
||
}
|
||
|
||
# confirm <question> <default yes|no> -> 0 when yes
|
||
confirm() {
|
||
_q=$1; _def=${2:-yes}
|
||
if [ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ]; then
|
||
[ "$_def" = "yes" ]
|
||
return
|
||
fi
|
||
if [ "$_def" = "yes" ]; then _hint="Y/n"; else _hint="y/N"; fi
|
||
printf ' %b%s%b %b[%s]%b ' "$WHITE" "$_q" "$R" "$DIM" "$_hint" "$R"
|
||
IFS= read -r _a <"$TTY" || _a=""
|
||
case "$_a" in
|
||
[yY]*) return 0 ;;
|
||
[nN]*) return 1 ;;
|
||
*) [ "$_def" = "yes" ] ;;
|
||
esac
|
||
}
|
||
|
||
# confirm_phrase makes the operator type something specific. Reserved for the
|
||
# two moments where a wrong keystroke is unrecoverable: the encryption keys,
|
||
# and --purge-data.
|
||
confirm_phrase() {
|
||
_q=$1; _phrase=$2
|
||
[ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ] && return 0
|
||
printf ' %b%s%b ' "$WHITE" "$_q" "$R"
|
||
IFS= read -r _a <"$TTY" || _a=""
|
||
[ "$_a" = "$_phrase" ]
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Host checks
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
have() { command -v "$1" >/dev/null 2>&1; }
|
||
|
||
detect_platform() {
|
||
OS=$(uname -s 2>/dev/null || echo unknown)
|
||
ARCH=$(uname -m 2>/dev/null || echo unknown)
|
||
case "$ARCH" in
|
||
x86_64|amd64) ARCH=amd64 ;;
|
||
aarch64|arm64) ARCH=arm64 ;;
|
||
esac
|
||
case "$OS" in
|
||
Linux|Darwin) ;;
|
||
*) fail_with "Warmbly's release images are Linux containers and $OS is not supported by this installer." \
|
||
"Docker Desktop on Windows runs them: install it, then run this script inside WSL." \
|
||
"$DOCS/development/deployment-guide/" ;;
|
||
esac
|
||
case "$ARCH" in
|
||
amd64|arm64) ;;
|
||
*) fail_with "The release images are published for amd64 and arm64, and this machine is $ARCH." \
|
||
"Build from source instead: $DOCS/development/deployment-guide/" ;;
|
||
esac
|
||
}
|
||
|
||
# need_sudo decides how privileged steps run. The script is never meant to be
|
||
# piped into sudo: it asks for elevation only where it needs it, and says which
|
||
# step wanted it.
|
||
need_sudo() {
|
||
if [ "$(id -u)" = 0 ]; then
|
||
SUDO=""
|
||
return 0
|
||
fi
|
||
if have sudo; then
|
||
SUDO="sudo"
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
check_docker() {
|
||
if have docker && docker info >/dev/null 2>&1; then
|
||
if docker compose version >/dev/null 2>&1; then
|
||
COMPOSE="docker compose"
|
||
return 0
|
||
fi
|
||
if have docker-compose; then
|
||
COMPOSE="docker-compose"
|
||
warn "Using the standalone docker-compose. Compose v2 (docker compose) is what this is tested against."
|
||
return 0
|
||
fi
|
||
fail_with "Docker is running but the Compose plugin is missing." \
|
||
"Install it: https://docs.docker.com/compose/install/"
|
||
fi
|
||
|
||
if have docker; then
|
||
fail_with "Docker is installed but not answering." \
|
||
"The daemon is probably not running, or your user is not in the docker group." \
|
||
"Try: sudo systemctl start docker" \
|
||
"and: sudo usermod -aG docker \$USER (then log out and back in)"
|
||
fi
|
||
|
||
offer_docker
|
||
}
|
||
|
||
# offer_docker installs Docker only after saying so and being told yes. It runs
|
||
# the official convenience script, which is the same one Docker documents.
|
||
offer_docker() {
|
||
if [ "$OS" = "Darwin" ]; then
|
||
fail_with "Docker is not installed." \
|
||
"On macOS, install Docker Desktop and start it, then run this again:" \
|
||
"https://docs.docker.com/desktop/install/mac-install/"
|
||
fi
|
||
say ""
|
||
out " ${AMBER}!${R} ${B}Docker is not installed on this machine.${R}"
|
||
note "Warmbly runs as containers, so it is a hard requirement."
|
||
note "The installer can run Docker's own convenience script for you:"
|
||
note " curl -fsSL https://get.docker.com | sudo sh"
|
||
say ""
|
||
if ! confirm "Install Docker now?" yes; then
|
||
fail_with "Docker is required." "Install it and run this again: https://docs.docker.com/engine/install/"
|
||
fi
|
||
need_sudo || fail_with "Installing Docker needs root and sudo is not available." \
|
||
"Run this script as root, or install Docker yourself first."
|
||
info "Installing Docker (this is the only step that uses sudo)"
|
||
curl -fsSL https://get.docker.com -o "$TMPDIR_RUN/get-docker.sh" \
|
||
|| fail_with "Could not download Docker's install script."
|
||
if ! spin "Installing Docker" $SUDO sh "$TMPDIR_RUN/get-docker.sh"; then
|
||
show_log
|
||
fail_with "Docker's installer did not finish." "Install it by hand and run this again."
|
||
fi
|
||
if [ -n "$SUDO" ]; then
|
||
$SUDO usermod -aG docker "$(id -un)" 2>/dev/null || true
|
||
note "Your user was added to the docker group. It takes effect at your next login;"
|
||
note "this run keeps using sudo for docker."
|
||
DOCKER_NEEDS_SUDO=1
|
||
fi
|
||
have docker || fail_with "Docker still is not on PATH after installing."
|
||
}
|
||
|
||
# docker_cmd runs docker with sudo when the current session is not yet in the
|
||
# docker group, which is the state right after the group was added.
|
||
DOCKER_NEEDS_SUDO=0
|
||
dockerc() {
|
||
if [ "$DOCKER_NEEDS_SUDO" = 1 ]; then
|
||
$SUDO docker "$@"
|
||
else
|
||
docker "$@"
|
||
fi
|
||
}
|
||
composec() {
|
||
if [ "$DOCKER_NEEDS_SUDO" = 1 ]; then
|
||
$SUDO docker compose "$@"
|
||
else
|
||
# shellcheck disable=SC2086
|
||
$COMPOSE "$@"
|
||
fi
|
||
}
|
||
|
||
# port_busy answers whether something already listens on a port, using
|
||
# whichever of the three usual tools this host happens to have.
|
||
port_busy() {
|
||
_p=$1
|
||
if have ss; then
|
||
ss -ltn 2>/dev/null | grep -qE "[:.]${_p}[[:space:]]" && return 0
|
||
elif have netstat; then
|
||
netstat -ltn 2>/dev/null | grep -qE "[:.]${_p}[[:space:]]" && return 0
|
||
elif have lsof; then
|
||
lsof -iTCP:"$_p" -sTCP:LISTEN >/dev/null 2>&1 && return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
check_ports() {
|
||
_busy=""
|
||
for _p in $1; do
|
||
port_busy "$_p" && _busy="$_busy $_p"
|
||
done
|
||
printf '%s' "${_busy# }"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Secrets
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
gen_hex() {
|
||
if have openssl; then openssl rand -hex "$1"; return; fi
|
||
od -An -tx1 -N"$1" /dev/urandom | tr -d ' \n'
|
||
}
|
||
gen_b64() {
|
||
if have openssl; then openssl rand -base64 "$1" | tr -d '\n'; return; fi
|
||
head -c "$1" /dev/urandom | base64 | tr -d '\n'
|
||
}
|
||
|
||
require_randomness() {
|
||
if ! have openssl && [ ! -r /dev/urandom ]; then
|
||
fail_with "Neither openssl nor /dev/urandom is available, so secrets cannot be generated." \
|
||
"Install openssl and run this again. Warmbly will not start with guessable keys."
|
||
fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Releases
|
||
#
|
||
# The tag is resolved ONCE and written into .env, so every later
|
||
# `docker compose up` in the install directory brings up the same version.
|
||
# The default is never a moving tag.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
resolve_version() {
|
||
if [ -n "$VERSION" ]; then
|
||
RESOLVED_TAG=$VERSION
|
||
return 0
|
||
fi
|
||
_url="$RELEASES_API/latest"
|
||
[ "$CHANNEL" = "dev" ] && _url="$RELEASES_API?per_page=10"
|
||
_json=$(curl -fsSL -H 'Accept: application/vnd.github+json' "$_url" 2>/dev/null || true)
|
||
RESOLVED_TAG=$(printf '%s' "$_json" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -1)
|
||
if [ -z "$RESOLVED_TAG" ]; then
|
||
fail_with "Could not read the newest release from GitHub." \
|
||
"Pass the version you want instead: --version v1.0.0" \
|
||
"Releases: https://github.com/$REPO/releases"
|
||
fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Derived values
|
||
#
|
||
# One place turns the answers into the URLs, paths and provider settings the
|
||
# services read, so --dry-run prints exactly what a real run would write.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
derive() {
|
||
# Data root. Empty means "under the install directory"; the literal string
|
||
# "volumes" means Docker's own named volumes. Compose reads a source
|
||
# starting with / as a bind mount and anything else as a named volume, so
|
||
# one variable per store covers both with no second compose file.
|
||
if [ "$DATA_ROOT" = "volumes" ]; then
|
||
V_PG=warmbly_postgres; V_REDIS=warmbly_redis; V_NATS=warmbly_nats
|
||
V_BLOBS=warmbly_blobs; V_WORKER=warmbly_worker; V_UPDATER=warmbly_updater
|
||
DATA_DESC="Docker named volumes"
|
||
else
|
||
[ -n "$DATA_ROOT" ] || DATA_ROOT="$DIR/data"
|
||
V_PG="$DATA_ROOT/postgres"; V_REDIS="$DATA_ROOT/redis"; V_NATS="$DATA_ROOT/nats"
|
||
V_BLOBS="$DATA_ROOT/blobs"; V_WORKER="$DATA_ROOT/worker"; V_UPDATER="$DATA_ROOT/updater"
|
||
DATA_DESC="$DATA_ROOT"
|
||
fi
|
||
|
||
# URLs. Three shapes, and the difference between them is what an operator
|
||
# gets wrong by hand every time.
|
||
case "$TLS" in
|
||
caddy)
|
||
SCHEME=https
|
||
H_APP="app.$HOSTNAME_ANSWER"; H_API="api.$HOSTNAME_ANSWER"
|
||
H_ADMIN="admin.$HOSTNAME_ANSWER"; H_WS="ws.$HOSTNAME_ANSWER"
|
||
H_TRACK="track.$HOSTNAME_ANSWER"; H_FORMS="forms.$HOSTNAME_ANSWER"
|
||
URL_APP="https://$H_APP"; URL_API="https://$H_API"; URL_ADMIN="https://$H_ADMIN"
|
||
URL_WS="wss://$H_WS/socket/websocket"
|
||
TRACKING_DOMAIN="$H_TRACK"; FORMS_DOMAIN="$H_FORMS"
|
||
PHX_HOST="$H_WS"
|
||
# The origins a browser connects FROM, which are the dashboard and
|
||
# the admin panel, not this service's own host.
|
||
CHECK_ORIGIN_HOSTS="$URL_APP,$URL_ADMIN"
|
||
# Caddy sits on the same compose network, so the private ranges are
|
||
# the honest answer here rather than a single container address that
|
||
# changes on every recreate.
|
||
TRUSTED="172.16.0.0/12,10.0.0.0/8,192.168.0.0/16"
|
||
BIND="127.0.0.1:"
|
||
;;
|
||
proxy)
|
||
SCHEME=https
|
||
H_APP="$HOSTNAME_ANSWER"; H_API="$HOSTNAME_ANSWER"; H_ADMIN="$HOSTNAME_ANSWER"
|
||
URL_APP="${WARMBLY_APP_URL:-https://$HOSTNAME_ANSWER}"
|
||
URL_API="${WARMBLY_API_URL:-https://api.$HOSTNAME_ANSWER}"
|
||
URL_ADMIN="${WARMBLY_ADMIN_URL:-https://admin.$HOSTNAME_ANSWER}"
|
||
URL_WS="${WARMBLY_WS_URL:-wss://ws.$HOSTNAME_ANSWER/socket/websocket}"
|
||
TRACKING_DOMAIN="${WARMBLY_TRACKING_DOMAIN:-track.$HOSTNAME_ANSWER}"
|
||
FORMS_DOMAIN="${WARMBLY_FORMS_DOMAIN:-forms.$HOSTNAME_ANSWER}"
|
||
PHX_HOST=$(printf '%s' "$URL_WS" | sed -e 's|^wss\{0,1\}://||' -e 's|/.*$||')
|
||
CHECK_ORIGIN_HOSTS="$URL_APP,$URL_ADMIN"
|
||
TRUSTED="$PROXY_CIDRS"
|
||
BIND="127.0.0.1:"
|
||
;;
|
||
*)
|
||
SCHEME=http
|
||
URL_APP="http://$HOSTNAME_ANSWER:$PORT_WEB"
|
||
URL_API="http://$HOSTNAME_ANSWER:$PORT_BACKEND"
|
||
URL_ADMIN="http://$HOSTNAME_ANSWER:$PORT_ADMIN"
|
||
URL_WS="ws://$HOSTNAME_ANSWER:$PORT_REALTIME/socket/websocket"
|
||
TRACKING_DOMAIN="$HOSTNAME_ANSWER:$PORT_TRACKING"
|
||
FORMS_DOMAIN="$HOSTNAME_ANSWER:$PORT_FORMS"
|
||
PHX_HOST="$HOSTNAME_ANSWER"
|
||
# Plain HTTP on a LAN: the origin is whatever host the person typed,
|
||
# which this install cannot know, so the check stays off. The socket
|
||
# still requires a short-lived ticket, which is the actual control.
|
||
CHECK_ORIGIN_HOSTS=""
|
||
# Nothing in front, so nothing may set X-Forwarded-For. Trusting a
|
||
# proxy that is not there is how a rate limit gets bypassed.
|
||
TRUSTED=""
|
||
BIND=""
|
||
;;
|
||
esac
|
||
CORS="$URL_APP,$URL_ADMIN"
|
||
|
||
# Postgres and Redis: bundled unless the operator brought their own.
|
||
if [ -n "$EXTERNAL_DB" ]; then
|
||
DB_URL="$EXTERNAL_DB"; BUNDLED_PG=0
|
||
else
|
||
DB_URL="postgres://warmbly:${PG_PASSWORD}@postgres:5432/warmbly?sslmode=disable"; BUNDLED_PG=1
|
||
fi
|
||
if [ -n "$EXTERNAL_REDIS" ]; then
|
||
REDIS_URL="$EXTERNAL_REDIS"; BUNDLED_REDIS=0
|
||
else
|
||
REDIS_URL="redis://redis:6379"; BUNDLED_REDIS=1
|
||
fi
|
||
|
||
# The component set decides which services exist at all. A core install
|
||
# has no tracking and no forms service, so naming a host for either is
|
||
# worse than leaving it unset: campaign mail would ship links, an opt-out
|
||
# address and share URLs pointing at a name nothing answers on.
|
||
if [ "$COMPONENTS" = "core" ]; then
|
||
WANT_TRACKING=0; WANT_REALTIME=0; WANT_FORMS=0
|
||
TRACKING_DOMAIN=""; FORMS_DOMAIN=""
|
||
else
|
||
WANT_TRACKING=1; WANT_REALTIME=1; WANT_FORMS=1
|
||
fi
|
||
[ "$TLS" = "caddy" ] && WANT_CADDY=1 || WANT_CADDY=0
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# .env
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
render_env() {
|
||
cat <<ENVEOF
|
||
# Warmbly. Written by install.sh on $(date -u '+%Y-%m-%d %H:%M:%S UTC').
|
||
#
|
||
# This file is the whole configuration of this instance and it holds every
|
||
# secret it has. Keep it 0600 and back it up with the data.
|
||
#
|
||
# Re-running the installer edits this file rather than replacing it: your own
|
||
# edits survive, and the secrets below are never regenerated.
|
||
#
|
||
# Reference: $DOCS/development/configuration/
|
||
|
||
# ── Release ──────────────────────────────────────────────────────────────
|
||
# Pinned, not moving. The admin panel's "Update and restart" rewrites this
|
||
# line; so does re-running the installer with --version.
|
||
WARMBLY_TAG=$RESOLVED_TAG
|
||
WARMBLY_IMAGE_PREFIX=$REGISTRY
|
||
|
||
APP_ENV=prod
|
||
DEPLOYMENT_MODE=self_hosted
|
||
|
||
# ── How it is reached ────────────────────────────────────────────────────
|
||
PUBLIC_HOST=$HOSTNAME_ANSWER
|
||
APP_URL=$URL_APP
|
||
API_PUBLIC_URL=$URL_API
|
||
CORS_ALLOW_ORIGINS=$CORS
|
||
WEBSOCKET_URL=$URL_WS
|
||
PHX_HOST=$PHX_HOST
|
||
CHECK_ORIGIN_HOSTS=$CHECK_ORIGIN_HOSTS
|
||
# Unset means campaign mail ships with no open pixel and unwrapped links. A
|
||
# workspace that verifies its own domain against this one also serves its
|
||
# recipients' unsubscribe link there; otherwise it stays on API_PUBLIC_URL.
|
||
TRACKING_DOMAIN=$TRACKING_DOMAIN
|
||
FORMS_DOMAIN=$FORMS_DOMAIN
|
||
# CIDRs allowed to set X-Forwarded-For. Empty trusts nothing, which is correct
|
||
# for a directly exposed backend and wrong the moment a proxy is in front.
|
||
TRUSTED_PROXIES=$TRUSTED
|
||
|
||
# ── Where the data sits ──────────────────────────────────────────────────
|
||
# A source starting with / is a bind mount; anything else is a Docker named
|
||
# volume. That is the whole difference between "I can rsync this" and "ask
|
||
# docker volume inspect where it went".
|
||
WARMBLY_PG_DATA=$V_PG
|
||
WARMBLY_REDIS_DATA=$V_REDIS
|
||
WARMBLY_NATS_DATA=$V_NATS
|
||
WARMBLY_BLOBS=$V_BLOBS
|
||
WARMBLY_WORKER_STATE=$V_WORKER
|
||
WARMBLY_UPDATER_STATE=$V_UPDATER
|
||
|
||
PRIMARY_DB=$DB_URL
|
||
DATABASE_SSL=${DATABASE_SSL:-false}
|
||
REDIS=$REDIS_URL
|
||
$(render_env_pgpassword)
|
||
$(render_env_blobs)
|
||
|
||
# ── Keys ─────────────────────────────────────────────────────────────────
|
||
# The first two are UNRECOVERABLE. Every mailbox credential and every
|
||
# per-organization key is sealed with them, so a database backup without them
|
||
# restores an instance whose mailboxes authenticate against nothing.
|
||
CREDENTIALS_ENCRYPTION_KEY=$CREDENTIALS_ENCRYPTION_KEY
|
||
KMS_LOCAL_MASTER_KEY=$KMS_LOCAL_MASTER_KEY
|
||
AUTH_SECRET=$AUTH_SECRET
|
||
INTERNAL_API_TOKEN=$INTERNAL_API_TOKEN
|
||
$(render_env_node_token)
|
||
SECRET_KEY_BASE=$SECRET_KEY_BASE
|
||
|
||
# ── Providers (all local; no cloud account of any kind) ──────────────────
|
||
EVENTBUS_PROVIDER=nats
|
||
NATS_URL=nats://nats:4222
|
||
CODEC_PROVIDER=json
|
||
KMS_PROVIDER=local
|
||
TASKS_PROVIDER=local
|
||
BILLING_PROVIDER=none
|
||
CAPTCHA_PROVIDER=none
|
||
PUBSUB_ENABLED=false
|
||
ENCRYPTED_KEYS_PROVIDER=postgres
|
||
|
||
# ── What is kept, and for how long ───────────────────────────────────────
|
||
# Applied on the first boot of a fresh database and editable from then on in
|
||
# the admin panel under Instance > Instance settings, which is authoritative
|
||
# once it has been saved. Leaving this line here never undoes an edit made
|
||
# there.
|
||
WARMBLY_SETTINGS_BOOTSTRAP=$(render_settings_bootstrap)
|
||
|
||
# ── Who gets in ──────────────────────────────────────────────────────────
|
||
# invite_only still lets an invitation link create an account; it stops a
|
||
# stranger without one. The very first signup is always allowed, so the claim
|
||
# link works on a fresh instance.
|
||
DISABLE_REGISTRATION=$REGISTRATION
|
||
$(render_env_bootstrap_owner)
|
||
|
||
# ── Platform mail (login codes, resets, invitations) ─────────────────────
|
||
$(render_env_mail)
|
||
|
||
# ── Whose instance this is ───────────────────────────────────────────────
|
||
# Unset, this instance claims nothing: the sign-in screen shows no Terms or
|
||
# Privacy link, transactional email carries no company line, and public form
|
||
# pages carry no "powered by". Nothing here points at warmbly.com. Fill these
|
||
# in to put your own name on those surfaces.
|
||
# EMAIL_BRAND_NAME=Acme
|
||
# EMAIL_BRAND_WEBSITE_URL=https://acme.example
|
||
# EMAIL_BRAND_TERMS_URL=https://acme.example/terms
|
||
# EMAIL_BRAND_PRIVACY_URL=https://acme.example/privacy
|
||
# EMAIL_BRAND_SUPPORT_EMAIL=support@acme.example
|
||
# EMAIL_BRAND_LEGAL_ENTITY=Acme Ltd
|
||
# EMAIL_BRAND_COMPANY_NUMBER=
|
||
# EMAIL_BRAND_PLACE_OF_REG=
|
||
# EMAIL_BRAND_ADDRESS=
|
||
|
||
# ── Updates ──────────────────────────────────────────────────────────────
|
||
# The check is an outbound call to the GitHub releases API and nothing else.
|
||
# There is no telemetry in Warmbly; set UPDATE_CHECK_ENABLED=false and this
|
||
# instance makes no outbound call of its own at all.
|
||
UPDATE_CHECK_ENABLED=$UPDATE_CHECK
|
||
UPDATE_CHANNEL=$CHANNEL
|
||
RELEASES_GITHUB_REPO=$REPO
|
||
UPDATER_URL=http://updater:8095
|
||
UPDATER_TOKEN=$UPDATER_TOKEN
|
||
ENVEOF
|
||
}
|
||
|
||
# Fleet nodes authenticate with their own token, apart from the one the
|
||
# tracking and forms services hold. An install from before it existed has
|
||
# none, and keeps the shared token so its joined nodes keep working.
|
||
render_env_node_token() {
|
||
[ -n "$NODE_BROKER_TOKEN" ] || return 0
|
||
printf '%s\n' "NODE_BROKER_TOKEN=$NODE_BROKER_TOKEN"
|
||
}
|
||
|
||
render_env_pgpassword() {
|
||
[ "$BUNDLED_PG" = 1 ] || return 0
|
||
printf '%s\n' "POSTGRES_PASSWORD=$PG_PASSWORD"
|
||
}
|
||
|
||
render_env_blobs() {
|
||
if [ "$BLOBS" = "s3" ]; then
|
||
cat <<S3EOF
|
||
BLOB_PROVIDER=s3
|
||
BLOB_BUCKET=$S3_BUCKET
|
||
AWS_ENDPOINT_URL_S3=$S3_ENDPOINT
|
||
AWS_REGION=$S3_REGION
|
||
AWS_ACCESS_KEY_ID=$S3_KEY
|
||
AWS_SECRET_ACCESS_KEY=$S3_SECRET
|
||
S3EOF
|
||
else
|
||
cat <<FSEOF
|
||
BLOB_PROVIDER=filesystem
|
||
# Inside the containers. On this host it is $V_BLOBS.
|
||
BLOB_FS_ROOT=/data/blobs
|
||
FSEOF
|
||
fi
|
||
}
|
||
|
||
render_settings_bootstrap() {
|
||
printf '{"sync":{"backfill_days":%s,"backfill_messages":%s,"daily_messages_per_mailbox":%s,"daily_messages_per_org":%s},"retention":{"engagement_event_days":%s,"form_event_days":%s,"audit_log_days":%s,"warmup_mail_days":%s,"warmup_event_days":%s}}' \
|
||
"$SYNC_BACKFILL_DAYS" "$SYNC_BACKFILL_MESSAGES" "$SYNC_DAILY_MAILBOX" "$SYNC_DAILY_ORG" \
|
||
"$RET_ENGAGEMENT" "$RET_FORMS" "$RET_AUDIT" "$RET_WARMUP_MAIL" "$RET_WARMUP_EVENTS"
|
||
}
|
||
|
||
render_env_bootstrap_owner() {
|
||
if [ -n "$BOOTSTRAP_EMAIL" ]; then
|
||
printf '%s\n' "WARMBLY_BOOTSTRAP_EMAIL=$BOOTSTRAP_EMAIL"
|
||
printf '%s\n' "WARMBLY_BOOTSTRAP_PASSWORD_HASH=$BOOTSTRAP_HASH"
|
||
else
|
||
printf '%s\n' "# No first owner set: the backend prints a single-use claim link to its log."
|
||
printf '%s\n' "# WARMBLY_BOOTSTRAP_EMAIL=you@example.com"
|
||
printf '%s\n' "# WARMBLY_BOOTSTRAP_PASSWORD_HASH= # warmblyctl hash-password"
|
||
fi
|
||
}
|
||
|
||
render_env_mail() {
|
||
if [ "$MAIL_MODE" = "smtp" ]; then
|
||
cat <<MAILEOF
|
||
MAIL_TRANSPORT=smtp
|
||
EMAIL_ADDRESS=$SMTP_FROM
|
||
SMTP_HOST=$SMTP_HOST
|
||
SMTP_PORT=$SMTP_PORT
|
||
SMTP_USERNAME=$SMTP_USER
|
||
SMTP_PASSWORD=$SMTP_PASS
|
||
SMTP_SECURITY=$SMTP_SECURITY
|
||
MAILEOF
|
||
else
|
||
cat <<MAILEOF
|
||
# log: password resets, login codes and invitations are PRINTED TO THE
|
||
# BACKEND LOG instead of being sent. Fine for a trial, not for a team.
|
||
# docker compose -p warmbly logs backend | grep -i code
|
||
# Switch to a relay by setting MAIL_TRANSPORT=smtp and the SMTP_* values.
|
||
MAIL_TRANSPORT=log
|
||
EMAIL_ADDRESS=noreply@$HOSTNAME_ANSWER
|
||
MAILEOF
|
||
fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# docker-compose.yml
|
||
#
|
||
# Generated rather than downloaded, because the answers change the SHAPE of
|
||
# the file and not only its values: an external database means no postgres
|
||
# service at all, "core only" means no tracking, realtime or forms, and a
|
||
# bundled Caddy adds one. Every service reads the .env next to it, so there is
|
||
# one place to change a setting and it is the file an operator would look in.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
render_compose() {
|
||
cat <<'HDR'
|
||
# Warmbly. Generated by install.sh; safe to read, safe to edit.
|
||
#
|
||
# Every value lives in the .env next to this file, which each service reads in
|
||
# full. Re-running the installer regenerates THIS file (keeping a .bak) and
|
||
# edits .env in place, so put your own changes in .env or in a
|
||
# docker-compose.override.yml, which compose merges on top of this one.
|
||
#
|
||
# docker compose -p warmbly ps what is running
|
||
# docker compose -p warmbly logs -f follow everything
|
||
# docker compose -p warmbly pull && docker compose -p warmbly up -d update
|
||
|
||
name: warmbly
|
||
|
||
x-app: &app
|
||
restart: unless-stopped
|
||
# Time for a connect or send in flight to finish before an update stops it.
|
||
stop_grace_period: 60s
|
||
env_file: [.env]
|
||
|
||
services:
|
||
HDR
|
||
|
||
[ "$BUNDLED_PG" = 1 ] && cat <<PGEOF
|
||
|
||
postgres:
|
||
restart: unless-stopped
|
||
image: postgres:16-alpine
|
||
environment:
|
||
POSTGRES_USER: warmbly
|
||
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
||
POSTGRES_DB: warmbly
|
||
volumes:
|
||
- \${WARMBLY_PG_DATA}:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U warmbly"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 10
|
||
PGEOF
|
||
|
||
[ "$BUNDLED_REDIS" = 1 ] && cat <<'REDISEOF'
|
||
|
||
redis:
|
||
restart: unless-stopped
|
||
image: redis:7-alpine
|
||
volumes:
|
||
- ${WARMBLY_REDIS_DATA}:/data
|
||
healthcheck:
|
||
test: ["CMD", "redis-cli", "ping"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 10
|
||
REDISEOF
|
||
|
||
cat <<'NATSEOF'
|
||
|
||
# The event bus: one ~15MB JetStream binary, no Kafka, no Zookeeper.
|
||
nats:
|
||
restart: unless-stopped
|
||
image: nats:2.10-alpine
|
||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||
volumes:
|
||
- ${WARMBLY_NATS_DATA}:/data
|
||
healthcheck:
|
||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
|
||
interval: 5s
|
||
timeout: 3s
|
||
retries: 10
|
||
NATSEOF
|
||
|
||
cat <<BACKENDEOF
|
||
|
||
# The control plane. Applies its own migrations on boot.
|
||
backend:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/backend:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_BACKEND}:8080"]
|
||
environment:
|
||
API_HOST: "0.0.0.0:8080"
|
||
GIN_MODE: release
|
||
volumes:
|
||
- \${WARMBLY_BLOBS}:/data/blobs
|
||
depends_on:$(depends_infra)
|
||
healthcheck:
|
||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/health"]
|
||
interval: 10s
|
||
timeout: 3s
|
||
retries: 10
|
||
start_period: 20s
|
||
|
||
# Turns bus events into platform state: replies, opens, clicks, bounces.
|
||
consumer:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/consumer:\${WARMBLY_TAG}
|
||
volumes:
|
||
- \${WARMBLY_BLOBS}:/data/blobs
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
nats: { condition: service_healthy }
|
||
|
||
# The execution plane: sends and syncs. Scale it out with
|
||
# \`docker compose -p warmbly up -d --scale worker=3\`, or enroll workers on
|
||
# other machines from the admin panel. Outbound IPs belong to the mail
|
||
# provider, so more workers means more parallelism, not more IPs.
|
||
worker:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/worker:\${WARMBLY_TAG}
|
||
environment:
|
||
# Workers hold no database connection; they read encrypted keys through
|
||
# the backend's internal API. Keep it that way.
|
||
ENCRYPTED_KEYS_PROVIDER: http
|
||
ENCRYPTED_KEYS_BACKEND_URL: http://backend:8080
|
||
ENCRYPTED_KEYS_WORKER_TOKEN: \${INTERNAL_API_TOKEN}
|
||
WORKER_STATE_DIR: /data/state
|
||
MAIL_TLS_INSECURE: "false"
|
||
volumes:
|
||
- \${WARMBLY_BLOBS}:/data/blobs
|
||
- \${WARMBLY_WORKER_STATE}:/data/state
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
nats: { condition: service_healthy }
|
||
|
||
# The dashboard and the operator panel. Static builds; their API URLs are
|
||
# injected at container start, so these are the images the release publishes.
|
||
web:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/web:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_WEB}:80"]
|
||
environment:
|
||
WARMBLY_API_URL: \${API_PUBLIC_URL}
|
||
WARMBLY_APP_URL: \${APP_URL}
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
|
||
admin:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/admin:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_ADMIN}:80"]
|
||
environment:
|
||
WARMBLY_API_URL: \${API_PUBLIC_URL}
|
||
WARMBLY_DASHBOARD_URL: \${APP_URL}
|
||
WARMBLY_ENV_LABEL: production
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
BACKENDEOF
|
||
|
||
[ "$WANT_TRACKING" = 1 ] && cat <<TRACKEOF
|
||
|
||
# Open pixels and click tickets. Without it, campaign mail ships with no
|
||
# pixel and unwrapped links, and open/click reporting stays empty.
|
||
tracking:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/tracking:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_TRACKING}:3000"]
|
||
environment:
|
||
TRACKING_HOST: "0.0.0.0"
|
||
TRACKING_PORT: "3000"
|
||
BACKEND_INTERNAL_URL: http://backend:8080
|
||
depends_on:
|
||
nats: { condition: service_healthy }
|
||
TRACKEOF
|
||
|
||
[ "$WANT_REALTIME" = 1 ] && cat <<RTEOF
|
||
|
||
# Websocket fanout: live inbox, presence, and everything the dashboard
|
||
# updates without a refresh.
|
||
realtime:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/realtime:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_REALTIME}:4000"]
|
||
environment:
|
||
PORT: 4000
|
||
DATABASE_URL: \${PRIMARY_DB}
|
||
REDIS_URL: \${REDIS}
|
||
# Must equal the backend's AUTH_SECRET or no JWT validates here.
|
||
JWT_SECRET: \${AUTH_SECRET}
|
||
depends_on:$(depends_infra)
|
||
RTEOF
|
||
|
||
[ "$WANT_FORMS" = 1 ] && cat <<FORMSEOF
|
||
|
||
# Hosted lead-capture forms on their own origin. No database: the backend's
|
||
# internal API is its only dependency.
|
||
forms:
|
||
<<: *app
|
||
image: \${WARMBLY_IMAGE_PREFIX}/forms:\${WARMBLY_TAG}
|
||
ports: ["${BIND}${PORT_FORMS}:8090"]
|
||
environment:
|
||
GIN_MODE: release
|
||
FORMS_PORT: "8090"
|
||
BACKEND_INTERNAL_URL: http://backend:8080
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
FORMSEOF
|
||
|
||
cat <<UPDEOF
|
||
|
||
# "Update and restart" in the admin panel. Image mode: it pins the release
|
||
# tag in the .env next to this file, pulls, and recreates what changed. It
|
||
# holds the docker socket, which is root on this host, so remove this service
|
||
# if you would rather update by hand with
|
||
# \`docker compose -p warmbly pull && docker compose -p warmbly up -d\`.
|
||
updater:
|
||
restart: unless-stopped
|
||
image: \${WARMBLY_IMAGE_PREFIX}/updater:\${WARMBLY_TAG}
|
||
environment:
|
||
UPDATER_MODE: image
|
||
UPDATER_TOKEN: \${UPDATER_TOKEN}
|
||
UPDATER_REPO_DIR: $DIR
|
||
UPDATER_COMPOSE_PROJECT: warmbly
|
||
UPDATER_COMPOSE_PROFILES: ""
|
||
UPDATER_BACKEND_HEALTH_URL: http://backend:8080/health
|
||
working_dir: $DIR
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock
|
||
- $DIR:$DIR
|
||
- \${WARMBLY_UPDATER_STATE}:/var/lib/warmbly-updater
|
||
UPDEOF
|
||
|
||
[ "$WANT_CADDY" = 1 ] && cat <<'CADDYEOF'
|
||
|
||
# Automatic HTTPS. Caddy answers on 80 and 443 and obtains a certificate for
|
||
# each hostname in the Caddyfile the first time it is asked for, so every
|
||
# name below has to resolve to this machine before it can succeed.
|
||
caddy:
|
||
restart: unless-stopped
|
||
image: caddy:2-alpine
|
||
ports:
|
||
- "80:80"
|
||
- "443:443"
|
||
- "443:443/udp"
|
||
volumes:
|
||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||
- caddy_data:/data
|
||
- caddy_config:/config
|
||
depends_on:
|
||
backend: { condition: service_healthy }
|
||
CADDYEOF
|
||
|
||
render_compose_volumes
|
||
}
|
||
|
||
# depends_infra is the dependency block backend and realtime share. An
|
||
# external database or cache is not ours to wait for, so it is left out
|
||
# rather than named as a service that does not exist.
|
||
depends_infra() {
|
||
printf '\n'
|
||
[ "$BUNDLED_PG" = 1 ] && printf ' postgres: { condition: service_healthy }\n'
|
||
[ "$BUNDLED_REDIS" = 1 ] && printf ' redis: { condition: service_healthy }\n'
|
||
printf ' nats: { condition: service_healthy }'
|
||
}
|
||
|
||
# Only the named volumes actually referenced are declared. With a data root on
|
||
# a path, every source is a bind mount and this block holds nothing but Caddy's
|
||
# certificate store.
|
||
render_compose_volumes() {
|
||
_named=""
|
||
if [ "$DATA_ROOT" = "volumes" ]; then
|
||
_named="warmbly_nats warmbly_blobs warmbly_worker warmbly_updater"
|
||
[ "$BUNDLED_PG" = 1 ] && _named="warmbly_postgres $_named"
|
||
[ "$BUNDLED_REDIS" = 1 ] && _named="warmbly_redis $_named"
|
||
fi
|
||
[ "$WANT_CADDY" = 1 ] && _named="$_named caddy_data caddy_config"
|
||
[ -n "$(printf '%s' "$_named" | tr -d ' ')" ] || return 0
|
||
printf '\nvolumes:\n'
|
||
for _v in $_named; do printf ' %s:\n' "$_v"; done
|
||
}
|
||
|
||
render_caddyfile() {
|
||
cat <<CADDYFILE
|
||
# Warmbly, behind automatic HTTPS. Written by install.sh.
|
||
#
|
||
# Every hostname here must resolve to this machine's public IP before Caddy can
|
||
# obtain a certificate for it. Check with: dig +short $H_APP
|
||
#
|
||
# on_demand_tls covers the hostnames that are not knowable at install time: a
|
||
# workspace can point its own tracking or forms domain here later, and Caddy
|
||
# asks the backend whether it has verified that name before obtaining a
|
||
# certificate for it. Without the ask, Caddy would issue for anything anyone
|
||
# aimed at this machine.
|
||
{
|
||
email ${CADDY_EMAIL:-admin@$HOSTNAME_ANSWER}
|
||
on_demand_tls {
|
||
ask http://backend:8080/tls/authorize
|
||
}
|
||
}
|
||
|
||
# Applied to every site below. HSTS is set here rather than per service
|
||
# because Caddy is the only thing terminating TLS: whatever it proxies to
|
||
# speaks plain HTTP on the compose network and cannot know the request
|
||
# arrived over TLS. Server header removed so the version is not advertised.
|
||
(warmbly_headers) {
|
||
header {
|
||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||
X-Content-Type-Options "nosniff"
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
-Server
|
||
}
|
||
}
|
||
|
||
# Customer-owned domains get the same headers minus HSTS: a pin sent from a
|
||
# customer's apex would cover every subdomain they run elsewhere.
|
||
(customer_headers) {
|
||
header {
|
||
X-Content-Type-Options "nosniff"
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
-Strict-Transport-Security
|
||
-Server
|
||
}
|
||
}
|
||
|
||
$H_APP {
|
||
import warmbly_headers
|
||
reverse_proxy web:80
|
||
}
|
||
|
||
$H_ADMIN {
|
||
import warmbly_headers
|
||
reverse_proxy admin:80
|
||
}
|
||
|
||
$H_API {
|
||
import warmbly_headers
|
||
reverse_proxy backend:8080
|
||
}
|
||
CADDYFILE
|
||
[ "$WANT_REALTIME" = 1 ] && cat <<CADDYFILE
|
||
|
||
$H_WS {
|
||
import warmbly_headers
|
||
reverse_proxy realtime:4000
|
||
}
|
||
CADDYFILE
|
||
[ "$WANT_TRACKING" = 1 ] && cat <<CADDYFILE
|
||
|
||
$H_TRACK {
|
||
import warmbly_headers
|
||
reverse_proxy tracking:3000
|
||
}
|
||
CADDYFILE
|
||
[ "$WANT_FORMS" = 1 ] && cat <<CADDYFILE
|
||
|
||
$H_FORMS {
|
||
import warmbly_headers
|
||
reverse_proxy forms:8090
|
||
}
|
||
CADDYFILE
|
||
render_caddy_custom_domains
|
||
return 0
|
||
}
|
||
|
||
# The catch-all for customer-owned domains. A named site block above always
|
||
# wins over it, so this only ever sees a hostname the install was not told
|
||
# about, and `tls { on_demand }` is what makes the certificate for that name
|
||
# appear. Every request through here has already passed the ask endpoint.
|
||
#
|
||
# Forms and tracking share the block because they share the door: the two
|
||
# services have disjoint paths, so the path decides which one answers rather
|
||
# than a hostname list this file cannot know.
|
||
render_caddy_custom_domains() {
|
||
[ "$WANT_TRACKING" = 1 ] || [ "$WANT_FORMS" = 1 ] || return 0
|
||
|
||
cat <<'CADDYFILE'
|
||
|
||
# Custom tracking and forms domains a workspace points here (CNAME). Caddy
|
||
# obtains the certificate on the first request, after /tls/authorize confirms
|
||
# this instance has verified the name.
|
||
https:// {
|
||
import customer_headers
|
||
tls {
|
||
on_demand
|
||
}
|
||
CADDYFILE
|
||
|
||
if [ "$WANT_FORMS" = 1 ] && [ "$WANT_TRACKING" = 1 ]; then
|
||
cat <<'CADDYFILE'
|
||
@forms path /f/* /forms.js /api/forms/*
|
||
handle @forms {
|
||
reverse_proxy forms:8090
|
||
}
|
||
handle {
|
||
reverse_proxy tracking:3000
|
||
}
|
||
}
|
||
CADDYFILE
|
||
elif [ "$WANT_FORMS" = 1 ]; then
|
||
cat <<'CADDYFILE'
|
||
reverse_proxy forms:8090
|
||
}
|
||
CADDYFILE
|
||
else
|
||
cat <<'CADDYFILE'
|
||
reverse_proxy tracking:3000
|
||
}
|
||
CADDYFILE
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Scheduled backups
|
||
#
|
||
# A backup that is only a pg_dump is not a restore: the bodies live in the blob
|
||
# root and the ciphertext in the database is opened by keys in .env. The bundle
|
||
# `warmblyctl backup` writes holds all three, and this is the timer that runs it.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
render_backup_script() {
|
||
cat <<BACKUPEOF
|
||
#!/bin/sh
|
||
# Warmbly scheduled backup. Written by install.sh; edit freely.
|
||
#
|
||
# One bundle per run: the database, the blob root, and (unless you turn it off)
|
||
# the two encryption keys without which the rest cannot be read. Restore it on
|
||
# another host with:
|
||
#
|
||
# docker compose -p warmbly exec backend warmblyctl restore --file <bundle>
|
||
set -eu
|
||
|
||
INSTALL_DIR=$DIR
|
||
TARGET=$BACKUP_DIR
|
||
KEEP=$BACKUP_KEEP
|
||
KEYS=$BACKUP_KEYS
|
||
S3_TARGET=${BACKUP_S3:-}
|
||
|
||
stamp=\$(date -u '+%Y%m%d-%H%M%S')
|
||
name="warmbly-\${stamp}.tar.gz"
|
||
mkdir -p "\$TARGET"
|
||
|
||
# warmblyctl writes inside the container; the blob mount is the one path both
|
||
# sides can see, so the bundle lands there and is moved out afterwards.
|
||
flags=""
|
||
[ "\$KEYS" = "true" ] || flags="--no-keys"
|
||
|
||
cd "\$INSTALL_DIR"
|
||
docker compose -p warmbly exec -T backend warmblyctl backup --out "/data/blobs/\$name" \$flags
|
||
docker compose -p warmbly cp "backend:/data/blobs/\$name" "\$TARGET/\$name"
|
||
docker compose -p warmbly exec -T backend rm -f "/data/blobs/\$name"
|
||
chmod 600 "\$TARGET/\$name"
|
||
|
||
# Off-host, when an aws CLI is available. A backup that only exists on the
|
||
# machine it backs up is not one.
|
||
if [ -n "\$S3_TARGET" ]; then
|
||
if command -v aws >/dev/null 2>&1; then
|
||
aws s3 cp "\$TARGET/\$name" "\$S3_TARGET/\$name"
|
||
else
|
||
echo "warmbly-backup: aws CLI not found, so \$name was not copied to \$S3_TARGET" >&2
|
||
fi
|
||
fi
|
||
|
||
# Keep the newest \$KEEP and delete the rest.
|
||
ls -1t "\$TARGET"/warmbly-*.tar.gz 2>/dev/null | tail -n +\$((KEEP + 1)) | while read -r old; do
|
||
rm -f "\$old"
|
||
done
|
||
|
||
echo "warmbly-backup: wrote \$TARGET/\$name"
|
||
BACKUPEOF
|
||
}
|
||
|
||
render_backup_units() {
|
||
cat <<UNITEOF
|
||
[Unit]
|
||
Description=Warmbly instance backup
|
||
After=docker.service
|
||
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=$DIR/backup.sh
|
||
UNITEOF
|
||
}
|
||
|
||
render_backup_timer() {
|
||
case "$BACKUP_SCHEDULE" in
|
||
hourly) _cal="hourly" ;;
|
||
weekly) _cal="weekly" ;;
|
||
*) _cal="daily" ;;
|
||
esac
|
||
cat <<TIMEREOF
|
||
[Unit]
|
||
Description=Warmbly instance backup ($_cal)
|
||
|
||
[Timer]
|
||
OnCalendar=$_cal
|
||
Persistent=true
|
||
RandomizedDelaySec=30m
|
||
|
||
[Install]
|
||
WantedBy=timers.target
|
||
TIMEREOF
|
||
}
|
||
|
||
install_backup_timer() {
|
||
[ -n "$BACKUP_DIR" ] || return 0
|
||
write_file "$DIR/backup.sh" "$(render_backup_script)" 0755
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
note "and a systemd service + timer for it ($BACKUP_SCHEDULE)"
|
||
return 0
|
||
fi
|
||
if ! have systemctl; then
|
||
warn "No systemd here, so the timer was not installed."
|
||
note "backup.sh is written and works; schedule it with cron:"
|
||
note " 0 3 * * * $DIR/backup.sh"
|
||
return 0
|
||
fi
|
||
if ! need_sudo; then
|
||
warn "Installing the backup timer needs root and sudo is not available."
|
||
note "Install it yourself later: $DIR/backup.sh is ready to schedule."
|
||
return 0
|
||
fi
|
||
printf '%s\n' "$(render_backup_units)" | $SUDO tee /etc/systemd/system/warmbly-backup.service >/dev/null
|
||
printf '%s\n' "$(render_backup_timer)" | $SUDO tee /etc/systemd/system/warmbly-backup.timer >/dev/null
|
||
$SUDO systemctl daemon-reload >/dev/null 2>&1 || true
|
||
$SUDO systemctl enable --now warmbly-backup.timer >/dev/null 2>&1 || true
|
||
ok "Scheduled backup installed ($BACKUP_SCHEDULE, keeping $BACKUP_KEEP, into $BACKUP_DIR)"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Writing
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
# write_file <path> <content> <mode>. Under --dry-run it prints instead, which
|
||
# is the whole point of --dry-run: what you see is what would land on disk.
|
||
write_file() {
|
||
_path=$1; _body=$2; _mode=${3:-0644}
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
say ""
|
||
out " ${DIM}──${R} ${B}${_path}${R} ${DIM}(mode ${_mode})${R}"
|
||
printf '%s\n' "$_body" | sed 's/^/ /'
|
||
return 0
|
||
fi
|
||
_dir=$(dirname "$_path")
|
||
mkdir -p "$_dir"
|
||
# Created with the final mode rather than chmod'd afterwards: .env holds
|
||
# every secret this instance has, and "briefly world-readable" is readable.
|
||
(umask 077; : >"$_path")
|
||
printf '%s\n' "$_body" >"$_path"
|
||
chmod "$_mode" "$_path"
|
||
}
|
||
|
||
backup_existing() {
|
||
_path=$1
|
||
[ -f "$_path" ] || return 0
|
||
[ "$DRY_RUN" = 1 ] && return 0
|
||
cp -p "$_path" "${_path}.bak"
|
||
note "kept the previous $(basename "$_path") as $(basename "$_path").bak"
|
||
}
|
||
|
||
# merge_env keeps an existing .env's own edits: every key the installer owns is
|
||
# rewritten, every other line is left exactly where it was. A second run must
|
||
# reconfigure, never clobber.
|
||
merge_env() {
|
||
_existing=$1; _new=$2
|
||
_keys=$(printf '%s\n' "$_new" | sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p')
|
||
printf '%s\n' "$_new"
|
||
_extra=$(printf '%s\n' "$_existing" | awk -v keys="$_keys" '
|
||
BEGIN { split(keys, k, "\n"); for (i in k) own[k[i]] = 1 }
|
||
/^[A-Z_][A-Z0-9_]*=/ {
|
||
split($0, parts, "=")
|
||
if (parts[1] in own) next
|
||
print
|
||
}')
|
||
if [ -n "$_extra" ]; then
|
||
printf '\n# ── Kept from the previous .env ──────────────────────────────────────────\n'
|
||
printf '%s\n' "$_extra"
|
||
fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Existing installs
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
env_get() {
|
||
# Last assignment wins, as compose itself resolves it.
|
||
[ -f "$1" ] || return 0
|
||
sed -n "s/^${2}=//p" "$1" | tail -1
|
||
}
|
||
|
||
adopt_existing() {
|
||
_env="$DIR/.env"
|
||
[ -f "$_env" ] || return 0
|
||
EXISTING=1
|
||
|
||
# Secrets are adopted, never regenerated: a new AUTH_SECRET signs every
|
||
# session out and a new CREDENTIALS_ENCRYPTION_KEY makes every stored
|
||
# mailbox credential unreadable, permanently.
|
||
AUTH_SECRET=$(env_get "$_env" AUTH_SECRET)
|
||
INTERNAL_API_TOKEN=$(env_get "$_env" INTERNAL_API_TOKEN)
|
||
NODE_BROKER_TOKEN=$(env_get "$_env" NODE_BROKER_TOKEN)
|
||
UPDATER_TOKEN=$(env_get "$_env" UPDATER_TOKEN)
|
||
SECRET_KEY_BASE=$(env_get "$_env" SECRET_KEY_BASE)
|
||
KMS_LOCAL_MASTER_KEY=$(env_get "$_env" KMS_LOCAL_MASTER_KEY)
|
||
CREDENTIALS_ENCRYPTION_KEY=$(env_get "$_env" CREDENTIALS_ENCRYPTION_KEY)
|
||
PG_PASSWORD=$(env_get "$_env" POSTGRES_PASSWORD)
|
||
|
||
# And so is the data root: pointing an existing install at a new path is
|
||
# not a move, it is an empty instance next to a full one.
|
||
_pg=$(env_get "$_env" WARMBLY_PG_DATA)
|
||
if [ -n "$_pg" ] && [ -z "${WARMBLY_DATA_ROOT:-}" ] && [ -z "${DATA_ROOT_SET:-}" ]; then
|
||
case "$_pg" in
|
||
/*) DATA_ROOT=$(dirname "$_pg") ;;
|
||
*) DATA_ROOT=volumes ;;
|
||
esac
|
||
fi
|
||
|
||
[ -z "${WARMBLY_HOST:-}" ] && [ -z "${HOST_SET:-}" ] && {
|
||
_h=$(env_get "$_env" PUBLIC_HOST); [ -n "$_h" ] && HOSTNAME_ANSWER=$_h
|
||
}
|
||
[ -z "${WARMBLY_VERSION:-}" ] && [ -z "${VERSION_SET:-}" ] && {
|
||
_t=$(env_get "$_env" WARMBLY_TAG); [ -n "$_t" ] && VERSION=$_t
|
||
}
|
||
return 0
|
||
}
|
||
|
||
ensure_secrets() {
|
||
[ -n "${AUTH_SECRET:-}" ] || AUTH_SECRET=$(gen_hex 32)
|
||
[ -n "${INTERNAL_API_TOKEN:-}" ] || INTERNAL_API_TOKEN=$(gen_hex 32)
|
||
NODE_BROKER_TOKEN=${NODE_BROKER_TOKEN:-}
|
||
if [ -z "$NODE_BROKER_TOKEN" ] && [ "$EXISTING" = 0 ]; then
|
||
NODE_BROKER_TOKEN=$(gen_hex 32)
|
||
fi
|
||
[ -n "${UPDATER_TOKEN:-}" ] || UPDATER_TOKEN=$(gen_hex 32)
|
||
[ -n "${SECRET_KEY_BASE:-}" ] || SECRET_KEY_BASE=$(gen_hex 48)
|
||
[ -n "${KMS_LOCAL_MASTER_KEY:-}" ] || KMS_LOCAL_MASTER_KEY=$(gen_b64 32)
|
||
[ -n "${CREDENTIALS_ENCRYPTION_KEY:-}" ] || CREDENTIALS_ENCRYPTION_KEY=$(gen_hex 32)
|
||
[ -n "${PG_PASSWORD:-}" ] || PG_PASSWORD=$(gen_hex 20)
|
||
# The last `||` above returns non-zero when the key was already set, which
|
||
# set -e would read as this function failing.
|
||
return 0
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# The wizard
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
wizard() {
|
||
wiz_location
|
||
wiz_access
|
||
wiz_storage
|
||
wiz_keys
|
||
wiz_retention
|
||
wiz_backups
|
||
wiz_entry
|
||
wiz_footprint
|
||
}
|
||
|
||
wiz_location() {
|
||
stepper 1
|
||
note "One directory holds the compose file, the .env, and (unless you move it"
|
||
note "in the next step but one) every store this instance writes to."
|
||
say ""
|
||
ask "Install directory" "$DIR"
|
||
DIR=$ANSWER
|
||
if [ -d "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ] && [ ! -f "$DIR/$MARKER" ] && [ ! -f "$DIR/.env" ]; then
|
||
say ""
|
||
warn "$DIR is not empty and was not created by this installer."
|
||
if ! confirm "Use it anyway?" no; then
|
||
die "nothing was changed"
|
||
fi
|
||
FORCE=1
|
||
fi
|
||
adopt_existing
|
||
if [ "$EXISTING" = 1 ]; then
|
||
say ""
|
||
ok "Found an existing install here. Its secrets and data root are kept;"
|
||
note "everything else is yours to change, and nothing you have not been"
|
||
note "asked about is touched."
|
||
fi
|
||
}
|
||
|
||
wiz_access() {
|
||
stepper 2
|
||
note "The hostname every link in this instance is built from: the dashboard"
|
||
note "URL, the unsubscribe link in campaign mail, the tracking pixel."
|
||
say ""
|
||
ask "Hostname or IP" "$HOSTNAME_ANSWER"
|
||
HOSTNAME_ANSWER=$ANSWER
|
||
|
||
say ""
|
||
out " ${WHITE}How is it reached?${R}"
|
||
say ""
|
||
_def=1
|
||
case "$TLS" in caddy) _def=2 ;; proxy) _def=3 ;; esac
|
||
choose "$_def" \
|
||
"Plain HTTP on ports|Fine for localhost or a private network. No certificate." \
|
||
"Bundled Caddy with automatic HTTPS|Automatic certificates. Needs DNS here and ports 80 and 443 free." \
|
||
"Behind my own reverse proxy|Binds to 127.0.0.1 and sets TRUSTED_PROXIES, so client IPs survive."
|
||
case "$CHOICE" in 1) TLS=none ;; 2) TLS=caddy ;; 3) TLS=proxy ;; esac
|
||
|
||
if [ "$TLS" = "caddy" ]; then
|
||
say ""
|
||
note "Caddy serves one hostname per surface, all under $HOSTNAME_ANSWER:"
|
||
note " app.$HOSTNAME_ANSWER admin.$HOSTNAME_ANSWER api.$HOSTNAME_ANSWER"
|
||
note " ws.$HOSTNAME_ANSWER track.$HOSTNAME_ANSWER forms.$HOSTNAME_ANSWER"
|
||
note "Each needs an A record pointing at this machine before it can get a"
|
||
note "certificate. The exact list is printed again at the end."
|
||
say ""
|
||
ask "Email for the certificate authority (expiry notices)" "admin@$HOSTNAME_ANSWER"
|
||
CADDY_EMAIL=$ANSWER
|
||
elif [ "$TLS" = "proxy" ]; then
|
||
say ""
|
||
note "Your proxy terminates TLS and forwards to 127.0.0.1 on the ports below."
|
||
note "TRUSTED_PROXIES is the one setting that is silently wrong on every"
|
||
note "proxied install: without it every request looks like it came from the"
|
||
note "proxy, so per-IP rate limits and click deduplication stop working."
|
||
note "Domains a workspace points here later (tracking hosts, root redirects)"
|
||
note "go to the tracking port too, with the original Host header."
|
||
say ""
|
||
ask "Public dashboard URL" "https://$HOSTNAME_ANSWER"
|
||
WARMBLY_APP_URL=$ANSWER
|
||
ask "Public API URL" "https://api.$HOSTNAME_ANSWER"
|
||
WARMBLY_API_URL=$ANSWER
|
||
# Asked rather than derived: it is one of the two origins CORS allows,
|
||
# and an admin panel served somewhere unexpected is refused by the API
|
||
# with an error that does not say why.
|
||
ask "Public admin panel URL" "https://admin.$HOSTNAME_ANSWER"
|
||
WARMBLY_ADMIN_URL=$ANSWER
|
||
ask "Trusted proxy CIDRs" "$PROXY_CIDRS"
|
||
PROXY_CIDRS=$ANSWER
|
||
fi
|
||
}
|
||
|
||
wiz_storage() {
|
||
stepper 3
|
||
note "This is the question the rest of the install cannot answer for you."
|
||
note "Self-hosting Warmbly means holding mailbox credentials, message bodies"
|
||
note "and contact records on your own disk. Where they go decides how you"
|
||
note "back this instance up and how you move it."
|
||
say ""
|
||
out " ${WHITE}Where should the data live?${R}"
|
||
say ""
|
||
_def=1
|
||
[ "$DATA_ROOT" = "volumes" ] && _def=3
|
||
[ -n "$DATA_ROOT" ] && [ "$DATA_ROOT" != "volumes" ] && [ "$DATA_ROOT" != "$DIR/data" ] && _def=2
|
||
choose "$_def" \
|
||
"Under the install directory|$DIR/data: one path holding Postgres, blobs, NATS and worker state." \
|
||
"A path I choose|An external disk, or a mount you already snapshot." \
|
||
"Docker named volumes|Managed by Docker. Moved only with docker volume commands."
|
||
case "$CHOICE" in
|
||
1) DATA_ROOT="$DIR/data" ;;
|
||
2)
|
||
say ""
|
||
ask "Data root" "${DATA_ROOT:-/mnt/data/warmbly}"
|
||
DATA_ROOT=$ANSWER
|
||
case "$DATA_ROOT" in
|
||
/*) ;;
|
||
*) DATA_ROOT="$PWD/$DATA_ROOT" ;;
|
||
esac
|
||
;;
|
||
3) DATA_ROOT=volumes ;;
|
||
esac
|
||
|
||
if confirm "Configure an external Postgres, Redis or object store?" no; then
|
||
say ""
|
||
ask "External Postgres URL (blank keeps the bundled one)" "$EXTERNAL_DB"
|
||
EXTERNAL_DB=$ANSWER
|
||
if [ -n "$EXTERNAL_DB" ]; then
|
||
case "$EXTERNAL_DB" in
|
||
postgres://*|postgresql://*) ok "Looks like a Postgres URL. It is checked for real before anything starts." ;;
|
||
*) warn "That does not start with postgres:// — the backend will refuse it at boot." ;;
|
||
esac
|
||
fi
|
||
ask "External Redis URL (blank keeps the bundled one)" "$EXTERNAL_REDIS"
|
||
EXTERNAL_REDIS=$ANSWER
|
||
|
||
say ""
|
||
out " ${WHITE}Where do message bodies, attachments and avatars go?${R}"
|
||
say ""
|
||
_bdef=1; [ "$BLOBS" = "s3" ] && _bdef=2
|
||
choose "$_bdef" \
|
||
"On this machine's filesystem|Under the data root. Correct while every worker runs here." \
|
||
"S3-compatible object storage|MinIO, R2, B2 or AWS. Required once workers run off-host."
|
||
[ "$CHOICE" = 2 ] && BLOBS=s3 || BLOBS=filesystem
|
||
if [ "$BLOBS" = "s3" ]; then
|
||
say ""
|
||
ask "Bucket" "$S3_BUCKET"; S3_BUCKET=$ANSWER
|
||
ask "Endpoint URL (blank for AWS)" "$S3_ENDPOINT"; S3_ENDPOINT=$ANSWER
|
||
ask "Region" "$S3_REGION"; S3_REGION=$ANSWER
|
||
ask "Access key id" "$S3_KEY"; S3_KEY=$ANSWER
|
||
ask_secret "Secret access key:"; [ -n "$ANSWER" ] && S3_SECRET=$ANSWER
|
||
else
|
||
say ""
|
||
note "Filesystem blobs are local to this machine. A worker running on"
|
||
note "another host writes to ITS OWN disk, so once you scale workers out"
|
||
note "this has to become S3-compatible storage or bodies go missing."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
wiz_keys() {
|
||
stepper 4
|
||
ensure_secrets
|
||
if [ "$EXISTING" = 1 ]; then
|
||
ok "This install already has its keys and they are kept as they are."
|
||
note "Regenerating CREDENTIALS_ENCRYPTION_KEY would make every stored"
|
||
note "mailbox credential permanently unreadable, so the installer never does."
|
||
say ""
|
||
return 0
|
||
fi
|
||
|
||
note "Seven secrets were generated for this instance. Two of them cannot be"
|
||
note "recovered if they are lost, and a database backup without them is not"
|
||
note "a backup: every mailbox credential in it stays sealed forever."
|
||
say ""
|
||
printf '%s\n' \
|
||
"${RED}${B}Copy these somewhere safe now${R}" \
|
||
"" \
|
||
"${DIM}CREDENTIALS_ENCRYPTION_KEY${R}" \
|
||
"${WHITE}$CREDENTIALS_ENCRYPTION_KEY${R}" \
|
||
"" \
|
||
"${DIM}KMS_LOCAL_MASTER_KEY${R}" \
|
||
"${WHITE}$KMS_LOCAL_MASTER_KEY${R}" \
|
||
"" \
|
||
"${DIM}Also written to keys-backup.txt in the install directory, 0600.${R}" \
|
||
"${DIM}That file is on the same disk as the database it protects, so it${R}" \
|
||
"${DIM}is not itself a backup.${R}" \
|
||
| box "$RED"
|
||
say ""
|
||
if [ "$INTERACTIVE" = 1 ] && [ "$ASSUME_YES" = 0 ]; then
|
||
while ! confirm_phrase "Type 'copied' when you have them somewhere else:" "copied"; do
|
||
out " ${DIM}Type the word copied, or Ctrl-C to stop.${R}"
|
||
done
|
||
fi
|
||
}
|
||
|
||
wiz_retention() {
|
||
stepper 5
|
||
note "How much mail is imported when a mailbox is connected, and how long"
|
||
note "event-level history is kept afterwards. Every one of these is also a"
|
||
note "privacy setting, and all of them stay editable in the admin panel."
|
||
say ""
|
||
out " ${WHITE}Start from which posture?${R}"
|
||
say ""
|
||
choose 1 \
|
||
"Defaults|90 days imported; opens/clicks 365 days, forms 180, audit 90, warmup mail 30." \
|
||
"Minimal retention|30 days imported; every event log 30 days, warmup mail 7. Reports get shorter." \
|
||
"Let me set each one|Nine questions, each with its default already filled in."
|
||
case "$CHOICE" in
|
||
2)
|
||
SYNC_BACKFILL_DAYS=30; SYNC_BACKFILL_MESSAGES=2000
|
||
RET_ENGAGEMENT=30; RET_FORMS=30; RET_AUDIT=30
|
||
RET_WARMUP_MAIL=7; RET_WARMUP_EVENTS=30
|
||
ok "Minimal retention"
|
||
;;
|
||
3)
|
||
say ""
|
||
out " ${DIM}Mailbox import, when a mailbox is first connected${R}"
|
||
ask_number "How far back to import (days)" "$SYNC_BACKFILL_DAYS" 1 730; SYNC_BACKFILL_DAYS=$ANSWER
|
||
ask_number "Most messages to import per mailbox" "$SYNC_BACKFILL_MESSAGES" 1 100000; SYNC_BACKFILL_MESSAGES=$ANSWER
|
||
say ""
|
||
out " ${DIM}Daily sync ceilings. Over them, mail waits; nothing is dropped.${R}"
|
||
ask_number "New messages per mailbox per day" "$SYNC_DAILY_MAILBOX" 1 100000; SYNC_DAILY_MAILBOX=$ANSWER
|
||
ask_number "Messages per organization per day" "$SYNC_DAILY_ORG" 1 2000000; SYNC_DAILY_ORG=$ANSWER
|
||
say ""
|
||
out " ${DIM}Event history. Each window is how long that data is held.${R}"
|
||
ask_number "Opens and clicks (days)" "$RET_ENGAGEMENT" 1 3650; RET_ENGAGEMENT=$ANSWER
|
||
ask_number "Form funnel events (days)" "$RET_FORMS" 1 3650; RET_FORMS=$ANSWER
|
||
ask_number "Audit log, incl. IP addresses (days)" "$RET_AUDIT" 1 3650; RET_AUDIT=$ANSWER
|
||
say ""
|
||
out " ${DIM}Warmup. Mail is deleted from each mailbox after the first window, records after the second.${R}"
|
||
ask_number "Warmup mail in mailboxes (days)" "$RET_WARMUP_MAIL" 3 3650; RET_WARMUP_MAIL=$ANSWER
|
||
ask_number "Warmup records (days)" "$RET_WARMUP_EVENTS" 30 3650; RET_WARMUP_EVENTS=$ANSWER
|
||
;;
|
||
*) ok "Defaults" ;;
|
||
esac
|
||
}
|
||
|
||
wiz_backups() {
|
||
stepper 6
|
||
note "A bundle holding the database, the blob root and the encryption keys."
|
||
note "That combination is the only one that restores; any two of the three"
|
||
note "restore an instance that looks fine and cannot read its own mailboxes."
|
||
say ""
|
||
if ! confirm "Schedule a backup?" yes; then
|
||
BACKUP_DIR=""
|
||
return 0
|
||
fi
|
||
say ""
|
||
ask "Write bundles to" "${BACKUP_DIR:-/var/backups/warmbly}"
|
||
BACKUP_DIR=$ANSWER
|
||
say ""
|
||
out " ${WHITE}How often?${R}"
|
||
say ""
|
||
choose 2 "Hourly|" "Daily|Runs overnight with a random delay." "Weekly|"
|
||
case "$CHOICE" in 1) BACKUP_SCHEDULE=hourly ;; 3) BACKUP_SCHEDULE=weekly ;; *) BACKUP_SCHEDULE=daily ;; esac
|
||
ask_number "How many to keep" "$BACKUP_KEEP" 1 3650; BACKUP_KEEP=$ANSWER
|
||
say ""
|
||
if confirm "Include the encryption keys in each bundle?" yes; then
|
||
BACKUP_KEYS=true
|
||
note "The bundle is now as sensitive as the instance itself. Anyone holding"
|
||
note "one can read every mailbox credential in it."
|
||
else
|
||
BACKUP_KEYS=false
|
||
note "Keep $DIR/keys-backup.txt somewhere else, or the bundles cannot be restored."
|
||
fi
|
||
say ""
|
||
ask "Also copy each bundle to an S3 URL (needs the aws CLI)" "$BACKUP_S3"
|
||
BACKUP_S3=$ANSWER
|
||
}
|
||
|
||
wiz_entry() {
|
||
stepper 7
|
||
out " ${WHITE}Who gets the first account?${R}"
|
||
say ""
|
||
choose 1 \
|
||
"Print a claim link|A single-use link is printed; open it and set your own password." \
|
||
"Create it now, unattended|An email plus a hash from warmblyctl hash-password."
|
||
if [ "$CHOICE" = 2 ]; then
|
||
say ""
|
||
ask "Owner email" "$BOOTSTRAP_EMAIL"; BOOTSTRAP_EMAIL=$ANSWER
|
||
ask "Password hash (argon2, from warmblyctl hash-password)" "$BOOTSTRAP_HASH"; BOOTSTRAP_HASH=$ANSWER
|
||
if [ -z "$BOOTSTRAP_HASH" ]; then
|
||
warn "No hash given, so the claim link is used instead."
|
||
BOOTSTRAP_EMAIL=""
|
||
fi
|
||
else
|
||
BOOTSTRAP_EMAIL=""
|
||
fi
|
||
|
||
say ""
|
||
out " ${WHITE}Who else may create an account?${R}"
|
||
say ""
|
||
_rdef=2
|
||
case "$REGISTRATION" in false) _rdef=1 ;; true) _rdef=3 ;; esac
|
||
choose "$_rdef" \
|
||
"Anyone|Open sign-ups. Only sensible behind a private network." \
|
||
"Invitation only|An invitation link creates the account; a stranger without one cannot." \
|
||
"Nobody|Closed. Members cannot invite either; you create every account by hand."
|
||
case "$CHOICE" in 1) REGISTRATION=false ;; 2) REGISTRATION=invite_only ;; 3) REGISTRATION=true ;; esac
|
||
|
||
say ""
|
||
out " ${WHITE}Platform mail: login codes, password resets, invitations.${R}"
|
||
note "This is not campaign sending. Campaign mail goes through the mailboxes"
|
||
note "you connect; this is Warmbly's own transactional mail."
|
||
say ""
|
||
_mdef=1; [ "$MAIL_MODE" = "smtp" ] && _mdef=2
|
||
choose "$_mdef" \
|
||
"Skip it for now|Codes and reset links go to the backend log. Not for a team." \
|
||
"An SMTP relay|Postmark, SES, Resend, or your own server."
|
||
if [ "$CHOICE" = 2 ]; then
|
||
MAIL_MODE=smtp
|
||
say ""
|
||
ask "SMTP host" "$SMTP_HOST"; SMTP_HOST=$ANSWER
|
||
say ""
|
||
out " ${WHITE}Connection security${R}"
|
||
say ""
|
||
choose 1 "STARTTLS on 587|The usual choice." "Implicit TLS on 465|" "None|A local sink only; credentials are never sent in the clear."
|
||
case "$CHOICE" in
|
||
1) SMTP_SECURITY=starttls; SMTP_PORT=587 ;;
|
||
2) SMTP_SECURITY=tls; SMTP_PORT=465 ;;
|
||
3) SMTP_SECURITY=none; SMTP_PORT=25 ;;
|
||
esac
|
||
ask "Port" "$SMTP_PORT"; SMTP_PORT=$ANSWER
|
||
ask "Username" "$SMTP_USER"; SMTP_USER=$ANSWER
|
||
ask_secret "Password:"; SMTP_PASS=$ANSWER
|
||
ask "From address" "noreply@$HOSTNAME_ANSWER"; SMTP_FROM=$ANSWER
|
||
else
|
||
MAIL_MODE=log
|
||
fi
|
||
}
|
||
|
||
wiz_footprint() {
|
||
stepper 8
|
||
out " ${WHITE}Which components?${R}"
|
||
say ""
|
||
_cdef=1; [ "$COMPONENTS" = "core" ] && _cdef=2
|
||
choose "$_cdef" \
|
||
"Everything|Adds open and click tracking, live updates, and hosted forms." \
|
||
"Core only|API, dashboard, admin, worker. No tracking, websockets or forms."
|
||
[ "$CHOICE" = 2 ] && COMPONENTS=core || COMPONENTS=full
|
||
|
||
say ""
|
||
_ports="$PORT_BACKEND $PORT_WEB $PORT_ADMIN"
|
||
[ "$COMPONENTS" = "full" ] && _ports="$_ports $PORT_TRACKING $PORT_REALTIME $PORT_FORMS"
|
||
[ "$TLS" = "caddy" ] && _ports="80 443 $_ports"
|
||
_busy=$(check_ports "$_ports")
|
||
if [ -n "$_busy" ] && [ "$DEMO" = 1 ]; then
|
||
warn "Something already listens on:$( printf ' %s' "$_busy" )"
|
||
note "A real install would stop here and ask. This one starts nothing, so"
|
||
note "it carries on."
|
||
elif [ -n "$_busy" ]; then
|
||
warn "Something already listens on:$( printf ' %s' "$_busy" )"
|
||
note "Warmbly's containers would fail to start. Free the port, or change the"
|
||
note "published port in $DIR/docker-compose.yml before starting."
|
||
say ""
|
||
confirm "Continue anyway?" no || die "nothing was changed"
|
||
else
|
||
ok "Ports free:$(printf ' %s' "$_ports")"
|
||
fi
|
||
|
||
say ""
|
||
note "The update check is one outbound call to the GitHub releases API, so the"
|
||
note "admin panel can tell you a newer version exists. There is no telemetry"
|
||
note "in Warmbly: nothing about this instance is ever sent anywhere."
|
||
say ""
|
||
if confirm "Check for new releases?" yes; then UPDATE_CHECK=true; else UPDATE_CHECK=false; fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Review
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
summary_rows() {
|
||
printf '%s\n' \
|
||
"${DIM}Version${R} ${WHITE}${RESOLVED_TAG}${R} ${DIM}from ${REGISTRY}${R}" \
|
||
"${DIM}Directory${R} ${WHITE}${DIR}${R}" \
|
||
"${DIM}Reached at${R} ${WHITE}${URL_APP}${R} ${DIM}$(tls_label)${R}" \
|
||
"${DIM}Data${R} ${WHITE}${DATA_DESC}${R}" \
|
||
"${DIM}Database${R} ${WHITE}$(db_label)${R}" \
|
||
"${DIM}Blobs${R} ${WHITE}$(blob_label)${R}" \
|
||
"${DIM}Import${R} ${WHITE}${SYNC_BACKFILL_DAYS} days, up to ${SYNC_BACKFILL_MESSAGES} messages per mailbox${R}" \
|
||
"${DIM}Kept${R} ${WHITE}opens/clicks ${RET_ENGAGEMENT}d, forms ${RET_FORMS}d, audit ${RET_AUDIT}d, warmup mail ${RET_WARMUP_MAIL}d, records ${RET_WARMUP_EVENTS}d${R}" \
|
||
"${DIM}Backups${R} ${WHITE}$(backup_label)${R}" \
|
||
"${DIM}First owner${R} ${WHITE}$(owner_label)${R}" \
|
||
"${DIM}Sign-ups${R} ${WHITE}$(registration_label)${R}" \
|
||
"${DIM}Platform mail${R} ${WHITE}$(mail_label)${R}" \
|
||
"${DIM}Components${R} ${WHITE}$(components_label)${R}" \
|
||
"${DIM}Update check${R} ${WHITE}${UPDATE_CHECK}${R}"
|
||
}
|
||
|
||
tls_label() {
|
||
case "$TLS" in
|
||
caddy) printf 'automatic HTTPS via the bundled Caddy' ;;
|
||
proxy) printf 'behind your reverse proxy, bound to 127.0.0.1' ;;
|
||
*) printf 'plain HTTP' ;;
|
||
esac
|
||
}
|
||
db_label() { [ "$BUNDLED_PG" = 1 ] && printf 'bundled Postgres 16' || printf 'external'; }
|
||
blob_label() {
|
||
if [ "$BLOBS" = "s3" ]; then printf 'S3-compatible (%s)' "${S3_BUCKET:-no bucket set}"
|
||
else printf 'filesystem, %s' "$V_BLOBS"; fi
|
||
}
|
||
backup_label() {
|
||
[ -n "$BACKUP_DIR" ] || { printf 'none scheduled'; return; }
|
||
printf '%s into %s, keeping %s' "$BACKUP_SCHEDULE" "$BACKUP_DIR" "$BACKUP_KEEP"
|
||
}
|
||
owner_label() { [ -n "$BOOTSTRAP_EMAIL" ] && printf '%s' "$BOOTSTRAP_EMAIL" || printf 'claim link printed at the end'; }
|
||
registration_label() {
|
||
case "$REGISTRATION" in
|
||
false) printf 'anyone' ;;
|
||
true) printf 'nobody' ;;
|
||
*) printf 'invitation only' ;;
|
||
esac
|
||
}
|
||
mail_label() { [ "$MAIL_MODE" = "smtp" ] && printf 'SMTP via %s' "$SMTP_HOST" || printf 'log only (codes printed to the backend log)'; }
|
||
components_label() { [ "$COMPONENTS" = "core" ] && printf 'core only' || printf 'everything (tracking, realtime, forms)'; }
|
||
|
||
review() {
|
||
while :; do
|
||
stepper 9
|
||
summary_rows | box "$SKY"
|
||
say ""
|
||
if [ "$INTERACTIVE" = 0 ] || [ "$ASSUME_YES" = 1 ]; then
|
||
return 0
|
||
fi
|
||
out " ${DIM}${WHITE}enter${DIM} install ${WHITE}e${DIM} change a section ${WHITE}q${DIM} quit${R}"
|
||
# One keypress, like the menus, and only the three keys that mean
|
||
# something act: an unrecognised one redraws rather than installing.
|
||
raw_on
|
||
read_key
|
||
raw_off
|
||
case "$KEY" in
|
||
QUIT|ESC|INT) die "nothing was changed" ;;
|
||
ENTER|SELECT|105|121) return 0 ;;
|
||
101)
|
||
say ""
|
||
out " ${WHITE}Which section?${R}"
|
||
say ""
|
||
choose 3 \
|
||
"Where it lives|" "How it is reached|" "Where the data sits|" \
|
||
"What is kept|" "Backups|" "Who gets in|" "Footprint|"
|
||
case "$CHOICE" in
|
||
1) wiz_location ;;
|
||
2) wiz_access ;;
|
||
3) wiz_storage ;;
|
||
4) wiz_retention ;;
|
||
5) wiz_backups ;;
|
||
6) wiz_entry ;;
|
||
7) wiz_footprint ;;
|
||
esac
|
||
derive
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Doing it
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
prepare_dirs() {
|
||
[ "$DRY_RUN" = 1 ] && return 0
|
||
if ! mkdir -p "$DIR" 2>/dev/null; then
|
||
need_sudo || fail_with "Cannot create $DIR and sudo is not available." \
|
||
"Pick a directory you can write to: --dir ~/warmbly"
|
||
info "Creating $DIR (this step needs sudo)"
|
||
$SUDO mkdir -p "$DIR"
|
||
$SUDO chown "$(id -u):$(id -g)" "$DIR"
|
||
fi
|
||
[ -w "$DIR" ] || {
|
||
need_sudo || fail_with "$DIR is not writable and sudo is not available."
|
||
$SUDO chown "$(id -u):$(id -g)" "$DIR"
|
||
}
|
||
if [ "$DATA_ROOT" != "volumes" ]; then
|
||
for _d in "$V_PG" "$V_REDIS" "$V_NATS" "$V_BLOBS" "$V_WORKER" "$V_UPDATER"; do
|
||
mkdir -p "$_d" 2>/dev/null || {
|
||
need_sudo || fail_with "Cannot create the data root $DATA_ROOT and sudo is not available."
|
||
$SUDO mkdir -p "$_d"
|
||
$SUDO chown "$(id -u):$(id -g)" "$_d"
|
||
}
|
||
done
|
||
# The backend and worker run as uid 1000 inside their containers, so a
|
||
# blob root owned by anyone else means the first send fails on mkdir.
|
||
chown -R 1000:1000 "$V_BLOBS" "$V_WORKER" 2>/dev/null ||
|
||
{ [ -n "$SUDO" ] && $SUDO chown -R 1000:1000 "$V_BLOBS" "$V_WORKER" 2>/dev/null; } || true
|
||
fi
|
||
}
|
||
|
||
write_all() {
|
||
_env_body=$(render_env)
|
||
if [ -f "$DIR/.env" ] && [ "$DRY_RUN" = 0 ]; then
|
||
backup_existing "$DIR/.env"
|
||
_env_body=$(merge_env "$(cat "$DIR/.env")" "$_env_body")
|
||
fi
|
||
write_file "$DIR/.env" "$_env_body" 0600
|
||
|
||
backup_existing "$DIR/docker-compose.yml"
|
||
write_file "$DIR/docker-compose.yml" "$(render_compose)" 0644
|
||
|
||
[ "$WANT_CADDY" = 1 ] && write_file "$DIR/Caddyfile" "$(render_caddyfile)" 0644
|
||
|
||
if [ "$EXISTING" = 0 ]; then
|
||
write_file "$DIR/keys-backup.txt" "$(render_keys_backup)" 0600
|
||
fi
|
||
write_file "$DIR/$MARKER" "$(printf 'installed_at=%s\nversion=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$RESOLVED_TAG")" 0644
|
||
install_backup_timer
|
||
}
|
||
|
||
render_keys_backup() {
|
||
cat <<KEYSEOF
|
||
# Warmbly instance keys, $(date -u '+%Y-%m-%d %H:%M:%S UTC').
|
||
#
|
||
# The first two cannot be recovered. Every mailbox credential and every
|
||
# per-organization key on this instance is sealed with them, so a database
|
||
# backup that does not travel with them restores an instance whose mailboxes
|
||
# authenticate against nothing.
|
||
#
|
||
# This file sits on the same disk as the database it protects, which means it
|
||
# is not a backup. Copy it somewhere else.
|
||
|
||
CREDENTIALS_ENCRYPTION_KEY=$CREDENTIALS_ENCRYPTION_KEY
|
||
KMS_LOCAL_MASTER_KEY=$KMS_LOCAL_MASTER_KEY
|
||
|
||
# These are replaceable, at the cost of signing everyone out and
|
||
# reconfiguring the workers.
|
||
AUTH_SECRET=$AUTH_SECRET
|
||
INTERNAL_API_TOKEN=$INTERNAL_API_TOKEN
|
||
$(render_env_node_token)
|
||
UPDATER_TOKEN=$UPDATER_TOKEN
|
||
SECRET_KEY_BASE=$SECRET_KEY_BASE
|
||
KEYSEOF
|
||
}
|
||
|
||
# A registry that refuses to serve an image answers "unauthorized", which the
|
||
# generic message below would report as a missing tag and send the operator
|
||
# looking in entirely the wrong place. Every Warmbly image is meant to be
|
||
# pullable with no account at all, so this is a bug in the release rather than
|
||
# anything wrong with their machine (#371).
|
||
pull_failed() {
|
||
show_log
|
||
if [ -s "$LOGFILE" ] &&
|
||
grep -Eqi 'unauthorized|denied|authentication required' "$LOGFILE"; then
|
||
_host=${REGISTRY%%/*}
|
||
fail_with "The registry refused to serve the release images." \
|
||
"It answered unauthorized rather than 'no such tag', so" \
|
||
"${RESOLVED_TAG} is probably fine and the images are simply" \
|
||
"not readable without an account." \
|
||
"" \
|
||
"Every Warmbly image is meant to be public. If you did not" \
|
||
"pass --registry, that makes this our bug, not yours:" \
|
||
"please tell us at https://github.com/$REPO/issues" \
|
||
"" \
|
||
"If you did pass it, check it for a typo. A namespace that" \
|
||
"does not exist is refused in exactly the same way as a" \
|
||
"private one." \
|
||
"" \
|
||
"To get past it now: docker login $_host as someone who can" \
|
||
"read these images, or point --registry at a mirror you can."
|
||
fi
|
||
fail_with "Could not pull the release images." \
|
||
"The tag ${RESOLVED_TAG} may not exist, or this host cannot" \
|
||
"reach ${REGISTRY}." \
|
||
"Releases: https://github.com/$REPO/releases"
|
||
}
|
||
|
||
# pull_images shows one row per image, flipping to a tick the moment that image
|
||
# lands. The pull itself runs in the background, so this is compose's own
|
||
# parallel pull with a readable face on it rather than a serial one.
|
||
pull_images() {
|
||
_refs=$(cd "$DIR" && composec config --images 2>/dev/null | sort -u)
|
||
if [ -z "$_refs" ]; then
|
||
spin "Pulling images" sh -c "cd '$DIR' && $COMPOSE pull" || pull_failed
|
||
return 0
|
||
fi
|
||
_total=$(printf '%s\n' "$_refs" | wc -l | tr -d ' ')
|
||
|
||
( cd "$DIR" && composec pull ) >"$LOGFILE" 2>&1 &
|
||
_pid=$!
|
||
_start=$(now_s)
|
||
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
say " ... pulling $_total images"
|
||
wait "$_pid" 2>/dev/null && _rc=0 || _rc=$?
|
||
[ "$_rc" = 0 ] && say " ok pulled $_total images"
|
||
[ "$_rc" = 0 ] || pull_failed
|
||
return 0
|
||
fi
|
||
|
||
printf '%b' "$HIDE"
|
||
_first=1; _f=1; _tick=0
|
||
# The set of images already on disk is asked of the daemon about once a
|
||
# second and then matched locally. Inspecting each image on every animation
|
||
# frame would be a dozen daemon round trips ten times a second, which
|
||
# competes with the pull it is drawing.
|
||
_present=""
|
||
while :; do
|
||
kill -0 "$_pid" 2>/dev/null && _running=1 || _running=0
|
||
if [ "$_tick" = 0 ]; then
|
||
_present=$(dockerc image ls --format '{{.Repository}}:{{.Tag}}' 2>/dev/null || true)
|
||
fi
|
||
_tick=$((_tick + 1)); [ "$_tick" -ge 8 ] && _tick=0
|
||
|
||
[ "$_first" = 1 ] || printf '%b' "${ESC}[$((_total + 1))A"
|
||
_first=0
|
||
_have=0
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
for _ref in $_refs; do
|
||
printf '%b' "${ESC}[2K"
|
||
if printf '%s\n' "$_present" | grep -qxF "$_ref"; then
|
||
_have=$((_have + 1))
|
||
out " ${GREEN}✓${R} ${DIM}$(short_ref "$_ref")${R}"
|
||
else
|
||
out " ${SKY}${_frame}${R} ${GREY}$(short_ref "$_ref")${R}"
|
||
fi
|
||
done
|
||
printf '%b' "${ESC}[2K"
|
||
out " $(bar "$_have" "$_total" 24) ${WHITE}${_have}/${_total}${R} ${DIM}$(elapsed_since "$_start")${R}"
|
||
[ "$_running" = 0 ] && break
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.12
|
||
done
|
||
printf '%b' "$SHOW"
|
||
wait "$_pid" 2>/dev/null && _rc=0 || _rc=$?
|
||
if [ "$_rc" != 0 ]; then
|
||
pull_failed
|
||
fi
|
||
}
|
||
|
||
# short_ref trims the registry namespace so the rows read as service names.
|
||
short_ref() {
|
||
_r=$1
|
||
case "$_r" in
|
||
"$REGISTRY"/*) printf '%s' "${_r#"$REGISTRY"/}" ;;
|
||
*) printf '%s' "$_r" ;;
|
||
esac
|
||
}
|
||
|
||
# verify_digests checks what was actually pulled against the digests the
|
||
# release published, so "curl | sh" is checkable after the fact and not only
|
||
# before it. Never a warning that is really a failure: a mismatch stops the
|
||
# install, and a manifest that cannot be read says so and continues, because an
|
||
# older release, an air-gapped mirror and a private registry all legitimately
|
||
# have none.
|
||
verify_digests() {
|
||
[ "$REGISTRY" = "$DEFAULT_REGISTRY" ] || {
|
||
note "Images came from $REGISTRY, so the release manifest does not describe them; skipped."
|
||
return 0
|
||
}
|
||
_man="$TMPDIR_RUN/images.json"
|
||
if ! curl -fsSL "https://github.com/$REPO/releases/download/$RESOLVED_TAG/images.json" -o "$_man" 2>/dev/null; then
|
||
note "No image manifest published for $RESOLVED_TAG, so the pulled digests were not checked."
|
||
return 0
|
||
fi
|
||
|
||
_checked=0; _bad=""
|
||
for _svc in backend consumer worker forms updater web admin tracking realtime; do
|
||
_want=$(sed -n "s/.*\"${_svc}\": *\"\([^\"]*\)\".*/\1/p" "$_man" | head -1)
|
||
[ -n "$_want" ] || continue
|
||
_ref="$REGISTRY/$_svc:$RESOLVED_TAG"
|
||
_got=$(dockerc image inspect "$_ref" --format '{{range .RepoDigests}}{{println .}}{{end}}' 2>/dev/null |
|
||
sed -n "s|^${REGISTRY}/${_svc}@||p" | head -1)
|
||
# Not pulled at all is not a mismatch: a core-only install has no
|
||
# tracking, realtime or forms image and the manifest still lists them.
|
||
[ -n "$_got" ] || continue
|
||
_checked=$((_checked + 1))
|
||
[ "$_got" = "$_want" ] || _bad="$_bad $_svc"
|
||
done
|
||
|
||
if [ -n "$_bad" ]; then
|
||
fail_with "The images on this machine are not the ones release $RESOLVED_TAG published." \
|
||
"Mismatched:$_bad" \
|
||
"Nothing was started. Remove those images and run this again; if it happens twice," \
|
||
"do not run this instance and open an issue at https://github.com/$REPO/issues."
|
||
fi
|
||
if [ "$_checked" -gt 0 ]; then
|
||
ok "Verified $_checked images against the $RESOLVED_TAG manifest"
|
||
fi
|
||
}
|
||
|
||
start_stack() {
|
||
( cd "$DIR" && composec up -d --remove-orphans ) >"$LOGFILE" 2>&1 &
|
||
_pid=$!
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
wait "$_pid" 2>/dev/null && _rc=0 || _rc=$?
|
||
else
|
||
printf '%b' "$HIDE"; _f=1
|
||
while kill -0 "$_pid" 2>/dev/null; do
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
printf '\r %b%s%b Starting the stack' "$SKY" "$_frame" "$R"
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.08
|
||
done
|
||
printf '\r%b' "${ESC}[2K"; printf '%b' "$SHOW"
|
||
wait "$_pid" 2>/dev/null && _rc=0 || _rc=$?
|
||
fi
|
||
if [ "$_rc" != 0 ]; then
|
||
show_log
|
||
fail_with "The stack did not start." "Read the whole log with: cd $DIR && docker compose -p warmbly logs"
|
||
fi
|
||
ok "Containers created"
|
||
}
|
||
|
||
# wait_healthy watches the API come up. The first boot applies every migration,
|
||
# so this is the slow step and it says what it is waiting for rather than
|
||
# spinning silently.
|
||
wait_healthy() {
|
||
_url="http://127.0.0.1:${PORT_BACKEND}/health"
|
||
_start=$(now_s)
|
||
_deadline=$(( _start + 300 ))
|
||
_f=1; _tick=0
|
||
[ "$USE_COLOR" = 1 ] && printf '%b' "$HIDE"
|
||
while :; do
|
||
# Probed about once a second; the frames in between are just animation.
|
||
if [ "$_tick" = 0 ] && curl -fsS --max-time 2 "$_url" >/dev/null 2>&1; then
|
||
clear_line
|
||
ok "The API is answering"
|
||
return 0
|
||
fi
|
||
if [ "$(now_s)" -gt "$_deadline" ]; then
|
||
clear_line
|
||
warn "The API has not answered in five minutes."
|
||
note "It is probably still applying migrations, or one service is failing."
|
||
note "Watch it: cd $DIR && docker compose -p warmbly logs -f backend"
|
||
return 1
|
||
fi
|
||
if [ "$USE_COLOR" = 1 ]; then
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
printf '\r%b %b%s%b %s %b(%s)%b' "${ESC}[2K" \
|
||
"$SKY" "$_frame" "$R" "$(health_label)" "$DIM" "$(elapsed_since "$_start")" "$R"
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
_tick=$((_tick + 1)); [ "$_tick" -ge 8 ] && _tick=0
|
||
nap 0.12
|
||
else
|
||
sleep 2 2>/dev/null || true
|
||
fi
|
||
done
|
||
}
|
||
|
||
# health_label is the wait's status text, long enough to explain itself on a
|
||
# normal terminal and short enough never to wrap on a narrow one. Every
|
||
# character of a redrawn line has to fit or the \r only rewinds the last
|
||
# physical row and the rest is left on screen.
|
||
health_label() {
|
||
if [ "$COLS" -ge 76 ]; then
|
||
printf 'Waiting for the API, migrations run on this first boot'
|
||
else
|
||
printf 'Waiting for the API'
|
||
fi
|
||
}
|
||
|
||
# clear_line wipes the one-line status a spinner leaves behind and puts the
|
||
# cursor back.
|
||
clear_line() {
|
||
[ "$USE_COLOR" = 1 ] || return 0
|
||
printf '\r%b%b' "${ESC}[2K" "$SHOW"
|
||
}
|
||
|
||
show_log() {
|
||
[ -s "$LOGFILE" ] || return 0
|
||
say ""
|
||
out " ${DIM}last lines of the log:${R}"
|
||
tail -20 "$LOGFILE" | sed 's/^/ /'
|
||
say ""
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# The end of a successful run
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
claim_link() {
|
||
if [ "$DEMO" = 1 ]; then
|
||
printf '%s/setup/demo-link-not-a-real-token' "$URL_APP"
|
||
return 0
|
||
fi
|
||
# setup-link mints a fresh single-use link, and only on an instance with no
|
||
# accounts. On one that already has them it fails, which is the correct
|
||
# answer: there is nothing left to claim.
|
||
( cd "$DIR" && composec exec -T backend warmblyctl setup-link 2>/dev/null ) |
|
||
sed -n 's|.*\(https\{0,1\}://[^ ]*\)|\1|p' | head -1
|
||
}
|
||
|
||
finale() {
|
||
say ""
|
||
rule
|
||
say ""
|
||
out " ${GREEN}${B}Warmbly ${RESOLVED_TAG} is running.${R}"
|
||
say ""
|
||
|
||
_link=$(claim_link || true)
|
||
if [ -n "$_link" ]; then
|
||
printf '%s\n' \
|
||
"${B}${WHITE}Claim this instance${R}" \
|
||
"${DIM}Single use, and it expires. Until an account exists it is the${R}" \
|
||
"${DIM}only way in.${R}" \
|
||
"" \
|
||
"${SKY}${_link}${R}" \
|
||
| box "$GREEN"
|
||
elif [ -n "$BOOTSTRAP_EMAIL" ]; then
|
||
printf '%s\n' \
|
||
"${B}${WHITE}Sign in${R}" \
|
||
"${DIM}The first owner was created from the environment.${R}" \
|
||
"" \
|
||
"${SKY}${URL_APP}${R} ${DIM}as ${BOOTSTRAP_EMAIL}${R}" \
|
||
| box "$GREEN"
|
||
else
|
||
printf '%s\n' \
|
||
"${B}${WHITE}Sign in${R}" \
|
||
"${SKY}${URL_APP}${R}" \
|
||
"" \
|
||
"${DIM}This instance already has accounts, so no claim link was issued.${R}" \
|
||
| box "$GREEN"
|
||
fi
|
||
|
||
say ""
|
||
out " ${B}Where things are${R}"
|
||
out " ${DIM}Dashboard${R} $URL_APP"
|
||
out " ${DIM}Admin${R} $URL_ADMIN"
|
||
out " ${DIM}API${R} $URL_API"
|
||
[ "$WANT_FORMS" = 1 ] && out " ${DIM}Forms${R} ${SCHEME}://$FORMS_DOMAIN"
|
||
out " ${DIM}Install${R} $DIR"
|
||
out " ${DIM}Data${R} $DATA_DESC"
|
||
|
||
if [ "$TLS" = "caddy" ]; then
|
||
say ""
|
||
out " ${B}DNS${R} ${DIM}each of these must resolve to this machine${R}"
|
||
for _h in "$H_APP" "$H_ADMIN" "$H_API" "$H_WS" "$H_TRACK" "$H_FORMS"; do
|
||
out " ${DIM}A${R} $_h"
|
||
done
|
||
note "Caddy retries until they do; until then the names return an error."
|
||
fi
|
||
|
||
if [ "$MAIL_MODE" = "log" ]; then
|
||
say ""
|
||
out " ${AMBER}!${R} ${B}Platform mail goes to the log${R}"
|
||
note "Password resets, login codes and invitations are printed, not sent."
|
||
note "From $DIR:"
|
||
note " docker compose -p warmbly logs backend | grep -i code"
|
||
note "Set MAIL_TRANSPORT=smtp in .env before anyone else uses this instance."
|
||
fi
|
||
|
||
say ""
|
||
out " ${B}Next${R} ${DIM}from ${DIR}, where compose reads the .env${R}"
|
||
out " ${DIM}Health${R} docker compose -p warmbly exec backend warmblyctl status"
|
||
out " ${DIM}Logs${R} docker compose -p warmbly logs -f"
|
||
out " ${DIM}Back up${R} docker compose -p warmbly exec backend warmblyctl backup"
|
||
out " ${DIM}Update${R} the version pill in the admin panel, or compose pull + up -d"
|
||
out " ${DIM}Guide${R} $DOCS/development/first-run/"
|
||
out " ${DIM}Mailboxes${R} $DOCS/guides/mailboxes/"
|
||
say ""
|
||
note "A mail server on this machine, such as Proton Bridge on 127.0.0.1,"
|
||
note "connects with Security: None. That mode is offered only here, where"
|
||
note "the worker and the relay share a host and no password reaches a wire."
|
||
say ""
|
||
if [ "$EXISTING" = 0 ]; then
|
||
out " ${RED}Keep a copy of $DIR/keys-backup.txt somewhere other than this machine.${R}"
|
||
out " ${DIM}Without those two keys a database backup cannot open a single mailbox.${R}"
|
||
say ""
|
||
fi
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Demo
|
||
#
|
||
# --demo walks the whole install with every side effect removed: the wizard and
|
||
# the review are the real ones, and the pull, the start and the health wait are
|
||
# played rather than run. It exists because "what does it look like" is a fair
|
||
# question to ask before pointing a script at your server, and answering it
|
||
# should not require a server.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
# The played phases are paced to be watchable, not to match a real install:
|
||
# a real pull is a minute or two and a first boot with migrations another
|
||
# half, which nobody wants to sit through to see what a screen looks like.
|
||
# One beat is roughly a second. WARMBLY_DEMO_FAST=1 collapses them while
|
||
# iterating on the UI itself.
|
||
DEMO_BEAT=14
|
||
[ -n "${WARMBLY_DEMO_FAST:-}" ] && DEMO_BEAT=3
|
||
|
||
demo_banner() {
|
||
printf '%s\n' \
|
||
"${B}${WHITE}Demo. Nothing is installed and nothing is written.${R}" \
|
||
"" \
|
||
"${DIM}The questions, the keys and the review below are the real ones. The${R}" \
|
||
"${DIM}pull and the start at the end are played, not run: no Docker is${R}" \
|
||
"${DIM}touched, no image is pulled, and no file is created anywhere.${R}" \
|
||
"" \
|
||
"${DIM}The real thing: curl -fsSL https://warmbly.com/install.sh | sh${R}" \
|
||
| box "$AMBER"
|
||
say ""
|
||
}
|
||
|
||
# demo_refs is the image list the chosen component set would pull.
|
||
demo_refs() {
|
||
for _s in backend consumer worker web admin updater; do
|
||
printf '%s/%s:%s\n' "$REGISTRY" "$_s" "$RESOLVED_TAG"
|
||
done
|
||
if [ "$COMPONENTS" = "full" ]; then
|
||
for _s in tracking realtime forms; do
|
||
printf '%s/%s:%s\n' "$REGISTRY" "$_s" "$RESOLVED_TAG"
|
||
done
|
||
fi
|
||
[ "$BUNDLED_PG" = 1 ] && printf 'postgres:16-alpine\n'
|
||
[ "$BUNDLED_REDIS" = 1 ] && printf 'redis:7-alpine\n'
|
||
printf 'nats:2.10-alpine\n'
|
||
[ "$WANT_CADDY" = 1 ] && printf 'caddy:2-alpine\n'
|
||
return 0
|
||
}
|
||
|
||
# demo_pull is pull_images with the pull replaced by a clock. The rows fill in
|
||
# over a fixed number of frames rather than by asking the daemon anything.
|
||
demo_pull() {
|
||
_refs=$(demo_refs)
|
||
_total=$(printf '%s\n' "$_refs" | wc -l | tr -d ' ')
|
||
_start=$(now_s)
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
say " ... pulling $_total images"
|
||
say " ok pulled $_total images"
|
||
return 0
|
||
fi
|
||
# Paced off the beat and the image count together, so the bar moves at a
|
||
# readable rate whether the answers selected six services or thirteen.
|
||
_step=$(((DEMO_BEAT * 6 / _total) + 2))
|
||
_frames=$((_total * _step))
|
||
_n=0; _f=1; _first=1
|
||
printf '%b' "$HIDE"
|
||
while [ "$_n" -le "$_frames" ]; do
|
||
_have=$((_n / _step))
|
||
[ "$_have" -gt "$_total" ] && _have=$_total
|
||
[ "$_first" = 1 ] || printf '%b' "${ESC}[$((_total + 1))A"
|
||
_first=0
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
_i=0
|
||
for _ref in $_refs; do
|
||
_i=$((_i + 1))
|
||
printf '%b' "${ESC}[2K"
|
||
if [ "$_i" -le "$_have" ]; then
|
||
out " ${GREEN}✓${R} ${DIM}$(short_ref "$_ref")${R}"
|
||
else
|
||
out " ${SKY}${_frame}${R} ${GREY}$(short_ref "$_ref")${R}"
|
||
fi
|
||
done
|
||
printf '%b' "${ESC}[2K"
|
||
out " $(bar "$_have" "$_total" 24) ${WHITE}${_have}/${_total}${R} ${DIM}$(elapsed_since "$_start")${R}"
|
||
_n=$((_n + 1))
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.07
|
||
done
|
||
printf '%b' "$SHOW"
|
||
}
|
||
|
||
# demo_services is the container list the chosen answers would create, in the
|
||
# order compose brings them up.
|
||
demo_services() {
|
||
[ "$BUNDLED_PG" = 1 ] && printf 'postgres\n'
|
||
[ "$BUNDLED_REDIS" = 1 ] && printf 'redis\n'
|
||
printf 'nats\nbackend\nconsumer\nworker\nweb\nadmin\n'
|
||
if [ "$COMPONENTS" = "full" ]; then
|
||
printf 'tracking\nrealtime\nforms\n'
|
||
fi
|
||
printf 'updater\n'
|
||
[ "$WANT_CADDY" = 1 ] && printf 'caddy\n'
|
||
return 0
|
||
}
|
||
|
||
# demo_start plays the container creation the way compose reports it: one row
|
||
# per service, each moving from pending to creating to started. This is the
|
||
# step that is over in a blink when it is only a spinner, and it is the one
|
||
# where an operator most wants to see what the answers actually built.
|
||
demo_start() {
|
||
_svcs=$(demo_services)
|
||
_total=$(printf '%s\n' "$_svcs" | wc -l | tr -d ' ')
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
for _s in $_svcs; do say " ok Container warmbly-${_s}-1 Started"; done
|
||
ok "Containers created"
|
||
return 0
|
||
fi
|
||
_step=$(((DEMO_BEAT * 5 / _total) + 2))
|
||
_frames=$((_total * _step + _step))
|
||
_n=0; _f=1; _first=1
|
||
printf '%b' "$HIDE"
|
||
while [ "$_n" -le "$_frames" ]; do
|
||
_done=$((_n / _step))
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
[ "$_first" = 1 ] || printf '%b' "${ESC}[${_total}A"
|
||
_first=0
|
||
_i=0
|
||
for _s in $_svcs; do
|
||
_i=$((_i + 1))
|
||
printf '%b' "${ESC}[2K"
|
||
if [ "$_i" -le "$_done" ]; then
|
||
out " ${GREEN}✓${R} ${DIM}Container warmbly-${_s}-1${R} ${DIM}Started${R}"
|
||
elif [ "$_i" = $((_done + 1)) ]; then
|
||
out " ${SKY}${_frame}${R} ${WHITE}Container warmbly-${_s}-1${R} ${DIM}Creating${R}"
|
||
else
|
||
out " ${GREY}Container warmbly-${_s}-1${R}"
|
||
fi
|
||
done
|
||
_n=$((_n + 1))
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.07
|
||
done
|
||
printf '%b' "$SHOW"
|
||
ok "Containers created"
|
||
}
|
||
|
||
# demo_wait plays the wait_healthy screen, with a real clock. A first boot
|
||
# applies every migration and genuinely takes this long or longer, so this is
|
||
# the one phase where the demo being slow is the demo being honest.
|
||
demo_wait() {
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
ok "The API is answering"
|
||
return 0
|
||
fi
|
||
_start=$(now_s)
|
||
_frames=$((DEMO_BEAT * 10))
|
||
_n=0; _f=1
|
||
printf '%b' "$HIDE"
|
||
while [ "$_n" -lt "$_frames" ]; do
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
printf '\r%b %b%s%b %s %b(%s)%b' "${ESC}[2K" \
|
||
"$SKY" "$_frame" "$R" "$(health_label)" "$DIM" "$(elapsed_since "$_start")" "$R"
|
||
_n=$((_n + 1))
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.07
|
||
done
|
||
clear_line
|
||
ok "The API is answering"
|
||
}
|
||
|
||
# demo_spin plays one labelled spinner for a fixed number of frames.
|
||
demo_spin() {
|
||
_label=$1; _frames=$2
|
||
if [ "$USE_COLOR" = 0 ]; then
|
||
ok "$_label"
|
||
return 0
|
||
fi
|
||
_n=0; _f=1
|
||
printf '%b' "$HIDE"
|
||
while [ "$_n" -lt "$_frames" ]; do
|
||
_frame=$(printf '%s' "$SPIN_FRAMES" | cut -c"$_f")
|
||
printf '\r %b%s%b %s' "$SKY" "$_frame" "$R" "$_label"
|
||
_n=$((_n + 1))
|
||
_f=$((_f + 1)); [ "$_f" -gt 10 ] && _f=1
|
||
nap 0.07
|
||
done
|
||
clear_line
|
||
ok "$_label"
|
||
}
|
||
|
||
demo_install() {
|
||
step "Installing Warmbly $RESOLVED_TAG into $DIR"
|
||
say ""
|
||
demo_spin "Wrote .env and docker-compose.yml" "$DEMO_BEAT"
|
||
say ""
|
||
demo_pull
|
||
say ""
|
||
demo_start
|
||
demo_wait
|
||
finale
|
||
printf '%s\n' \
|
||
"${B}${WHITE}That was the demo.${R}" \
|
||
"" \
|
||
"${DIM}No file was written, no container was created, and the keys printed${R}" \
|
||
"${DIM}above were thrown away. To do it for real:${R}" \
|
||
"" \
|
||
"${SKY}curl -fsSL https://warmbly.com/install.sh | sh -s -- --wizard${R}" \
|
||
| box "$AMBER"
|
||
say ""
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Uninstall
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
uninstall() {
|
||
banner
|
||
[ -f "$DIR/docker-compose.yml" ] || fail_with "No Warmbly install at $DIR." "Point at it with --dir."
|
||
step "Removing the Warmbly stack in $DIR"
|
||
say ""
|
||
if [ "$PURGE_DATA" = 0 ]; then
|
||
info "Containers and networks only. Your data stays where it is."
|
||
note "Data root: $(env_get "$DIR/.env" WARMBLY_PG_DATA | xargs dirname 2>/dev/null || echo 'see .env')"
|
||
note "Add --purge-data to delete it too."
|
||
say ""
|
||
confirm "Stop and remove the containers?" yes || die "nothing was changed"
|
||
( cd "$DIR" && composec down --remove-orphans ) || true
|
||
ok "Containers removed. $DIR and its data are untouched."
|
||
return 0
|
||
fi
|
||
|
||
_pg=$(env_get "$DIR/.env" WARMBLY_PG_DATA)
|
||
say ""
|
||
printf '%s\n' \
|
||
"${RED}${B}This deletes every organization, mailbox, contact and message${R}" \
|
||
"${DIM}on this instance, and the keys that could have opened a backup of it.${R}" \
|
||
"" \
|
||
"${DIM}Removing:${R} ${WHITE}${DIR}${R}" \
|
||
"${DIM} ${R} ${WHITE}${_pg:-the configured stores}${R} ${DIM}and the rest of the data root${R}" \
|
||
"" \
|
||
"${DIM}There is no undo, and no copy is made first.${R}" \
|
||
| box "$RED"
|
||
say ""
|
||
if [ "$ASSUME_YES" = 0 ]; then
|
||
confirm_phrase "Type 'delete everything' to confirm:" "delete everything" ||
|
||
die "nothing was changed"
|
||
fi
|
||
( cd "$DIR" && composec down -v --remove-orphans ) || true
|
||
if have systemctl && [ -f /etc/systemd/system/warmbly-backup.timer ]; then
|
||
need_sudo && {
|
||
$SUDO systemctl disable --now warmbly-backup.timer >/dev/null 2>&1 || true
|
||
$SUDO rm -f /etc/systemd/system/warmbly-backup.timer /etc/systemd/system/warmbly-backup.service
|
||
$SUDO systemctl daemon-reload >/dev/null 2>&1 || true
|
||
}
|
||
fi
|
||
case "$_pg" in
|
||
/*)
|
||
_root=$(dirname "$_pg")
|
||
rm -rf "$_root" 2>/dev/null || { need_sudo && $SUDO rm -rf "$_root"; } || true
|
||
;;
|
||
esac
|
||
rm -rf "$DIR" 2>/dev/null || { need_sudo && $SUDO rm -rf "$DIR"; } || true
|
||
say ""
|
||
ok "Warmbly is gone."
|
||
say ""
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# Arguments
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
usage() {
|
||
cat <<USAGE
|
||
$SCRIPT_NAME
|
||
|
||
curl -fsSL https://warmbly.com/install.sh | sh
|
||
curl -fsSL https://warmbly.com/install.sh | sh -s -- --wizard
|
||
|
||
Modes
|
||
--wizard Ask the full question set: where each store lives, what
|
||
is kept and for how long, how it is backed up. Every
|
||
answer defaults to the fast path, so pressing enter
|
||
through it installs exactly what the fast path does.
|
||
-y, --yes Never ask. Every unanswered question takes its default.
|
||
--dry-run Print the .env and compose file it would write, and stop.
|
||
--demo Walk the whole thing with nothing installed: the wizard,
|
||
the review, and a simulated pull and start. Writes no
|
||
file, pulls no image, needs no Docker. For seeing what
|
||
the install looks like before you run one.
|
||
--print-env Print the .env and stop.
|
||
--uninstall Stop and remove the containers. Refuses to touch data.
|
||
--purge-data With --uninstall: delete the data root as well.
|
||
|
||
Answers environment variable
|
||
--dir PATH Install directory WARMBLY_DIR [$DEFAULT_DIR]
|
||
--host HOST Public hostname WARMBLY_HOST [localhost]
|
||
--tls MODE none|caddy|proxy WARMBLY_TLS [none]
|
||
--data-root PATH Or the word 'volumes' WARMBLY_DATA_ROOT [<dir>/data]
|
||
--blobs MODE filesystem|s3 WARMBLY_BLOBS [filesystem]
|
||
--database-url URL Use an external Postgres WARMBLY_DATABASE_URL
|
||
--redis-url URL Use an external Redis WARMBLY_REDIS_URL
|
||
--components SET core|full WARMBLY_COMPONENTS [full]
|
||
--version TAG Release to install WARMBLY_VERSION [newest]
|
||
--channel NAME stable|dev WARMBLY_CHANNEL [stable]
|
||
--registry PREFIX Image namespace WARMBLY_IMAGE_PREFIX [$DEFAULT_REGISTRY]
|
||
--backup-dir PATH Schedule backups into it WARMBLY_BACKUP_DIR
|
||
--backup-keep N How many to keep WARMBLY_BACKUP_KEEP [14]
|
||
--retention-preset P default|minimal (sets the five windows)
|
||
--no-update-check No outbound call at all WARMBLY_UPDATE_CHECK
|
||
|
||
Other
|
||
--force Install into a directory this installer did not create
|
||
--no-color Plain output
|
||
--clear Redraw the screen at each wizard step instead of
|
||
appending. Nothing above the installer is erased either
|
||
way, but appending leaves the whole run scrollable.
|
||
--no-clear The default; accepted so older invocations keep working
|
||
-h, --help This
|
||
|
||
Everything it writes lives under the install directory. It asks for sudo only
|
||
to create that directory when it is not yours, to install Docker if you say
|
||
yes, and to install the backup timer.
|
||
|
||
Docs: $DOCS/development/install/
|
||
USAGE
|
||
}
|
||
|
||
parse_args() {
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--wizard) WIZARD=1 ;;
|
||
--demo) DEMO=1; WIZARD=1 ;;
|
||
-y|--yes) ASSUME_YES=1 ;;
|
||
--dry-run) DRY_RUN=1 ;;
|
||
--print-env) PRINT_ENV=1 ;;
|
||
--uninstall) MODE=uninstall ;;
|
||
--purge-data) PURGE_DATA=1 ;;
|
||
--force) FORCE=1 ;;
|
||
--no-color) USE_COLOR=0 ;;
|
||
--clear) USE_CLEAR=1 ;;
|
||
--no-clear) USE_CLEAR=0 ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
--dir) DIR=$(need_value "$@"); shift ;;
|
||
--host) HOSTNAME_ANSWER=$(need_value "$@"); HOST_SET=1; shift ;;
|
||
--tls) TLS=$(need_value "$@"); shift ;;
|
||
--data-root) DATA_ROOT=$(need_value "$@"); DATA_ROOT_SET=1; shift ;;
|
||
--blobs) BLOBS=$(need_value "$@"); shift ;;
|
||
--database-url) EXTERNAL_DB=$(need_value "$@"); shift ;;
|
||
--redis-url) EXTERNAL_REDIS=$(need_value "$@"); shift ;;
|
||
--components) COMPONENTS=$(need_value "$@"); shift ;;
|
||
--version) VERSION=$(need_value "$@"); VERSION_SET=1; shift ;;
|
||
--channel) CHANNEL=$(need_value "$@"); shift ;;
|
||
--registry) REGISTRY=$(need_value "$@"); shift ;;
|
||
--backup-dir) BACKUP_DIR=$(need_value "$@"); shift ;;
|
||
--backup-keep) BACKUP_KEEP=$(need_value "$@"); shift ;;
|
||
--retention-preset)
|
||
case "$(need_value "$@")" in
|
||
minimal)
|
||
SYNC_BACKFILL_DAYS=30; SYNC_BACKFILL_MESSAGES=2000
|
||
RET_ENGAGEMENT=30; RET_FORMS=30; RET_AUDIT=30
|
||
RET_WARMUP_MAIL=7; RET_WARMUP_EVENTS=30
|
||
;;
|
||
esac
|
||
shift
|
||
;;
|
||
--no-update-check) UPDATE_CHECK=false ;;
|
||
--*) die "unknown option $1. Run with --help for the list." ;;
|
||
*) die "unexpected argument $1. Every value goes with its flag, e.g. --dir /opt/warmbly." ;;
|
||
esac
|
||
shift
|
||
done
|
||
case "$TLS" in none|caddy|proxy) ;; *) die "--tls must be none, caddy or proxy" ;; esac
|
||
case "$BLOBS" in filesystem|s3) ;; *) die "--blobs must be filesystem or s3" ;; esac
|
||
case "$COMPONENTS" in core|full) ;; *) die "--components must be core or full" ;; esac
|
||
case "$CHANNEL" in stable|dev) ;; *) die "--channel must be stable or dev" ;; esac
|
||
}
|
||
|
||
need_value() {
|
||
if [ $# -lt 2 ] || [ -z "$2" ]; then
|
||
die "$1 needs a value"
|
||
fi
|
||
printf '%s' "$2"
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# main
|
||
#
|
||
# Everything above is a function; nothing runs until this line at the very
|
||
# bottom is reached. A download that is cut off half way therefore executes
|
||
# nothing at all, which is the honest answer to the objection people have to
|
||
# piping a script into a shell.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
main() {
|
||
parse_args "$@"
|
||
setup_term
|
||
TMPDIR_RUN=$(mktemp -d 2>/dev/null || printf '/tmp/warmbly-install.%s' "$$")
|
||
mkdir -p "$TMPDIR_RUN"
|
||
LOGFILE="$TMPDIR_RUN/install.log"
|
||
: >"$LOGFILE"
|
||
trap 'cleanup_term; rm -rf "$TMPDIR_RUN" 2>/dev/null || true' EXIT
|
||
|
||
# The demo needs neither of these: it dials nothing and starts nothing.
|
||
if [ "$DEMO" = 0 ]; then
|
||
have curl || fail_with "curl is required and is not installed." \
|
||
"Install it and run this again (apt install curl / dnf install curl)."
|
||
detect_platform
|
||
fi
|
||
|
||
if [ "$MODE" = "uninstall" ]; then
|
||
check_docker
|
||
uninstall
|
||
return 0
|
||
fi
|
||
|
||
require_randomness
|
||
|
||
if [ "$DRY_RUN" = 0 ] && [ "$PRINT_ENV" = 0 ]; then
|
||
banner
|
||
fi
|
||
[ "$DEMO" = 1 ] && demo_banner
|
||
|
||
# A directory that already holds an install is adopted before anything is
|
||
# asked, so every default the wizard shows is this instance's own value.
|
||
# A demo adopts nothing: it must not read an operator's real secrets, and
|
||
# it must not show their hostname back to them as if it had installed
|
||
# something.
|
||
[ "$DEMO" = 1 ] || adopt_existing
|
||
if [ "$DEMO" = 0 ] && [ "$EXISTING" = 0 ] && [ -d "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ] &&
|
||
[ ! -f "$DIR/$MARKER" ] && [ "$FORCE" = 0 ] && [ "$WIZARD" = 0 ]; then
|
||
fail_with "$DIR is not empty and this installer did not create it." \
|
||
"Pass --force to install into it anyway, or --dir to pick another path."
|
||
fi
|
||
|
||
if [ "$DRY_RUN" = 0 ] && [ "$PRINT_ENV" = 0 ] && [ "$DEMO" = 0 ]; then
|
||
check_docker
|
||
fi
|
||
|
||
if [ -n "$VERSION" ]; then
|
||
RESOLVED_TAG=$VERSION
|
||
elif [ "$DEMO" = 1 ]; then
|
||
# No network call in a demo: it would be the one thing the script does
|
||
# for real, which is exactly what a demo should not do.
|
||
RESOLVED_TAG="v0.0.0-demo"
|
||
else
|
||
info "Resolving the newest ${CHANNEL} release"
|
||
resolve_version
|
||
ok "Installing ${RESOLVED_TAG}, pinned in .env so this never moves under you"
|
||
fi
|
||
|
||
ensure_secrets
|
||
|
||
offer_wizard
|
||
|
||
if [ "$WIZARD" = 1 ] && [ "$INTERACTIVE" = 1 ]; then
|
||
# The first step clears the screen, so nothing above it survives being
|
||
# read unless the operator says when.
|
||
say ""
|
||
press_enter "Press enter to begin ${WHITE}q${DIM} to quit"
|
||
wizard
|
||
elif [ "$WIZARD" = 1 ]; then
|
||
warn "The wizard needs a terminal and there is none, so the defaults and any"
|
||
note "flags or WARMBLY_* variables you passed are used instead."
|
||
fi
|
||
|
||
derive
|
||
|
||
if [ "$PRINT_ENV" = 1 ]; then
|
||
render_env
|
||
return 0
|
||
fi
|
||
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
step "This is what would be written. Nothing was touched."
|
||
write_file "$DIR/.env" "$(render_env)" 0600
|
||
write_file "$DIR/docker-compose.yml" "$(render_compose)" 0644
|
||
[ "$WANT_CADDY" = 1 ] && write_file "$DIR/Caddyfile" "$(render_caddyfile)" 0644
|
||
[ -n "$BACKUP_DIR" ] && write_file "$DIR/backup.sh" "$(render_backup_script)" 0755
|
||
say ""
|
||
summary_rows | box "$SKY"
|
||
say ""
|
||
return 0
|
||
fi
|
||
|
||
if [ "$WIZARD" = 1 ] && [ "$INTERACTIVE" = 1 ]; then
|
||
review
|
||
clear_screen
|
||
banner
|
||
[ "$DEMO" = 1 ] && demo_banner
|
||
fi
|
||
|
||
if [ "$DEMO" = 1 ]; then
|
||
say ""
|
||
press_enter "Press enter to watch it install ${WHITE}q${DIM} to quit"
|
||
demo_install
|
||
return 0
|
||
fi
|
||
|
||
step "Installing Warmbly $RESOLVED_TAG into $DIR"
|
||
say ""
|
||
prepare_dirs
|
||
write_all
|
||
ok "Wrote .env and docker-compose.yml"
|
||
say ""
|
||
pull_images
|
||
verify_digests
|
||
say ""
|
||
start_stack
|
||
wait_healthy || true
|
||
finale
|
||
}
|
||
|
||
main "$@"
|