fluxer/scripts/dev_bootstrap.sh

522 lines
21 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env sh
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
info() { printf "%b\n" "${GREEN}[INFO]${NC} $1"; }
warn() { printf "%b\n" "${YELLOW}[WARN]${NC} $1"; }
error() { printf "%b\n" "${RED}[ERROR]${NC} $1"; }
prepare_log_dir() {
info "Ensuring dev log directory exists..."
mkdir -p "$REPO_ROOT/dev/logs"
}
check_config() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
template_path="$REPO_ROOT/config/config.dev.template.json"
if [ ! -f "$config_path" ]; then
if [ -f "$template_path" ]; then
info "No config found, creating from development template..."
cp "$template_path" "$config_path"
else
error "Configuration file not found: $config_path"
error "Template file also missing: $template_path"
exit 1
fi
fi
}
random_hex() {
byte_count="$1"
node - "$byte_count" <<'NODE'
const {randomBytes} = require('node:crypto');
const byteCount = Number(process.argv[2]);
if (!Number.isInteger(byteCount) || byteCount <= 0) {
process.exit(1);
}
process.stdout.write(randomBytes(byteCount).toString('hex'));
NODE
}
is_empty_or_placeholder() {
value="$1"
shift
if [ -z "$value" ]; then
return 0
fi
for placeholder in "$@"; do
if [ "$value" = "$placeholder" ]; then
return 0
fi
done
return 1
}
seed_hex_secret() {
current_value="$1"
byte_count="$2"
shift 2
if is_empty_or_placeholder "$current_value" "$@"; then
random_hex "$byte_count"
else
printf '%s' "$current_value"
fi
}
sync_meilisearch_key_file() {
has_search="$1"
api_key="$2"
if [ "$has_search" != "true" ]; then
return 0
fi
meilisearch_key_path="$REPO_ROOT/dev/meilisearch_master_key"
meilisearch_key_file_value="$(cat "$meilisearch_key_path" 2>/dev/null || true)"
if [ "$meilisearch_key_file_value" != "$api_key" ]; then
printf '%s' "$api_key" > "$meilisearch_key_path"
chmod 600 "$meilisearch_key_path" 2>/dev/null || true
fi
mkdir -p "$REPO_ROOT/dev/data/meilisearch"
}
ensure_core_secrets() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
info "Checking development secret configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping secret generation"
return 0
fi
current_s3_access_key_id=$(jq -r '.s3.access_key_id // empty' "$config_path" 2>/dev/null || true)
current_s3_secret_access_key=$(jq -r '.s3.secret_access_key // empty' "$config_path" 2>/dev/null || true)
current_media_proxy_secret_key=$(jq -r '.services.media_proxy.secret_key // empty' "$config_path" 2>/dev/null || true)
current_admin_secret_key_base=$(jq -r '.services.admin.secret_key_base // empty' "$config_path" 2>/dev/null || true)
current_admin_oauth_client_secret=$(jq -r '.services.admin.oauth_client_secret // empty' "$config_path" 2>/dev/null || true)
current_marketing_secret_key_base=$(jq -r '.services.marketing.secret_key_base // empty' "$config_path" 2>/dev/null || true)
current_gateway_admin_reload_secret=$(jq -r '.services.gateway.admin_reload_secret // empty' "$config_path" 2>/dev/null || true)
current_queue_secret=$(jq -r '.services.queue.secret // empty' "$config_path" 2>/dev/null || true)
current_meilisearch_api_key=$(jq -r '.integrations.search.api_key // empty' "$config_path" 2>/dev/null || true)
current_rpc_secret=$(jq -r '.gateway.rpc_secret // empty' "$config_path" 2>/dev/null || true)
current_sudo_mode_secret=$(jq -r '.auth.sudo_mode_secret // empty' "$config_path" 2>/dev/null || true)
current_connection_initiation_secret=$(jq -r '.auth.connection_initiation_secret // empty' "$config_path" 2>/dev/null || true)
current_smtp_password=$(jq -r '.integrations.email.smtp.password // empty' "$config_path" 2>/dev/null || true)
current_voice_api_key=$(jq -r '.integrations.voice.api_key // empty' "$config_path" 2>/dev/null || true)
current_voice_api_secret=$(jq -r '.integrations.voice.api_secret // empty' "$config_path" 2>/dev/null || true)
has_smtp=$(jq -r '.integrations.email.smtp != null' "$config_path" 2>/dev/null || echo "false")
has_marketing=$(jq -r '.services.marketing != null' "$config_path" 2>/dev/null || echo "false")
has_queue=$(jq -r '.services.queue != null' "$config_path" 2>/dev/null || echo "false")
has_search=$(jq -r '.integrations.search != null' "$config_path" 2>/dev/null || echo "false")
has_voice=$(jq -r '.integrations.voice != null' "$config_path" 2>/dev/null || echo "false")
seeded_s3_access_key_id=$(seed_hex_secret "$current_s3_access_key_id" 16 "dev-access-key" "fluxer-dev-access-key")
seeded_s3_secret_access_key=$(seed_hex_secret "$current_s3_secret_access_key" 32 "dev-secret-key" "fluxer-dev-secret-key")
seeded_media_proxy_secret_key=$(seed_hex_secret "$current_media_proxy_secret_key" 32 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
seeded_admin_secret_key_base=$(seed_hex_secret "$current_admin_secret_key_base" 32 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
seeded_admin_oauth_client_secret=$(seed_hex_secret "$current_admin_oauth_client_secret" 32 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210")
seeded_marketing_secret_key_base="$current_marketing_secret_key_base"
if [ "$has_marketing" = "true" ]; then
seeded_marketing_secret_key_base=$(seed_hex_secret "$current_marketing_secret_key_base" 32 "marketing0123456789abcdef0123456789abcdef0123456789abcdef01234567")
fi
seeded_gateway_admin_reload_secret=$(seed_hex_secret "$current_gateway_admin_reload_secret" 32 "deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_queue_secret="$current_queue_secret"
if [ "$has_queue" = "true" ]; then
seeded_queue_secret=$(seed_hex_secret "$current_queue_secret" 32 "queue00123456789abcdef0123456789abcdef0123456789abcdef0123456789")
fi
seeded_meilisearch_api_key="$current_meilisearch_api_key"
if [ "$has_search" = "true" ]; then
seeded_meilisearch_api_key=$(seed_hex_secret "$current_meilisearch_api_key" 32 "meilisearch0123456789abcdef0123456789abcdef0123456789abcdef012345")
fi
seeded_rpc_secret=$(seed_hex_secret "$current_rpc_secret" 32 "cafebabe0123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_sudo_mode_secret=$(seed_hex_secret "$current_sudo_mode_secret" 32 "c0ffee000123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_connection_initiation_secret=$(seed_hex_secret "$current_connection_initiation_secret" 32 "d0d0ca000123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_smtp_password="$current_smtp_password"
if [ "$has_smtp" = "true" ]; then
seeded_smtp_password=$(seed_hex_secret "$current_smtp_password" 16 "dev")
fi
seeded_voice_api_key="$current_voice_api_key"
seeded_voice_api_secret="$current_voice_api_secret"
if [ "$has_voice" = "true" ]; then
seeded_voice_api_key=$(seed_hex_secret "$current_voice_api_key" 32 "5VCKLGhj3Yz0q2GIBnuumpOP1GlSTSw5mLPZDvZNIvQpiocQXDQIwTS5CRrnOhe7" "devkey")
seeded_voice_api_secret=$(seed_hex_secret "$current_voice_api_secret" 32 "devsecret")
fi
has_changes=false
if [ "$seeded_s3_access_key_id" != "$current_s3_access_key_id" ]; then has_changes=true; fi
if [ "$seeded_s3_secret_access_key" != "$current_s3_secret_access_key" ]; then has_changes=true; fi
if [ "$seeded_media_proxy_secret_key" != "$current_media_proxy_secret_key" ]; then has_changes=true; fi
if [ "$seeded_admin_secret_key_base" != "$current_admin_secret_key_base" ]; then has_changes=true; fi
if [ "$seeded_admin_oauth_client_secret" != "$current_admin_oauth_client_secret" ]; then has_changes=true; fi
if [ "$has_marketing" = "true" ] && [ "$seeded_marketing_secret_key_base" != "$current_marketing_secret_key_base" ]; then has_changes=true; fi
if [ "$seeded_gateway_admin_reload_secret" != "$current_gateway_admin_reload_secret" ]; then has_changes=true; fi
if [ "$has_queue" = "true" ] && [ "$seeded_queue_secret" != "$current_queue_secret" ]; then has_changes=true; fi
if [ "$has_search" = "true" ] && [ "$seeded_meilisearch_api_key" != "$current_meilisearch_api_key" ]; then has_changes=true; fi
if [ "$seeded_rpc_secret" != "$current_rpc_secret" ]; then has_changes=true; fi
if [ "$seeded_sudo_mode_secret" != "$current_sudo_mode_secret" ]; then has_changes=true; fi
if [ "$seeded_connection_initiation_secret" != "$current_connection_initiation_secret" ]; then has_changes=true; fi
if [ "$has_smtp" = "true" ] && [ "$seeded_smtp_password" != "$current_smtp_password" ]; then has_changes=true; fi
if [ "$has_voice" = "true" ] && [ "$seeded_voice_api_key" != "$current_voice_api_key" ]; then has_changes=true; fi
if [ "$has_voice" = "true" ] && [ "$seeded_voice_api_secret" != "$current_voice_api_secret" ]; then has_changes=true; fi
if [ "$has_changes" = false ]; then
info "Development secrets already configured"
sync_meilisearch_key_file "$has_search" "$seeded_meilisearch_api_key"
return 0
fi
# Development secrets are generated locally during bootstrap to avoid
# committing placeholder values that look like real credentials.
info "Generating local development secrets..."
temp_config="$config_path.tmp"
jq \
--arg s3_access_key_id "$seeded_s3_access_key_id" \
--arg s3_secret_access_key "$seeded_s3_secret_access_key" \
--arg media_proxy_secret_key "$seeded_media_proxy_secret_key" \
--arg admin_secret_key_base "$seeded_admin_secret_key_base" \
--arg admin_oauth_client_secret "$seeded_admin_oauth_client_secret" \
--arg marketing_secret_key_base "$seeded_marketing_secret_key_base" \
--arg gateway_admin_reload_secret "$seeded_gateway_admin_reload_secret" \
--arg queue_secret "$seeded_queue_secret" \
--arg meilisearch_api_key "$seeded_meilisearch_api_key" \
--arg rpc_secret "$seeded_rpc_secret" \
--arg sudo_mode_secret "$seeded_sudo_mode_secret" \
--arg connection_initiation_secret "$seeded_connection_initiation_secret" \
--arg smtp_password "$seeded_smtp_password" \
--arg voice_api_key "$seeded_voice_api_key" \
--arg voice_api_secret "$seeded_voice_api_secret" \
'.s3.access_key_id = $s3_access_key_id |
.s3.secret_access_key = $s3_secret_access_key |
.services.media_proxy.secret_key = $media_proxy_secret_key |
.services.admin.secret_key_base = $admin_secret_key_base |
.services.admin.oauth_client_secret = $admin_oauth_client_secret |
(if .services.marketing != null then .services.marketing.secret_key_base = $marketing_secret_key_base else . end) |
.services.gateway.admin_reload_secret = $gateway_admin_reload_secret |
(if .services.queue != null then .services.queue.secret = $queue_secret else . end) |
(if .integrations.search != null then .integrations.search.api_key = $meilisearch_api_key else . end) |
.gateway.rpc_secret = $rpc_secret |
.auth.sudo_mode_secret = $sudo_mode_secret |
.auth.connection_initiation_secret = $connection_initiation_secret |
(if .integrations.email.smtp != null then .integrations.email.smtp.password = $smtp_password else . end) |
(if .integrations.voice != null then .integrations.voice.api_key = $voice_api_key | .integrations.voice.api_secret = $voice_api_secret else . end)' \
"$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "Development secrets configured"
sync_meilisearch_key_file "$has_search" "$seeded_meilisearch_api_key"
else
error "Failed to update config.json with development secrets"
rm -f "$temp_config"
return 1
fi
}
validate_vapid_keys() {
public_key="$1"
private_key="$2"
node - "$public_key" "$private_key" >/dev/null 2>&1 <<'NODE'
const [publicKey, privateKey] = process.argv.slice(2);
try {
if (!publicKey || !privateKey) {
process.exit(1);
}
const publicRaw = Buffer.from(publicKey, 'base64url');
const privateRaw = Buffer.from(privateKey, 'base64url');
if (publicRaw.length !== 65 || publicRaw[0] !== 0x04 || privateRaw.length !== 32) {
process.exit(1);
}
process.exit(0);
} catch (_error) {
process.exit(1);
}
NODE
}
generate_vapid_keypair() {
node - <<'NODE'
const {generateKeyPairSync} = require('node:crypto');
const {privateKey, publicKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
const publicJwk = publicKey.export({format: 'jwk'});
const privateJwk = privateKey.export({format: 'jwk'});
const publicRaw = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(publicJwk.x, 'base64url'),
Buffer.from(publicJwk.y, 'base64url'),
]);
process.stdout.write(
JSON.stringify({
public_key: publicRaw.toString('base64url'),
private_key: privateJwk.d,
})
);
NODE
}
ensure_vapid_keys() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
info "Checking VAPID configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping VAPID key generation"
return 0
fi
vapid_public_key=$(jq -r '.auth.vapid.public_key // empty' "$config_path" 2>/dev/null || true)
vapid_private_key=$(jq -r '.auth.vapid.private_key // empty' "$config_path" 2>/dev/null || true)
if validate_vapid_keys "$vapid_public_key" "$vapid_private_key"; then
info "VAPID keys already configured"
return 0
fi
# Development VAPID keys are generated locally by bootstrap, not issued by
# an external provider. There is no external renewal process if keys are
# missing or invalid we generate a fresh pair here.
info "Generating development-only VAPID keypair..."
vapid_keys_json=$(generate_vapid_keypair)
generated_public_key=$(printf '%s' "$vapid_keys_json" | jq -r '.public_key // empty')
generated_private_key=$(printf '%s' "$vapid_keys_json" | jq -r '.private_key // empty')
if ! validate_vapid_keys "$generated_public_key" "$generated_private_key"; then
error "Failed to generate valid VAPID keys"
return 1
fi
temp_config="$config_path.tmp"
jq --arg vapid_public_key "$generated_public_key" \
--arg vapid_private_key "$generated_private_key" \
'.auth.vapid.public_key = $vapid_public_key |
.auth.vapid.private_key = $vapid_private_key' "$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "VAPID keys configured for development"
else
error "Failed to update config.json with VAPID keys"
rm -f "$temp_config"
return 1
fi
}
generate_bluesky_oauth_keys() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
key_path="$REPO_ROOT/dev/bluesky_oauth_key.pem"
info "Checking Bluesky OAuth configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping Bluesky OAuth key generation"
return 0
fi
keys_length=$(jq -r '.auth.bluesky.keys | length' "$config_path" 2>/dev/null || echo "0")
if [ "$keys_length" != "0" ]; then
all_keys_exist=true
for key_file in $(jq -r '.auth.bluesky.keys[].private_key_path // empty' "$config_path" 2>/dev/null); do
if [ ! -f "$key_file" ]; then
warn "Configured key file missing: $key_file"
all_keys_exist=false
continue
fi
if ! openssl pkey -in "$key_file" -text -noout 2>/dev/null | grep -Eq "prime256v1|secp256r1"; then
warn "Configured key file is not an ES256 (P-256) key: $key_file"
all_keys_exist=false
elif ! openssl pkcs8 -topk8 -nocrypt -in "$key_file" -out /dev/null >/dev/null 2>&1; then
warn "Configured key file is not PKCS#8 encoded: $key_file"
all_keys_exist=false
fi
done
if [ "$all_keys_exist" = true ]; then
info "Bluesky OAuth keys already configured"
return 0
fi
info "Regenerating Bluesky OAuth key files..."
fi
info "Generating Bluesky OAuth ES256 (P-256) keypair..."
mkdir -p "$REPO_ROOT/dev"
if ! openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$key_path" >/dev/null 2>&1; then
error "Failed to generate ES256 key for Bluesky OAuth"
return 1
fi
info "Generated ES256 key at: $key_path"
info "Updating config.json with Bluesky OAuth key..."
temp_config="$config_path.tmp"
jq --arg kid "dev-key-1" \
--arg key_path "$key_path" \
'.auth.bluesky.enabled = true |
.auth.bluesky.logo_uri = "https://fluxerstatic.com/web/apple-touch-icon.png" |
.auth.bluesky.tos_uri = "https://fluxer.app/terms" |
.auth.bluesky.policy_uri = "https://fluxer.app/privacy" |
.auth.bluesky.token_endpoint_auth_signing_alg = "ES256" |
.auth.bluesky.keys = [{
"kid": $kid,
"private_key_path": $key_path
}]' "$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "Bluesky OAuth configured with dev key (enabled: true)"
else
error "Failed to update config.json with Bluesky OAuth key"
rm -f "$temp_config"
return 1
fi
}
generate_livekit_config() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
livekit_config="$REPO_ROOT/dev/livekit.yaml"
template="$REPO_ROOT/dev/livekit.template.yaml"
info "Generating LiveKit configuration..."
api_key=
api_secret=
webhook_url=
base_domain=
api_key=$(jq -r '.integrations.voice.api_key // empty' "$config_path" 2>/dev/null || true)
api_secret=$(jq -r '.integrations.voice.api_secret // empty' "$config_path" 2>/dev/null || true)
webhook_url=$(jq -r '.integrations.voice.webhook_url // empty' "$config_path" 2>/dev/null || true)
base_domain=$(jq -r '.domain.base_domain // empty' "$config_path" 2>/dev/null || true)
api_key="${api_key:-devkey}"
api_secret="${api_secret:-devsecret}"
webhook_url="${webhook_url:-http://localhost:49319/api/webhooks/livekit}"
base_domain="${base_domain:-localhost}"
if [ "$base_domain" = "localhost" ] || [ "$base_domain" = "127.0.0.1" ]; then
node_ip="127.0.0.1"
turn_domain="localhost"
else
turn_domain="$base_domain"
node_ip=$(curl -4 -sf --max-time 5 https://ifconfig.me 2>/dev/null || true)
if [ -z "$node_ip" ]; then
node_ip=$(curl -4 -sf --max-time 5 https://api.ipify.org 2>/dev/null || true)
fi
if [ -z "$node_ip" ]; then
warn "Could not resolve public IP for LiveKit. Voice may not work for remote clients."
warn "Set rtc.node_ip manually in dev/livekit.yaml to your server's public IP."
node_ip="127.0.0.1"
else
info "Resolved public IP for LiveKit: $node_ip"
fi
fi
sed -e "s|{{API_KEY}}|$api_key|g" \
-e "s|{{API_SECRET}}|$api_secret|g" \
-e "s|{{WEBHOOK_URL}}|$webhook_url|g" \
-e "s|{{NODE_IP}}|$node_ip|g" \
-e "s|{{TURN_DOMAIN}}|$turn_domain|g" \
"$template" > "$livekit_config"
info "LiveKit config generated at: $livekit_config (domain: $base_domain, node_ip: $node_ip)"
}
setup_model_symlink() {
source="$REPO_ROOT/fluxer_media_proxy/data/model.onnx"
target_dir="$REPO_ROOT/fluxer_server/data"
target="$target_dir/model.onnx"
info "Setting up ONNX model symlink..."
if [ ! -f "$source" ]; then
warn "Source model not found: $source"
warn "NSFW detection will not work until model.onnx is provided"
return 0
fi
mkdir -p "$target_dir"
if ls -ld "$target" 2>/dev/null | grep -q '^l'; then
info "Model symlink already exists"
elif [ -f "$target" ]; then
source_size=$(stat -f%z "$source" 2>/dev/null || stat -c%s "$source" 2>/dev/null)
target_size=$(stat -f%z "$target" 2>/dev/null || stat -c%s "$target" 2>/dev/null)
if [ "$target_size" -lt 1000 ]; then
info "Replacing empty/corrupt model file with symlink"
rm -f "$target"
ln -s "$source" "$target"
else
info "Model file already exists (not a symlink)"
fi
else
ln -s "$source" "$target"
info "Created model symlink: $target -> $source"
fi
}
main() {
echo ""
info "Fluxer Development Bootstrap"
echo ""
prepare_log_dir
check_config
ensure_core_secrets
ensure_vapid_keys
generate_bluesky_oauth_keys
generate_livekit_config
setup_model_symlink
echo ""
info "Bootstrap complete"
echo ""
}
main "$@"