setup.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
set -euo pipefail
# Always operate on the checkout that contains this script. This keeps an
# absolute invocation from creating configuration files in the caller's
# unrelated working directory.
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
env_key_exists() {
local env_file="$1"
local key="$2"
# Match only an active assignment for the exact key. Comments, values, and
# longer names such as OTHER_MODE must not suppress a missing MODE entry.
grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$env_file"
}
sync_env_with_example() {
local example_file="$1"
local target_file="$2"
local added=0
local appended_any=0
local line
local trimmed
local key
while IFS= read -r line || [ -n "$line" ]; do
trimmed="${line#"${line%%[!$' \t']*}"}"
case "$trimmed" in
''|'#'*) continue ;;
esac
if [[ "$trimmed" == export[[:space:]]* ]]; then
trimmed="${trimmed#export}"
trimmed="${trimmed#"${trimmed%%[!$' \t']*}"}"
fi
if [[ "$trimmed" != *'='* ]]; then
continue
fi
key="${trimmed%%=*}"
key="${key%"${key##*[!$' \t']}"}"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
continue
fi
if ! env_key_exists "$target_file" "$key"; then
if [ "$appended_any" -eq 0 ]; then
# Command substitution removes a trailing newline, so a non-empty last
# byte is the portable signal that the file needs a separator first.
if [ -s "$target_file" ] && [ -n "$(tail -c 1 "$target_file" 2>/dev/null || true)" ]; then
printf '\n' >> "$target_file"
fi
appended_any=1
fi
printf '%s\n' "$line" >> "$target_file"
added=$((added + 1))
fi
done < "$example_file"
if [ "$added" -gt 0 ]; then
printf '✅ Added %d new key(s) from %s into %s\n' "$added" "$example_file" "$target_file"
else
printf 'ℹ️ %s already contains all keys from %s\n' "$target_file" "$example_file"
fi
}
printf '🔧 Setting up configuration...\n\n'
EXAMPLE_ENV=".env.example"
if [ ! -f "$EXAMPLE_ENV" ]; then
printf '❌ Required environment template not found: %s\n' "$SCRIPT_DIR/$EXAMPLE_ENV" >&2
exit 1
fi
if [ ! -f .env ]; then
cp "$EXAMPLE_ENV" .env
printf '✅ Created .env from %s\n' "$EXAMPLE_ENV"
else
printf 'ℹ️ .env already exists; syncing new keys from %s\n' "$EXAMPLE_ENV"
sync_env_with_example "$EXAMPLE_ENV" ".env"
fi
generate_jwt_secret() {
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
}
generate_encryption_key() {
python3 - <<'PY'
import base64
import os
try:
from cryptography.fernet import Fernet
except ImportError:
print(base64.urlsafe_b64encode(os.urandom(32)).decode())
else:
print(Fernet.generate_key().decode())
PY
}
generate_grafana_admin_user() {
printf 'omlorix-admin'
}
percent_encode_url_component() {
local value="$1"
# Redis credentials are embedded in a URI. Encode every reserved character
# so an operator-provided password such as `secret#value` reaches Redis
# unchanged instead of being interpreted as URI syntax.
printf '%s' "$value" | python3 -c \
'import sys; from urllib.parse import quote; print(quote(sys.stdin.read(), safe=""), end="")'
}
decode_double_quoted_env_value() {
local value="$1"
local remaining="${value#\"}"
local decoded=""
local character
local escaped=0
# Walk the value instead of cutting at the first quote. Compose accepts
# escaped quotes and backslashes in double-quoted dotenv values, so the first
# literal `\"` is not necessarily the end of the credential. Stop at the
# first unescaped closing quote and ignore any following inline comment.
while [ -n "$remaining" ]; do
character="${remaining:0:1}"
remaining="${remaining:1}"
if [ "$escaped" -eq 1 ]; then
case "$character" in
'"'|'\') decoded="${decoded}${character}" ;;
*) decoded="${decoded}\\${character}" ;;
esac
escaped=0
elif [ "$character" = '\' ]; then
escaped=1
elif [ "$character" = '"' ]; then
printf '%s' "$decoded"
return 0
else
decoded="${decoded}${character}"
fi
done
# Preserve a trailing unmatched backslash in a malformed value. Validation
# can then reject the actual content instead of silently dropping a byte.
if [ "$escaped" -eq 1 ]; then
decoded="${decoded}\\"
fi
printf '%s' "$decoded"
}
escape_sed_replacement() {
local value="$1"
value=${value//\\/\\\\}
value=${value//&/\\&}
value=${value//|/\\|}
printf '%s' "$value"
}
get_env_value() {
local env_file="$1"
local key="$2"
local line
local value
if [ ! -f "$env_file" ]; then
return 0
fi
# Keep this assignment grammar aligned with env_key_exists() and
# set_env_value(). The Compose wrapper accepts leading whitespace and an
# optional `export`, so setup must not mistake those legitimate forms for a
# missing credential and append a generated override later in the file.
line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$env_file" | tail -n 1 || true)"
line="${line#"${line%%[!$' \t']*}"}"
if [[ "$line" == export[[:space:]]* ]]; then
line="${line#export}"
line="${line#"${line%%[!$' \t']*}"}"
fi
value="${line#*=}"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ "${value#\"}" != "$value" ]; then
value="$(decode_double_quoted_env_value "$value")"
elif [ "${value#\'}" != "$value" ]; then
value="${value#\'}"
value="${value%%\'*}"
else
# Match Compose dotenv semantics: `#` begins an inline comment only when
# separated from an unquoted value by whitespace. A literal `#` is valid
# inside credentials and must not be silently truncated.
value="${value%%[[:space:]]#*}"
value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
fi
printf '%s' "$value"
}
env_value_is_blank_or_placeholder() {
local env_file="$1"
local key="$2"
local value
value="$(get_env_value "$env_file" "$key")"
[ -z "$value" ] || [ "$value" = "CHANGE_ME" ]
}
env_flag_enabled() {
local env_file="$1"
local key="$2"
local default_value="$3"
local value
value="$(get_env_value "$env_file" "$key")"
if [ -z "$value" ]; then
value="$default_value"
fi
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
case "$value" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
sed_in_place() {
local file="$1"
local expression="$2"
local tmp
tmp="$(mktemp "${file}.XXXXXX")"
chmod 600 "$tmp"
trap 'rm -f "$tmp"' RETURN
if ! sed -E "$expression" "$file" >"$tmp"; then
return 1
fi
if ! mv "$tmp" "$file"; then
return 1
fi
trap - RETURN
return 0
}
set_env_value() {
local env_file="$1"
local key="$2"
local value="$3"
local escaped_value
escaped_value="$(escape_sed_replacement "$value")"
if env_key_exists "$env_file" "$key"; then
# Normalize every active spelling of the key. Replacing all occurrences
# also prevents a pre-existing duplicate from retaining a conflicting
# effective value after the requested update.
sed_in_place "$env_file" "s|^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=.*|${key}=\"${escaped_value}\"|"
else
printf '%s=\"%s\"\n' "$key" "$value" >> "$env_file"
fi
}
sync_bundled_redis_url() {
local env_file="$1"
local redis_password="$2"
local redis_url
local encoded_redis_password
local expected_redis_url
local encode_status=0
# External and disabled Redis configurations belong to the operator. Only
# synthesize a URL when the bundled Compose service is the selected topology.
if ! env_flag_enabled "$env_file" REDIS_ENABLED true \
|| ! env_flag_enabled "$env_file" OMLORIX_USE_BUNDLED_REDIS true; then
return 0
fi
encoded_redis_password=$(percent_encode_url_component "$redis_password") || encode_status=$?
if [ "$encode_status" -ne 0 ] || [ -z "$encoded_redis_password" ]; then
printf '❌ Failed to encode REDIS_PASSWORD for REDIS_URL (python3 exit=%s)\n' "$encode_status" >&2
return 1
fi
# REDIS_URL is derived state for the bundled service. Recompute it on every
# setup run so a manual password rotation cannot leave application processes
# authenticating with an older credential.
redis_url="$(get_env_value "$env_file" REDIS_URL)"
expected_redis_url="redis://:${encoded_redis_password}@redis:6379/0"
if [ "$redis_url" != "$expected_redis_url" ]; then
# Backend processes run inside Compose containers, where bundled Redis is
# reachable through its service DNS name rather than localhost.
set_env_value "$env_file" REDIS_URL "$expected_redis_url"
printf '✅ Synced bundled REDIS_URL with REDIS_PASSWORD\n'
fi
}
ensure_required_keys() {
local env_file="$1"
# Replace placeholder service credentials with generated values so copied
# example configs are not deployable with known defaults.
local generated_value
if env_value_is_blank_or_placeholder "$env_file" DATABASE_PASSWORD; then
local generated_status=0
generated_value=$(generate_jwt_secret) || generated_status=$?
if [ "$generated_status" -ne 0 ] || [ -z "${generated_value}" ]; then
printf '❌ Failed to generate DATABASE_PASSWORD (python3 exit=%s)\n' "$generated_status" >&2
return 1
fi
set_env_value "$env_file" DATABASE_PASSWORD "$generated_value"
printf '✅ Generated DATABASE_PASSWORD\n'
fi
if env_value_is_blank_or_placeholder "$env_file" REDIS_PASSWORD; then
local generated_status=0
generated_value=$(generate_jwt_secret) || generated_status=$?
if [ "$generated_status" -ne 0 ] || [ -z "${generated_value}" ]; then
printf '❌ Failed to generate REDIS_PASSWORD (python3 exit=%s)\n' "$generated_status" >&2
return 1
fi
set_env_value "$env_file" REDIS_PASSWORD "$generated_value"
printf '✅ Generated REDIS_PASSWORD\n'
fi
sync_bundled_redis_url "$env_file" "$(get_env_value "$env_file" REDIS_PASSWORD)"
if env_value_is_blank_or_placeholder "$env_file" MINIO_ROOT_USER; then
local generated_status=0
generated_value="omlorix-$(generate_jwt_secret)" || generated_status=$?
if [ "$generated_status" -ne 0 ] || [ -z "${generated_value}" ]; then
printf '❌ Failed to generate MINIO_ROOT_USER (python3 exit=%s)\n' "$generated_status" >&2
return 1
fi
set_env_value "$env_file" MINIO_ROOT_USER "$generated_value"
printf '✅ Generated MINIO_ROOT_USER\n'
fi
if env_value_is_blank_or_placeholder "$env_file" MINIO_ROOT_PASSWORD; then
local generated_status=0
generated_value=$(generate_jwt_secret) || generated_status=$?
if [ "$generated_status" -ne 0 ] || [ -z "${generated_value}" ]; then
printf '❌ Failed to generate MINIO_ROOT_PASSWORD (python3 exit=%s)\n' "$generated_status" >&2
return 1
fi
set_env_value "$env_file" MINIO_ROOT_PASSWORD "$generated_value"
printf '✅ Generated MINIO_ROOT_PASSWORD\n'
fi
if env_value_is_blank_or_placeholder "$env_file" GRAFANA_ADMIN_USER \
|| [ "$(get_env_value "$env_file" GRAFANA_ADMIN_USER)" = "admin" ]; then
set_env_value "$env_file" GRAFANA_ADMIN_USER "$(generate_grafana_admin_user)"
printf '✅ Set GRAFANA_ADMIN_USER to a non-default value\n'
fi
if env_value_is_blank_or_placeholder "$env_file" GRAFANA_ADMIN_PASSWORD; then
local generated_status=0
generated_value=$(generate_jwt_secret) || generated_status=$?
if [ "$generated_status" -ne 0 ] || [ -z "${generated_value}" ]; then
printf '❌ Failed to generate GRAFANA_ADMIN_PASSWORD (python3 exit=%s)\n' "$generated_status" >&2
return 1
fi
set_env_value "$env_file" GRAFANA_ADMIN_PASSWORD "$generated_value"
printf '✅ Generated GRAFANA_ADMIN_PASSWORD\n'
fi
# Check and generate JWT_SECRET_KEY if empty
if env_value_is_blank_or_placeholder "$env_file" JWT_SECRET_KEY; then
local jwt_key
local jwt_status=0
jwt_key=$(generate_jwt_secret) || jwt_status=$?
if [ "$jwt_status" -ne 0 ] || [ -z "${jwt_key}" ]; then
printf '❌ Failed to generate JWT_SECRET_KEY (python3 exit=%s)\n' "$jwt_status" >&2
return 1
fi
set_env_value "$env_file" JWT_SECRET_KEY "$jwt_key"
printf '✅ Generated JWT_SECRET_KEY\n'
fi
# Check and generate ENCRYPTION_KEY if empty
if env_value_is_blank_or_placeholder "$env_file" ENCRYPTION_KEY; then
local enc_key
local enc_status=0
enc_key=$(generate_encryption_key) || enc_status=$?
if [ "$enc_status" -ne 0 ] || [ -z "${enc_key}" ]; then
printf '❌ Failed to generate ENCRYPTION_KEY (python3 exit=%s)\n' "$enc_status" >&2
return 1
fi
set_env_value "$env_file" ENCRYPTION_KEY "$enc_key"
printf '✅ Generated ENCRYPTION_KEY\n'
fi
# Keep password-reset fingerprints stable across workers and restarts. A
# process-local fallback is intentionally available in the backend, but an
# installation created by setup should always have a persistent salt.
local reset_salt
reset_salt="$(get_env_value "$env_file" PASSWORD_RESET_IDENTIFIER_HASH_SALT)"
if [ "$reset_salt" = "CHANGE_ME" ] || [ "${#reset_salt}" -lt 16 ]; then
local reset_salt_status=0
reset_salt=$(generate_jwt_secret) || reset_salt_status=$?
if [ "$reset_salt_status" -ne 0 ] || [ -z "${reset_salt}" ]; then
printf '❌ Failed to generate PASSWORD_RESET_IDENTIFIER_HASH_SALT (python3 exit=%s)\n' "$reset_salt_status" >&2
return 1
fi
set_env_value "$env_file" PASSWORD_RESET_IDENTIFIER_HASH_SALT "$reset_salt"
printf '✅ Generated PASSWORD_RESET_IDENTIFIER_HASH_SALT\n'
fi
# Audit and password-reset IP fingerprints use a dedicated salt so rotating
# JWT_SECRET_KEY cannot change their pseudonymous identifiers.
local log_ip_salt
log_ip_salt="$(get_env_value "$env_file" LOG_IP_HASH_SALT)"
if [ "$log_ip_salt" = "CHANGE_ME" ] || [ "${#log_ip_salt}" -lt 16 ]; then
local log_ip_salt_status=0
log_ip_salt=$(generate_jwt_secret) || log_ip_salt_status=$?
if [ "$log_ip_salt_status" -ne 0 ] || [ -z "${log_ip_salt}" ]; then
printf '❌ Failed to generate LOG_IP_HASH_SALT (python3 exit=%s)\n' "$log_ip_salt_status" >&2
return 1
fi
set_env_value "$env_file" LOG_IP_HASH_SALT "$log_ip_salt"
printf '✅ Generated LOG_IP_HASH_SALT\n'
fi
# Encrypted archives are the default and plaintext archives are disabled, so
# a blank passphrase would make the advertised backup workflow unusable.
if env_value_is_blank_or_placeholder "$env_file" BACKUP_ARCHIVE_ENCRYPTION_PASSPHRASE; then
local backup_passphrase
local backup_passphrase_status=0
backup_passphrase=$(generate_jwt_secret) || backup_passphrase_status=$?
if [ "$backup_passphrase_status" -ne 0 ] || [ -z "${backup_passphrase}" ]; then
printf '❌ Failed to generate BACKUP_ARCHIVE_ENCRYPTION_PASSPHRASE (python3 exit=%s)\n' "$backup_passphrase_status" >&2
return 1
fi
set_env_value "$env_file" BACKUP_ARCHIVE_ENCRYPTION_PASSPHRASE "$backup_passphrase"
printf '✅ Generated BACKUP_ARCHIVE_ENCRYPTION_PASSPHRASE\n'
fi
return 0
}
# Auto-generate required keys if they're empty
if [ -f .env ]; then
# .env contains database, cache, encryption, and backup secrets. Enforce the
# same owner-only mode used by the Server CLI even when setup does not need to
# rewrite any values during this run.
chmod 600 .env
ensure_required_keys .env
# Keep the invariant explicit after atomic replacements as well; do not rely
# on platform-specific mktemp defaults for the final secret file mode.
chmod 600 .env
fi
cat <<'EONEXT'
Setup complete.
Next steps:
1. Review your generated credentials in .env before production use & optionally save them securely (e.g. password manager)
2. Start the app: make up
EONEXT