Files
ente-cloudron/start.sh
Andreas Dueren 626a5b5031 Use Cloudron STARTTLS port 2587 for SMTP
Switch from plain SMTP on port 2525 to STARTTLS on port 2587.
The Go smtp.SendMail function automatically handles STARTTLS
negotiation when encryption is empty, which is required by
Cloudron's sendmail addon on the STARTTLS port.
2025-10-22 09:05:28 -06:00

642 lines
18 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$timestamp] [$level] $message"
}
APP_DIR="/app/code"
DATA_DIR="/app/data"
LOG_DIR="$DATA_DIR/logs"
CONFIG_DIR="$DATA_DIR/config"
TMP_DIR="$DATA_DIR/tmp"
SECRETS_DIR="$DATA_DIR/secrets"
MUSEUM_RUNTIME_DIR="$DATA_DIR/museum"
MUSEUM_CONFIG_DIR="$MUSEUM_RUNTIME_DIR/configurations"
MUSEUM_CONFIG="$MUSEUM_CONFIG_DIR/local.yaml"
MUSEUM_BIN="/app/museum-bin/museum"
WEB_SOURCE_DIR="/app/web"
WEB_RUNTIME_DIR="$DATA_DIR/web"
CADDY_CONFIG="$DATA_DIR/Caddyfile"
STARTUP_FLAG="$DATA_DIR/startup.lock"
mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$TMP_DIR" "$SECRETS_DIR" "$MUSEUM_RUNTIME_DIR" "$WEB_RUNTIME_DIR" "$MUSEUM_CONFIG_DIR"
chown -R cloudron:cloudron "$DATA_DIR"
log INFO "Starting Ente for Cloudron"
if ! command -v setpriv >/dev/null 2>&1; then
log ERROR "setpriv command not found"
exit 1
fi
if [ -f "$STARTUP_FLAG" ]; then
log WARN "Previous startup did not finish cleanly; removing flag"
rm -f "$STARTUP_FLAG"
fi
touch "$STARTUP_FLAG"
trap 'rm -f "$STARTUP_FLAG"' EXIT
BASE_URL="${CLOUDRON_APP_ORIGIN:-https://$CLOUDRON_APP_FQDN}"
BASE_URL="${BASE_URL%/}"
RP_ID="${CLOUDRON_APP_FQDN:-${CLOUDRON_APP_DOMAIN:-localhost}}"
API_ORIGIN="${BASE_URL}/api"
log INFO "Application base URL: $BASE_URL"
log INFO "Relying party ID: $RP_ID"
log INFO "API origin: $API_ORIGIN"
S3_CONFIG_FILE="$CONFIG_DIR/s3.env"
if [ ! -f "$S3_CONFIG_FILE" ]; then
cat > "$S3_CONFIG_FILE" <<'EOF_S3'
# S3 configuration for Ente (required)
# Provide credentials for an S3-compatible object storage and restart the app.
#
# Supported environment variables (either set here or via Cloudron env vars):
# S3_ENDPOINT=https://example.s3-provider.com
# S3_REGION=us-east-1
# S3_BUCKET=ente-data
# S3_ACCESS_KEY=your-access-key
# S3_SECRET_KEY=your-secret-key
# S3_PREFIX=optional/path/prefix
#
# Example for Cloudflare R2 (replace placeholders):
#S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
#S3_REGION=auto
#S3_BUCKET=ente
#S3_ACCESS_KEY=R2_ACCESS_KEY
#S3_SECRET_KEY=R2_SECRET_KEY
EOF_S3
chown cloudron:cloudron "$S3_CONFIG_FILE"
chmod 600 "$S3_CONFIG_FILE"
log INFO "Created S3 configuration template at $S3_CONFIG_FILE"
fi
set +u
if [ -f "$S3_CONFIG_FILE" ]; then
# shellcheck disable=SC1090
. "$S3_CONFIG_FILE"
fi
set -u
S3_ENDPOINT="${S3_ENDPOINT:-${ENTE_S3_ENDPOINT:-}}"
S3_REGION="${S3_REGION:-${ENTE_S3_REGION:-}}"
S3_BUCKET="${S3_BUCKET:-${ENTE_S3_BUCKET:-}}"
S3_ACCESS_KEY="${S3_ACCESS_KEY:-${ENTE_S3_ACCESS_KEY:-}}"
S3_SECRET_KEY="${S3_SECRET_KEY:-${ENTE_S3_SECRET_KEY:-}}"
S3_PREFIX="${S3_PREFIX:-${ENTE_S3_PREFIX:-}}"
if [ -z "$S3_ENDPOINT" ] || [ -z "$S3_REGION" ] || [ -z "$S3_BUCKET" ] || [ -z "$S3_ACCESS_KEY" ] || [ -z "$S3_SECRET_KEY" ]; then
log ERROR "Missing S3 configuration. Update $S3_CONFIG_FILE or set environment variables."
log ERROR "The application will start in configuration mode. Please configure S3 and restart."
S3_NOT_CONFIGURED=true
else
S3_NOT_CONFIGURED=false
fi
if [ "$S3_NOT_CONFIGURED" = "false" ]; then
S3_ENDPOINT_HOST="${S3_ENDPOINT#https://}"
S3_ENDPOINT_HOST="${S3_ENDPOINT_HOST#http://}"
S3_ENDPOINT_HOST="${S3_ENDPOINT_HOST%%/}"
S3_ENDPOINT_PATH="${S3_ENDPOINT_HOST#*/}"
if [ "$S3_ENDPOINT_PATH" != "$S3_ENDPOINT_HOST" ]; then
if [ -z "$S3_PREFIX" ]; then
S3_PREFIX="$S3_ENDPOINT_PATH"
fi
S3_ENDPOINT_HOST="${S3_ENDPOINT_HOST%%/*}"
fi
log INFO "Using S3 endpoint $S3_ENDPOINT_HOST (region $S3_REGION, bucket $S3_BUCKET)"
else
S3_ENDPOINT_HOST="s3.example.com"
log WARN "S3 not configured - using placeholder values"
fi
MASTER_KEY_FILE="$SECRETS_DIR/master_key"
HASH_KEY_FILE="$SECRETS_DIR/hash_key"
JWT_SECRET_FILE="$SECRETS_DIR/jwt_secret"
SESSION_SECRET_FILE="$SECRETS_DIR/session_secret"
SMTP_HOST="${CLOUDRON_MAIL_SMTP_SERVER:-mail}"
SMTP_PORT="${CLOUDRON_MAIL_STARTTLS_PORT:-2587}"
SMTP_ENCRYPTION=""
SMTP_USERNAME="${CLOUDRON_MAIL_SMTP_USERNAME:-}"
SMTP_PASSWORD="${CLOUDRON_MAIL_SMTP_PASSWORD:-}"
SMTP_EMAIL="${CLOUDRON_MAIL_FROM:-no-reply@$RP_ID}"
SMTP_SENDER_NAME="${CLOUDRON_MAIL_FROM_DISPLAY_NAME:-Ente}"
if [ -n "$SMTP_HOST" ]; then
log INFO "SMTP configured for $SMTP_HOST:$SMTP_PORT (encryption: ${SMTP_ENCRYPTION:-none})"
else
log INFO "SMTP not configured; Museum will skip outbound email"
fi
normalize_b64() {
local value="$1"
value="$(printf '%s' "$value" | tr -d '\r\n')"
value="$(printf '%s' "$value" | tr '-_' '+/')"
local mod=$(( ${#value} % 4 ))
if [ $mod -eq 2 ]; then
value="${value}=="
elif [ $mod -eq 3 ]; then
value="${value}="
elif [ $mod -eq 1 ]; then
value=""
fi
printf '%s' "$value"
}
normalize_b64url() {
local value="$1"
value="$(printf '%s' "$value" | tr -d '\r\n')"
value="$(printf '%s' "$value" | tr '+/' '-_')"
local mod=$(( ${#value} % 4 ))
if [ $mod -eq 2 ]; then
value="${value}=="
elif [ $mod -eq 3 ]; then
value="${value}="
elif [ $mod -eq 1 ]; then
value=""
fi
printf '%s' "$value"
}
generate_b64() {
local bytes="$1"
openssl rand -base64 "$bytes" | tr -d '\n'
}
generate_b64url() {
local bytes="$1"
openssl rand -base64 "$bytes" | tr '+/' '-_' | tr -d '\n'
}
ensure_secret() {
local file="$1"
local bytes="$2"
local mode="$3"
local current=""
if [ -f "$file" ]; then
current="$(tr -d '\n' < "$file")"
fi
if [ "$mode" = "b64" ]; then
current="$(normalize_b64 "$current")"
if [ -z "$current" ]; then
current="$(generate_b64 "$bytes")"
fi
else
current="$(normalize_b64url "$current")"
if [ -z "$current" ]; then
current="$(generate_b64url "$bytes")"
fi
fi
printf '%s
' "$current" > "$file"
}
ensure_secret "$MASTER_KEY_FILE" 32 b64
ensure_secret "$HASH_KEY_FILE" 64 b64
ensure_secret "$JWT_SECRET_FILE" 32 b64url
ensure_secret "$SESSION_SECRET_FILE" 32 b64url
MASTER_KEY="$(tr -d '\n' < "$MASTER_KEY_FILE")"
HASH_KEY="$(tr -d '\n' < "$HASH_KEY_FILE")"
JWT_SECRET="$(tr -d '\n' < "$JWT_SECRET_FILE")"
SESSION_SECRET="$(tr -d '\n' < "$SESSION_SECRET_FILE")"
chown cloudron:cloudron "$MASTER_KEY_FILE" "$HASH_KEY_FILE" "$JWT_SECRET_FILE" "$SESSION_SECRET_FILE"
chmod 600 "$MASTER_KEY_FILE" "$HASH_KEY_FILE" "$JWT_SECRET_FILE" "$SESSION_SECRET_FILE"
log INFO "Ensuring Museum runtime assets"
sync_dir() {
local source="$1"
local target="$2"
if [ -d "$source" ]; then
log INFO "Syncing $(basename "$source") into data directory"
rm -rf "$target"
cp -a "$source" "$target"
chown -R cloudron:cloudron "$target"
else
log WARN "Missing expected directory: $source"
fi
}
sync_dir "$APP_DIR/server/migrations" "$MUSEUM_RUNTIME_DIR/migrations"
sync_dir "$APP_DIR/server/web-templates" "$MUSEUM_RUNTIME_DIR/web-templates"
sync_dir "$APP_DIR/server/mail-templates" "$MUSEUM_RUNTIME_DIR/mail-templates"
sync_dir "$APP_DIR/server/assets" "$MUSEUM_RUNTIME_DIR/assets"
if [ ! -x "$MUSEUM_BIN" ]; then
log ERROR "Museum binary not found at $MUSEUM_BIN"
exit 1
fi
if [ ! -f "$MUSEUM_CONFIG" ]; then
log INFO "Rendering Museum configuration"
cat > "$MUSEUM_CONFIG" <<EOF_CFG
log-file: ""
http:
port: 8080
use-tls: false
apps:
public-albums: "$BASE_URL/albums"
public-locker: "$BASE_URL/photos"
accounts: "$BASE_URL/accounts"
cast: "$BASE_URL/cast"
family: "$BASE_URL/family"
custom-domain:
cname: "${CLOUDRON_APP_DOMAIN:-localhost}"
db:
host: ${CLOUDRON_POSTGRESQL_HOST}
port: ${CLOUDRON_POSTGRESQL_PORT}
name: ${CLOUDRON_POSTGRESQL_DATABASE}
user: ${CLOUDRON_POSTGRESQL_USERNAME}
password: ${CLOUDRON_POSTGRESQL_PASSWORD}
sslmode: disable
s3:
are_local_buckets: false
use_path_style_urls: true
hot_storage:
primary: primary-storage
secondary: primary-storage
primary-storage:
key: "$S3_ACCESS_KEY"
secret: "$S3_SECRET_KEY"
endpoint: "$S3_ENDPOINT_HOST"
region: "$S3_REGION"
bucket: "$S3_BUCKET"
path_prefix: "$S3_PREFIX"
smtp:
host: "${SMTP_HOST}"
port: "${SMTP_PORT}"
username: "${SMTP_USERNAME}"
password: "${SMTP_PASSWORD}"
email: "${SMTP_EMAIL}"
sender-name: "${SMTP_SENDER_NAME}"
encryption: "${SMTP_ENCRYPTION}"
internal:
silent: false
disable-registration: false
webauthn:
rpid: "$RP_ID"
rporigins:
- "$BASE_URL"
key:
encryption: $MASTER_KEY
hash: $HASH_KEY
jwt:
secret: $JWT_SECRET
sessions:
secret: $SESSION_SECRET
EOF_CFG
if [ -n "${CLOUDRON_OIDC_CLIENT_ID:-}" ] && [ -n "${CLOUDRON_OIDC_CLIENT_SECRET:-}" ] && [ -n "${CLOUDRON_OIDC_IDENTIFIER:-}" ]; then
cat >> "$MUSEUM_CONFIG" <<EOF_CFG
oidc:
enabled: true
issuer: "${CLOUDRON_OIDC_IDENTIFIER}"
client_id: "${CLOUDRON_OIDC_CLIENT_ID}"
client_secret: "${CLOUDRON_OIDC_CLIENT_SECRET}"
redirect_url: "$BASE_URL/api/v1/session/callback"
EOF_CFG
fi
chown cloudron:cloudron "$MUSEUM_CONFIG"
chmod 600 "$MUSEUM_CONFIG"
else
log INFO "Museum configuration already present; leaving untouched"
fi
log INFO "Preparing frontend assets"
if [ -d "$WEB_SOURCE_DIR" ]; then
for app in photos accounts auth cast albums family; do
if [ -d "$WEB_SOURCE_DIR/$app" ]; then
log INFO "Updating $app frontend assets"
rm -rf "$WEB_RUNTIME_DIR/$app"
cp -a "$WEB_SOURCE_DIR/$app" "$WEB_RUNTIME_DIR/$app"
chown -R cloudron:cloudron "$WEB_RUNTIME_DIR/$app"
else
log WARN "Missing built frontend for $app"
fi
done
else
log ERROR "Frontend assets directory missing at $WEB_SOURCE_DIR"
fi
rewrite_frontend_reference() {
local search="$1"
local replace="$2"
local count=0
local file
if [ -z "$search" ] || [ -z "$replace" ] || [ "$search" = "$replace" ]; then
return
fi
# Create escaped versions for different contexts
local search_escaped_slash="${search//\//\\/}"
local replace_escaped_slash="${replace//\//\\/}"
local search_json="${search//\//\\/}"
local replace_json="${replace//\//\\/}"
while IFS= read -r -d '' file; do
local file_changed=false
# Check if file contains any variant of the search string
if LC_ALL=C grep -q -e "$search" -e "$search_escaped_slash" "$file" 2>/dev/null; then
# Replace plain URL
if sed -i "s|$search|$replace|g" "$file" 2>/dev/null; then
file_changed=true
fi
# Replace backslash-escaped URL (common in JavaScript strings)
if sed -i "s|$search_escaped_slash|$replace_escaped_slash|g" "$file" 2>/dev/null; then
file_changed=true
fi
# Replace double-backslash-escaped URL (common in JSON)
if sed -i "s|${search//\//\\\\/}|${replace//\//\\\\/}|g" "$file" 2>/dev/null; then
file_changed=true
fi
if [ "$file_changed" = true ]; then
chown cloudron:cloudron "$file"
count=$((count + 1))
fi
fi
done < <(find "$WEB_RUNTIME_DIR" -type f \( -name "*.js" -o -name "*.json" -o -name "*.html" -o -name "*.css" -o -name "*.txt" \) -print0)
if [ "$count" -gt 0 ]; then
log INFO "Replaced '$search' with '$replace' in $count frontend files"
fi
}
if [ -d "$WEB_RUNTIME_DIR" ]; then
log INFO "Rewriting frontend endpoints for local deployment"
FRONTEND_REPLACEMENTS=(
"ENTE_API_ORIGIN_PLACEHOLDER|$API_ORIGIN"
"https://api.ente.io|$API_ORIGIN"
"https://accounts.ente.io|$BASE_URL/accounts"
"https://auth.ente.io|$BASE_URL/auth"
"https://cast.ente.io|$BASE_URL/cast"
"https://photos.ente.io|$BASE_URL/photos"
"https://web.ente.io|$BASE_URL/photos"
"https://albums.ente.io|$BASE_URL/albums"
"https://family.ente.io|$BASE_URL/family"
"https://ente.io|$BASE_URL"
)
OLD_IFS="$IFS"
for entry in "${FRONTEND_REPLACEMENTS[@]}"; do
IFS="|" read -r search replace <<<"$entry"
rewrite_frontend_reference "$search" "$replace"
done
IFS="$OLD_IFS"
fi
log INFO "Ensuring CLI configuration"
CLI_HOME="$DATA_DIR/home/.ente"
mkdir -p "$CLI_HOME"
cat > "$CLI_HOME/config.yaml" <<EOF_CLI
endpoint:
api: ${API_ORIGIN}
log:
http: false
EOF_CLI
chown -R cloudron:cloudron "$DATA_DIR/home"
chmod 700 "$DATA_DIR/home"
log INFO "Rendering Caddy configuration"
cat > "$CADDY_CONFIG" <<EOF_CADDY
{
admin off
auto_https off
}
:3080 {
log {
level INFO
output stdout
}
encode gzip
@options {
method OPTIONS
}
handle @options {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "*"
header Access-Control-Max-Age "3600"
respond 204
}
handle_path /api/* {
reverse_proxy localhost:8080 {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote}
header_up X-Forwarded-For {http.request.remote}
header_up X-Forwarded-Proto {http.request.scheme}
}
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "*"
header Access-Control-Allow-Credentials "true"
}
handle /health {
rewrite * /ping
reverse_proxy localhost:8080 {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote}
header_up X-Forwarded-For {http.request.remote}
header_up X-Forwarded-Proto {http.request.scheme}
}
}
handle /ping {
reverse_proxy localhost:8080 {
header_up Host {http.request.host}
header_up X-Real-IP {http.request.remote}
header_up X-Forwarded-For {http.request.remote}
header_up X-Forwarded-Proto {http.request.scheme}
}
}
handle /public/* {
reverse_proxy localhost:8080
}
handle /_next/* {
root * $WEB_RUNTIME_DIR
try_files {path} auth{path} accounts{path} photos{path} cast{path} albums{path} family{path}
file_server
}
handle /images/* {
root * $WEB_RUNTIME_DIR/photos
file_server
}
handle /auth/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /auth/index.html
file_server
}
handle /accounts/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /accounts/index.html
file_server
}
handle /cast/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /cast/index.html
file_server
}
handle /family/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /family/index.html
file_server
}
handle /albums/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /albums/index.html
file_server
}
handle /photos/* {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /photos/index.html
file_server
}
handle {
root * $WEB_RUNTIME_DIR
try_files {path} {path}/index.html /photos/index.html
file_server
}
}
EOF_CADDY
chown cloudron:cloudron "$CADDY_CONFIG"
log INFO "Validating Caddy configuration"
if ! caddy validate --config "$CADDY_CONFIG" > "$TMP_DIR/caddy-validate.log" 2>&1; then
cat "$TMP_DIR/caddy-validate.log"
log ERROR "Caddy configuration validation failed"
exit 1
fi
log INFO "Testing PostgreSQL connectivity"
if ! PGPASSWORD="$CLOUDRON_POSTGRESQL_PASSWORD" psql -h "$CLOUDRON_POSTGRESQL_HOST" -p "$CLOUDRON_POSTGRESQL_PORT" \
-U "$CLOUDRON_POSTGRESQL_USERNAME" -d "$CLOUDRON_POSTGRESQL_DATABASE" -c "SELECT 1" >/dev/null 2>&1; then
log ERROR "Unable to connect to PostgreSQL"
exit 1
fi
if [ "$S3_NOT_CONFIGURED" = "true" ]; then
log WARN "S3 not configured - creating configuration page"
mkdir -p "$WEB_RUNTIME_DIR/config"
cat > "$WEB_RUNTIME_DIR/config/index.html" <<'EOF_CONFIG'
<!DOCTYPE html>
<html>
<head>
<title>Ente Configuration Required</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
h1 { color: #2d2d2d; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }
</style>
</head>
<body>
<h1>Ente Configuration Required</h1>
<div class="warning">
<strong>S3 Storage Not Configured</strong>
<p>Ente requires S3-compatible object storage to function. Please configure your S3 credentials.</p>
</div>
<h2>Configuration Steps</h2>
<ol>
<li>Open the Cloudron dashboard</li>
<li>Go to your Ente app and open the Terminal</li>
<li>Edit <code>/app/data/config/s3.env</code>:
<pre>nano /app/data/config/s3.env</pre>
</li>
<li>Add your S3 credentials:
<pre>S3_ENDPOINT=https://your-s3-endpoint.com
S3_REGION=your-region
S3_BUCKET=your-bucket-name
S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key</pre>
</li>
<li>Save the file and restart the app from the Cloudron dashboard</li>
</ol>
<p>For more information, see the <a href="https://help.ente.io/self-hosting/guides/external-s3">Ente S3 Configuration Guide</a>.</p>
</body>
</html>
EOF_CONFIG
chown -R cloudron:cloudron "$WEB_RUNTIME_DIR/config"
log INFO "Starting Caddy in configuration mode"
setpriv --reuid=cloudron --regid=cloudron --init-groups caddy file-server --listen :3080 --root "$WEB_RUNTIME_DIR/config" &
CADDY_PID=$!
MUSEUM_PID=""
else
log INFO "Starting Museum server and Caddy"
setpriv --reuid=cloudron --regid=cloudron --init-groups /bin/bash -lc "cd '$MUSEUM_RUNTIME_DIR' && exec stdbuf -oL '$MUSEUM_BIN'" &
MUSEUM_PID=$!
setpriv --reuid=cloudron --regid=cloudron --init-groups caddy run --config "$CADDY_CONFIG" --watch &
CADDY_PID=$!
fi
terminate() {
log INFO "Shutting down services"
if [ -n "$MUSEUM_PID" ]; then
kill "$MUSEUM_PID" 2>/dev/null || true
fi
if [ -n "$CADDY_PID" ]; then
kill "$CADDY_PID" 2>/dev/null || true
fi
if [ -n "$MUSEUM_PID" ]; then
wait "$MUSEUM_PID" 2>/dev/null || true
fi
if [ -n "$CADDY_PID" ]; then
wait "$CADDY_PID" 2>/dev/null || true
fi
}
trap terminate TERM INT
if [ -n "$MUSEUM_PID" ]; then
wait -n "$MUSEUM_PID" "$CADDY_PID"
else
wait "$CADDY_PID"
fi
EXIT_CODE=$?
terminate
log ERROR "Service exited unexpectedly"
exit "$EXIT_CODE"