238 lines
8.8 KiB
Bash
238 lines
8.8 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
# Tx OS — host-side first-time setup helper for macOS & Linux.
|
||
|
|
#
|
||
|
|
# What it does (idempotent — safe to re-run):
|
||
|
|
# 1. Verifies Docker + Docker Compose v2 are installed.
|
||
|
|
# 2. Verifies / hints how to install mkcert (does NOT install it for you).
|
||
|
|
# 3. Prompts for LOCAL_DOMAIN + LOCAL_IP (with sensible defaults).
|
||
|
|
# 4. Provisions ./certs/local-cert.pem + ./certs/local-key.pem via mkcert,
|
||
|
|
# reusing the existing cert when its SANs already cover the requested
|
||
|
|
# hostnames.
|
||
|
|
# 5. Bootstraps .env from .env.example if missing, then upserts
|
||
|
|
# LOCAL_DOMAIN / LOCAL_IP / BASE_URL / PUBLIC_BASE_URL / ALLOWED_ORIGINS
|
||
|
|
# preserving every other key.
|
||
|
|
# 6. Prints `mkcert -CAROOT` so you can install the root CA on your phones.
|
||
|
|
# 7. Runs `docker compose up -d --build`.
|
||
|
|
#
|
||
|
|
# What it does NOT do:
|
||
|
|
# - Install packages on your behalf.
|
||
|
|
# - Touch the database, volumes, or `.env` keys it doesn't own.
|
||
|
|
# - Configure DNS / mDNS / Tailscale.
|
||
|
|
#
|
||
|
|
# Usage: ./scripts/local-setup.sh
|
||
|
|
# -----------------------------------------------------------------------------
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
# Allow the test harness to stub `mkcert` and skip docker/compose checks.
|
||
|
|
LOCAL_SETUP_DRY_RUN="${LOCAL_SETUP_DRY_RUN:-0}"
|
||
|
|
|
||
|
|
# Resolve project root (parent of scripts/).
|
||
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
|
|
cd "$PROJECT_ROOT"
|
||
|
|
|
||
|
|
CERTS_DIR="$PROJECT_ROOT/certs"
|
||
|
|
CERT_FILE="$CERTS_DIR/local-cert.pem"
|
||
|
|
KEY_FILE="$CERTS_DIR/local-key.pem"
|
||
|
|
|
||
|
|
# Cross-platform `sed -i` (BSD vs GNU).
|
||
|
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||
|
|
SED_I=(sed -i '')
|
||
|
|
else
|
||
|
|
SED_I=(sed -i)
|
||
|
|
fi
|
||
|
|
|
||
|
|
log() { printf "==> %s\n" "$*"; }
|
||
|
|
warn() { printf "WARN: %s\n" "$*" >&2; }
|
||
|
|
fatal() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1. Tooling checks
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then
|
||
|
|
if ! command -v docker >/dev/null 2>&1; then
|
||
|
|
fatal "Docker not found. Install Docker Desktop from https://www.docker.com/products/docker-desktop/"
|
||
|
|
fi
|
||
|
|
if ! docker compose version >/dev/null 2>&1; then
|
||
|
|
fatal "Docker Compose v2 not found. Update Docker Desktop."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
if ! command -v mkcert >/dev/null 2>&1; then
|
||
|
|
warn "mkcert is not installed."
|
||
|
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||
|
|
echo " Install with: brew install mkcert nss"
|
||
|
|
else
|
||
|
|
if command -v apt-get >/dev/null 2>&1; then echo " Install with: sudo apt-get install -y mkcert libnss3-tools"
|
||
|
|
elif command -v dnf >/dev/null 2>&1; then echo " Install with: sudo dnf install -y mkcert nss-tools"
|
||
|
|
elif command -v pacman >/dev/null 2>&1; then echo " Install with: sudo pacman -S mkcert nss"
|
||
|
|
elif command -v zypper >/dev/null 2>&1; then echo " Install with: sudo zypper install mkcert mozilla-nss-tools"
|
||
|
|
elif command -v apk >/dev/null 2>&1; then echo " Install with: sudo apk add mkcert nss-tools"
|
||
|
|
else echo " See https://github.com/FiloSottile/mkcert#installation"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then
|
||
|
|
fatal "Re-run this script after installing mkcert."
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2. Bootstrap .env from .env.example
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
if [ ! -f "$PROJECT_ROOT/.env" ]; then
|
||
|
|
if [ ! -f "$PROJECT_ROOT/.env.example" ]; then
|
||
|
|
fatal ".env.example missing — cannot bootstrap .env"
|
||
|
|
fi
|
||
|
|
log "Creating .env from .env.example ..."
|
||
|
|
cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env"
|
||
|
|
# Generate a SESSION_SECRET if openssl is available.
|
||
|
|
if command -v openssl >/dev/null 2>&1; then
|
||
|
|
SECRET="$(openssl rand -hex 32)"
|
||
|
|
"${SED_I[@]}" "s|^SESSION_SECRET=.*|SESSION_SECRET=${SECRET}|" "$PROJECT_ROOT/.env"
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# Helper: read a key from .env, defaulting to argument 2.
|
||
|
|
env_get() {
|
||
|
|
local key="$1"
|
||
|
|
local default="${2-}"
|
||
|
|
local val
|
||
|
|
val=$(grep -E "^${key}=" "$PROJECT_ROOT/.env" | head -n1 | cut -d= -f2- | tr -d '\r' || true)
|
||
|
|
if [ -z "$val" ]; then
|
||
|
|
printf "%s" "$default"
|
||
|
|
else
|
||
|
|
printf "%s" "$val"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# Helper: upsert KEY=VALUE in .env (insert if absent, replace if present).
|
||
|
|
# Uses a tmp file so we never lose data on a partially-failed sed.
|
||
|
|
env_set() {
|
||
|
|
local key="$1"
|
||
|
|
local value="$2"
|
||
|
|
local file="$PROJECT_ROOT/.env"
|
||
|
|
if grep -qE "^${key}=" "$file"; then
|
||
|
|
local tmp
|
||
|
|
tmp="$(mktemp)"
|
||
|
|
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} {
|
||
|
|
if ($1 == k) { print k"="v } else { print $0 }
|
||
|
|
}' "$file" > "$tmp"
|
||
|
|
mv "$tmp" "$file"
|
||
|
|
else
|
||
|
|
printf "%s=%s\n" "$key" "$value" >> "$file"
|
||
|
|
fi
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3. Prompt for LOCAL_DOMAIN + LOCAL_IP
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
detect_lan_ip() {
|
||
|
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||
|
|
local iface ip
|
||
|
|
iface=$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}' | head -n1 || true)
|
||
|
|
if [ -n "$iface" ]; then
|
||
|
|
ip=$(ipconfig getifaddr "$iface" 2>/dev/null || true)
|
||
|
|
[ -n "$ip" ] && { printf "%s" "$ip"; return; }
|
||
|
|
fi
|
||
|
|
else
|
||
|
|
if command -v ip >/dev/null 2>&1; then
|
||
|
|
local ip
|
||
|
|
ip=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1 || true)
|
||
|
|
[ -n "$ip" ] && { printf "%s" "$ip"; return; }
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
printf "%s" "127.0.0.1"
|
||
|
|
}
|
||
|
|
|
||
|
|
CURRENT_DOMAIN="$(env_get LOCAL_DOMAIN tx.local)"
|
||
|
|
CURRENT_IP="$(env_get LOCAL_IP "$(detect_lan_ip)")"
|
||
|
|
|
||
|
|
if [[ "${LOCAL_SETUP_NONINTERACTIVE:-0}" == "1" ]]; then
|
||
|
|
LOCAL_DOMAIN="$CURRENT_DOMAIN"
|
||
|
|
LOCAL_IP="$CURRENT_IP"
|
||
|
|
else
|
||
|
|
read -r -p "LOCAL_DOMAIN [$CURRENT_DOMAIN]: " LOCAL_DOMAIN || true
|
||
|
|
LOCAL_DOMAIN="${LOCAL_DOMAIN:-$CURRENT_DOMAIN}"
|
||
|
|
read -r -p "LOCAL_IP [$CURRENT_IP]: " LOCAL_IP || true
|
||
|
|
LOCAL_IP="${LOCAL_IP:-$CURRENT_IP}"
|
||
|
|
fi
|
||
|
|
|
||
|
|
log "Using LOCAL_DOMAIN=$LOCAL_DOMAIN LOCAL_IP=$LOCAL_IP"
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 4. Generate / refresh mkcert certificate
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
mkdir -p "$CERTS_DIR"
|
||
|
|
|
||
|
|
cert_covers_sans() {
|
||
|
|
# Returns 0 if cert at $CERT_FILE includes ALL of: LOCAL_DOMAIN, localhost,
|
||
|
|
# 127.0.0.1, LOCAL_IP. Returns non-zero otherwise (or if openssl missing).
|
||
|
|
command -v openssl >/dev/null 2>&1 || return 1
|
||
|
|
local out
|
||
|
|
out=$(openssl x509 -in "$CERT_FILE" -noout -ext subjectAltName 2>/dev/null || true)
|
||
|
|
[ -z "$out" ] && return 1
|
||
|
|
for needle in "$LOCAL_DOMAIN" "localhost" "127.0.0.1" "$LOCAL_IP"; do
|
||
|
|
if ! grep -q -F "$needle" <<<"$out"; then
|
||
|
|
return 1
|
||
|
|
fi
|
||
|
|
done
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && cert_covers_sans; then
|
||
|
|
log "Existing cert already covers $LOCAL_DOMAIN / $LOCAL_IP — skipping regeneration."
|
||
|
|
else
|
||
|
|
log "Generating local certificate via mkcert ..."
|
||
|
|
mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" \
|
||
|
|
"$LOCAL_DOMAIN" localhost 127.0.0.1 "$LOCAL_IP"
|
||
|
|
if command -v mkcert >/dev/null 2>&1; then
|
||
|
|
CAROOT="$(mkcert -CAROOT 2>/dev/null || true)"
|
||
|
|
if [ -n "$CAROOT" ]; then
|
||
|
|
echo
|
||
|
|
echo " Root CA stored in: $CAROOT"
|
||
|
|
echo " To trust HTTPS on phones / other devices, install rootCA.pem"
|
||
|
|
echo " from that directory."
|
||
|
|
echo
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
fi
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 5. Persist values back to .env (idempotent — preserves all other keys)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
env_set LOCAL_DOMAIN "$LOCAL_DOMAIN"
|
||
|
|
env_set LOCAL_IP "$LOCAL_IP"
|
||
|
|
env_set BASE_URL "https://$LOCAL_DOMAIN"
|
||
|
|
env_set PUBLIC_BASE_URL "https://$LOCAL_DOMAIN"
|
||
|
|
env_set ALLOWED_ORIGINS "https://$LOCAL_DOMAIN,https://$LOCAL_IP"
|
||
|
|
# Default HTTPS_MODE to "local" only if not already set (preserves byo/skip).
|
||
|
|
if [ -z "$(env_get HTTPS_MODE)" ]; then
|
||
|
|
env_set HTTPS_MODE "local"
|
||
|
|
fi
|
||
|
|
|
||
|
|
log ".env updated."
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 6. Bring the stack up
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
if [[ "$LOCAL_SETUP_DRY_RUN" == "1" ]]; then
|
||
|
|
log "Dry-run mode — skipping 'docker compose up'."
|
||
|
|
exit 0
|
||
|
|
fi
|
||
|
|
|
||
|
|
log "Building & starting containers (first run can take 5-10 minutes) ..."
|
||
|
|
docker compose up -d --build
|
||
|
|
|
||
|
|
cat <<EOF
|
||
|
|
|
||
|
|
----------------------------------------------------------------------
|
||
|
|
Tx OS is starting.
|
||
|
|
Open: https://$LOCAL_DOMAIN/
|
||
|
|
https://$LOCAL_IP/ (from devices that can't resolve mDNS)
|
||
|
|
|
||
|
|
If the API status reports setupRequired=true, the Setup Wizard
|
||
|
|
will prompt you to create the first admin in your browser.
|
||
|
|
----------------------------------------------------------------------
|
||
|
|
EOF
|