#!/bin/sh # Jotdex self-hosted installer. This script is intentionally POSIX sh so it # works on common Debian/Ubuntu, Fedora/RHEL, Alpine, NAS and ARM hosts without # requiring Node.js. Public bootstrap serves a release-pinned copy from # get.jotdex.com; --source-dir exists only for repository development checks. set -eu DEFAULT_RELEASE_BASE_URL="https://releases.jotdex.com" DEFAULT_UPDATE_MANIFEST_URL="https://updates.jotdex.com/v1/stable.json" LANGUAGE="zh-CN" VERSION="latest" PORT="8888" BIND_ADDRESS="0.0.0.0" MODE="lan" FEATURES="" INSTALL_DIR="/opt/jotdex" INSTALL_DOCKER="false" NO_START="false" DRY_RUN="false" SOURCE_DIR="" say() { if [ "$LANGUAGE" = "en" ]; then printf '%s\n' "$1" else shift printf '%s\n' "$1" fi } die() { say "$1" "$2" >&2 exit 1 } usage() { cat <<'EOF' Jotdex self-hosted installer Usage: curl -fsSL https://get.jotdex.com | sh Options: --lang zh-CN|en Installer output language (default: zh-CN) --version latest|X.Y.Z Image tag (default: latest; currently Jotdex 1.0.4) --port 1-65535 Host port (default: 8888) --bind-address ADDRESS Advanced: bind a single host address instead of all interfaces --mode lan|public-http LAN default; public HTTP is permitted without TLS --features backup Install optional scheduled backup profile --install-dir PATH Deployment directory (default: /opt/jotdex) --install-docker Explicitly allow Docker Engine installation --no-start Write files but do not start containers --dry-run Validate choices and show the plan only --source-dir PATH Development-only: use local repository assets --help Show this help Cloudflare Tunnel, R2/S3 backup credentials, enhanced search and Git Remote are configured later by an administrator in Settings -> Extensions. They are not installed by this bootstrap command. EOF } need_value() { [ "$#" -ge 2 ] || die "Missing value for $1" "$1 缺少参数" case "$2" in --*) die "Missing value for $1" "$1 缺少参数" ;; esac } valid_version() { printf '%s' "$1" | awk '/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/ { ok=1 } END { exit ok ? 0 : 1 }' } resolve_latest_release() { command -v curl >/dev/null 2>&1 || die "curl is required to resolve the latest stable release" "解析最新稳定版需要 curl" manifest="$(curl -fsSL "${JOTDEX_UPDATE_MANIFEST_URL:-$DEFAULT_UPDATE_MANIFEST_URL}")" || die "Unable to resolve the latest stable release" "无法解析最新稳定版" resolved="$(printf '%s\n' "$manifest" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)" valid_version "$resolved" || die "The stable update channel did not provide a valid release version" "稳定更新通道未提供有效版本号" printf '%s\n' "$resolved" } valid_port() { case "$1" in ''|*[!0-9]*) return 1 ;; esac [ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null } valid_bind_address() { case "$1" in ''|*[!A-Za-z0-9:.-]*) return 1 ;; esac return 0 } while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0 ;; --lang) need_value "$@"; LANGUAGE="$2"; shift 2 ;; --version) need_value "$@"; VERSION="$2"; shift 2 ;; --port) need_value "$@"; PORT="$2"; shift 2 ;; --bind-address) need_value "$@"; BIND_ADDRESS="$2"; shift 2 ;; --mode) need_value "$@"; MODE="$2"; shift 2 ;; --features) need_value "$@"; FEATURES="$2"; shift 2 ;; --install-dir) need_value "$@"; INSTALL_DIR="$2"; shift 2 ;; --install-docker) INSTALL_DOCKER="true"; shift ;; --no-start) NO_START="true"; shift ;; --dry-run) DRY_RUN="true"; shift ;; --source-dir) need_value "$@"; SOURCE_DIR="$2"; shift 2 ;; *) die "Unknown option: $1" "未知参数:$1" ;; esac done case "$LANGUAGE" in zh-CN|en) ;; *) die "--lang must be zh-CN or en" "--lang 只能是 zh-CN 或 en" ;; esac [ "$VERSION" = "latest" ] || valid_version "$VERSION" || die "--version must be latest or a SemVer release such as 1.0.0" "--version 必须是 latest 或 SemVer 版本,例如 1.0.0" valid_port "$PORT" || die "--port must be an integer from 1 through 65535" "--port 必须是 1 到 65535 的整数" case "$MODE" in lan) valid_bind_address "$BIND_ADDRESS" || die "--bind-address is invalid" "--bind-address 无效" ;; public-http) BIND_ADDRESS="0.0.0.0" ;; *) die "--mode must be lan or public-http" "--mode 只能是 lan 或 public-http" ;; esac case "$FEATURES" in ''|backup) ;; *) die "Only --features backup is available at install time" "安装时仅支持 --features backup" ;; esac architecture="$(uname -m)" case "$architecture" in x86_64|amd64) architecture="amd64" ;; aarch64|arm64) architecture="arm64" ;; *) die "Unsupported CPU architecture: $architecture" "不支持的 CPU 架构:$architecture" ;; esac if [ -r /etc/os-release ]; then # /etc/os-release commonly defines VERSION (for example, "7 (Core)" on # CentOS). Preserve the requested Jotdex release before sourcing it; the # application version must never be replaced by the host OS version. requested_jotdex_version="$VERSION" . /etc/os-release os_name="${PRETTY_NAME:-${ID:-Linux}}" VERSION="$requested_jotdex_version" else os_name="$(uname -s)" fi if [ "$DRY_RUN" = "true" ]; then say "Plan: Jotdex $VERSION for $architecture on $os_name -> $INSTALL_DIR" "计划:在 ${os_name}(${architecture})安装 Jotdex ${VERSION} 到 ${INSTALL_DIR}" say "Network: $MODE on $BIND_ADDRESS:$PORT; optional profile: ${FEATURES:-none}" "网络:${MODE},监听 ${BIND_ADDRESS}:${PORT};可选组件:${FEATURES:-无}" if [ "$MODE" = "public-http" ]; then say "Warning: public-http exposes the service without TLS. Use it only when you understand the risk and place it behind HTTPS when possible." "警告:public-http 会在没有 TLS 的情况下暴露服务。请确认你了解风险;条件允许时应放在 HTTPS 反向代理或 Cloudflare Tunnel 后。" fi exit 0 fi if [ "$VERSION" = "latest" ]; then RELEASE_VERSION="$(resolve_latest_release)" else RELEASE_VERSION="$VERSION" fi if ! command -v docker >/dev/null 2>&1; then [ "$INSTALL_DOCKER" = "true" ] || die "Docker is required. Re-run with --install-docker to explicitly permit its installation." "需要 Docker。请明确加上 --install-docker 后重试。" command -v curl >/dev/null 2>&1 || die "curl is required to install Docker" "安装 Docker 需要 curl" say "Installing Docker using Docker's official convenience script…" "将使用 Docker 官方脚本安装 Docker…" curl -fsSL https://get.docker.com | sh fi docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required" "需要 Docker Compose v2" umask 077 mkdir -p "$INSTALL_DIR" release_base="${JOTDEX_RELEASE_BASE_URL:-$DEFAULT_RELEASE_BASE_URL}" if [ -n "$SOURCE_DIR" ]; then [ -f "$SOURCE_DIR/deploy/compose.lan.yaml" ] || die "--source-dir does not contain deploy/compose.lan.yaml" "--source-dir 中没有 deploy/compose.lan.yaml" [ -f "$SOURCE_DIR/deploy/update-signing-public-key.txt" ] || die "--source-dir does not contain the update signing public key" "--source-dir 中没有更新签名公钥" cp "$SOURCE_DIR/deploy/compose.lan.yaml" "$INSTALL_DIR/compose.lan.yaml" cp "$SOURCE_DIR/deploy/update-signing-public-key.txt" "$INSTALL_DIR/update-signing-public-key.txt" else archive="jotdex-selfhosted-$RELEASE_VERSION.tar.gz" checksum="$archive.sha256" temporary="$(mktemp -d)" trap 'rm -rf "$temporary"' EXIT HUP INT TERM command -v curl >/dev/null 2>&1 || die "curl is required to download the signed release package" "下载发行包需要 curl" curl -fL "$release_base/$RELEASE_VERSION/$archive" -o "$temporary/$archive" curl -fL "$release_base/$RELEASE_VERSION/$checksum" -o "$temporary/$checksum" (cd "$temporary" && { command -v sha256sum >/dev/null 2>&1 && sha256sum -c "$checksum"; } || { command -v shasum >/dev/null 2>&1 && shasum -a 256 -c "$checksum"; }) || die "Release checksum verification failed" "发行包校验失败" tar -xzf "$temporary/$archive" -C "$temporary" [ -f "$temporary/deploy/compose.lan.yaml" ] || die "Release package is missing the Compose template" "发行包缺少 Compose 模板" [ -f "$temporary/deploy/update-signing-public-key.txt" ] || die "Release package is missing the update signing public key" "发行包缺少更新签名公钥" cp "$temporary/deploy/compose.lan.yaml" "$INSTALL_DIR/compose.lan.yaml" cp "$temporary/deploy/update-signing-public-key.txt" "$INSTALL_DIR/update-signing-public-key.txt" fi update_signing_public_key="$(tr -d '\r\n' < "$INSTALL_DIR/update-signing-public-key.txt")" printf '%s' "$update_signing_public_key" | grep -Eq '^[A-Za-z0-9_-]{43}$' || die "Release update signing public key is invalid" "发行更新签名公钥无效" chmod 644 "$INSTALL_DIR/update-signing-public-key.txt" random_secret() { openssl rand -base64 48 | tr '+/' '-_' | tr -d '=' | tr -d '\n' } random_config_key() { # The Extension Host decodes this as exactly 32 bytes for AES-256-GCM. openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' | tr -d '\n' } random_storage_root_key() { # Exactly 32 random bytes, encoded as URL-safe base64 for storagekey.Load. # This root lives outside the Docker data volume and is never put in .env. openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' | tr -d '\n' } install_admin_command() { # The command lives next to the Compose files; the global symlink is merely # a convenience, so custom --install-dir values remain fully supported. cat > "$INSTALL_DIR/jotdexctl" <<'EOF' #!/bin/sh set -eu script_path="$(readlink -f "$0")" jotdex_home="$(cd "$(dirname "$script_path")" && pwd)" compose() { docker compose --env-file "$jotdex_home/.env" -f "$jotdex_home/__JOTDEX_COMPOSE_FILE__" "$@" } usage() { cat <<'USAGE' Jotdex host administration Usage: jotdex admin pwd jotdex system status jotdex system logs jotdex system doctor jotdex system uninstall [--purge-data] jotdex backup run jotdex storage status jotdex storage verify jotdex storage encrypt jotdex storage export-key /secure/offline/jotdex-storage-root-key.json jotdex update status Commands: admin pwd Reset a forgotten password safely. system status Show the Compose service state. system logs Show the last 200 core-service log lines. system doctor Run a credential-free, read-only data diagnostic. system uninstall Stop and remove Jotdex containers, preserving data and deployment recovery files. system uninstall --purge-data Irreversibly remove Jotdex containers, volumes and deployment files. backup run Create one local recovery point now. storage status Report whether the live data directory is plaintext or encrypted. storage verify Briefly stop writers, verify the database and every stored attachment, then restart. storage encrypt Stop writers, convert a legacy plaintext instance, then restart it. storage export-key PATH Create a passphrase-protected offline root-key recovery package. update status Check the signed update channel without opening a browser. Web settings remain the normal place to configure extensions, Tunnel, Git and remote backup credentials. This command is for host-level recovery and checks. USAGE } reset_password() { was_running="false" terminal_hidden="false" cleanup() { result=$? if [ "$terminal_hidden" = "true" ]; then stty echo 2>/dev/null || true printf '\n' >&2 fi if [ "$was_running" = "true" ]; then if ! compose up -d; then printf '%s\n' '密码处理已结束,但 Jotdex 服务未能恢复启动。请执行:docker compose up -d' >&2 [ "$result" -eq 0 ] && result=1 fi fi trap - 0 exit "$result" } trap cleanup 0 trap 'exit 130' HUP INT TERM if [ ! -t 0 ] || [ ! -t 2 ]; then printf '%s\n' '此命令需要在交互式终端中运行。' >&2 exit 2 fi printf '%s' '用户名或邮箱: ' >&2 IFS= read -r username if [ -z "$(printf '%s' "$username" | tr -d '[:space:]')" ]; then echo '用户名或邮箱不能为空。' >&2 exit 2 fi printf '%s' '新的密码(12–128 个字符): ' >&2 stty -echo terminal_hidden="true" if ! IFS= read -r password; then stty echo terminal_hidden="false" printf '\n%s\n' '已取消,未作任何修改。' >&2 exit 2 fi stty echo terminal_hidden="false" printf '\n' >&2 printf '%s' '再次输入新密码: ' >&2 stty -echo terminal_hidden="true" if ! IFS= read -r confirmation; then stty echo terminal_hidden="false" printf '\n%s\n' '已取消,未作任何修改。' >&2 exit 2 fi stty echo terminal_hidden="false" printf '\n' >&2 if [ "$password" != "$confirmation" ]; then echo '两次输入的密码不一致,未作任何修改。' >&2 exit 2 fi if compose ps --status running -q jotdex | grep -q .; then was_running="true" # Password replacement opens the same encrypted database as Core. Stop # every optional writer first, otherwise a scheduled backup or enabled # Git worker could race the password transaction while Core is paused. compose stop jotdex jotdex-extension-host jotdex-update-agent jotdex-backup jotdex-git jotdex-git-http fi printf '%s' "$password" | compose run --rm --no-deps -T \ --entrypoint /app/jotdex-user-password jotdex \ --username "$username" --password-stdin unset password confirmation echo '密码已重置。此前浏览器和原生设备会话已撤销,请重新登录。' } system_status() { compose ps } system_logs() { compose logs --tail=200 jotdex } system_doctor() { compose run --rm --no-deps --entrypoint /app/jotdex-diagnose jotdex } system_uninstall() { purge_data="${1:-false}" if [ "$purge_data" != "false" ] && [ "$purge_data" != "true" ]; then usage exit 2 fi if [ ! -t 0 ] || [ ! -t 2 ]; then printf '%s\n' '卸载命令需要在交互式终端中运行。' >&2 exit 2 fi case "$jotdex_home" in ''|/|/opt|/usr|/usr/local|/var|/tmp) printf '%s\n' "拒绝对不安全的安装目录执行卸载:$jotdex_home" >&2 exit 2 ;; esac if [ "$purge_data" = "true" ]; then printf '%s\n' '将永久删除 Jotdex 容器、全部数据卷、笔记、附件、备份恢复点和本机密钥;此操作无法恢复。' >&2 printf '%s' '输入 DELETE JOTDEX DATA 继续: ' >&2 IFS= read -r confirmation if [ "$confirmation" != 'DELETE JOTDEX DATA' ]; then printf '%s\n' '已取消,未作任何修改。' >&2 exit 2 fi compose down --remove-orphans --volumes command_path="${JOTDEX_COMMAND_PATH:-/usr/local/bin/jotdex}" if [ -L "$command_path" ] && [ "$(readlink -f "$command_path" 2>/dev/null || printf '')" = "$jotdex_home/jotdexctl" ]; then rm -f "$command_path" fi rm -rf "$jotdex_home" printf '%s\n' 'Jotdex、其数据卷和本机密钥已永久删除。' return fi printf '%s\n' '将停止并移除 Jotdex 容器;笔记、附件和数据卷不会删除。安装目录会改名保留,以便恢复或重新安装。' >&2 printf '%s' '输入 UNINSTALL JOTDEX 继续: ' >&2 IFS= read -r confirmation if [ "$confirmation" != 'UNINSTALL JOTDEX' ]; then printf '%s\n' '已取消,未作任何修改。' >&2 exit 2 fi compose down --remove-orphans archived_home="${jotdex_home}.uninstalled-$(date -u +%Y%m%d-%H%M%S)" if [ -e "$archived_home" ]; then printf '%s\n' "保留目录已存在,未作任何删除:$archived_home" >&2 exit 1 fi command_path="${JOTDEX_COMMAND_PATH:-/usr/local/bin/jotdex}" if [ -L "$command_path" ] && [ "$(readlink -f "$command_path" 2>/dev/null || printf '')" = "$jotdex_home/jotdexctl" ]; then rm -f "$command_path" fi mv "$jotdex_home" "$archived_home" printf '%s\n' "Jotdex 已卸载;数据卷仍保留,部署恢复文件位于:$archived_home" } backup_run() { # The backup worker is an opt-in Compose profile; enable it only for this # explicit one-shot command so normal LAN/public startup stays lightweight. compose --profile backup run --rm jotdex-backup } storage_status() { compose run --rm --no-deps --entrypoint /app/jotdex-storage jotdex status } storage_verify() { was_running="false" if compose ps --status running -q jotdex | grep -q .; then was_running="true" compose stop jotdex jotdex-extension-host jotdex-update-agent jotdex-backup jotdex-git jotdex-git-http fi restore_after_verify() { result=$? if [ "$was_running" = "true" ]; then compose up -d fi trap - 0 HUP INT TERM exit "$result" } trap restore_after_verify 0 HUP INT TERM compose run --rm --no-deps --entrypoint /app/jotdex-storage jotdex verify trap - 0 HUP INT TERM if [ "$was_running" = "true" ]; then compose up -d fi } storage_encrypt() { if [ ! -t 0 ] || [ ! -t 2 ]; then printf '%s\n' '此命令需要在交互式终端中运行。' >&2 exit 2 fi printf '%s\n' '迁移会停止 Jotdex,先创建并校验新的明文恢复点,再转换数据库与全部附件;明文回滚副本会保留。请确认根密钥已独立保存。' >&2 printf '%s' '输入 I_UNDERSTAND_STORAGE_MIGRATION 继续: ' >&2 IFS= read -r confirmation if [ "$confirmation" != "I_UNDERSTAND_STORAGE_MIGRATION" ]; then printf '%s\n' '已取消,未作任何修改。' >&2 exit 2 fi compose stop jotdex jotdex-extension-host jotdex-update-agent jotdex-backup jotdex-git jotdex-git-http restart_core="true" restore_after_failure() { result=$? if [ "$restart_core" = "true" ]; then compose up -d fi trap - 0 HUP INT TERM exit "$result" } trap restore_after_failure 0 HUP INT TERM printf '%s\n' '正在停止写入者,并创建、校验迁移前恢复点…' >&2 compose run --rm --no-deps --entrypoint /app/jotdex-storage jotdex encrypt --confirm I_UNDERSTAND_STORAGE_MIGRATION temporary_env="$(mktemp "$jotdex_home/.env.storage-XXXXXX")" trap 'rm -f "$temporary_env"; restore_after_failure' 0 HUP INT TERM awk '!/^JOTDEX_STORAGE_ENCRYPTION=/' "$jotdex_home/.env" > "$temporary_env" printf '%s\n' 'JOTDEX_STORAGE_ENCRYPTION=encrypted' >> "$temporary_env" chmod 600 "$temporary_env" mv "$temporary_env" "$jotdex_home/.env" compose up -d restart_core="false" trap - 0 HUP INT TERM printf '%s\n' '加密迁移已完成并重启。现在运行 jotdex storage verify,并验证笔记、附件和独立恢复演练;之后才能按文档显式清理明文回滚副本。' } storage_export_key() { output_path="${1:-}" if [ -z "$output_path" ] || [ "$#" -ne 1 ]; then usage exit 2 fi if [ ! -t 0 ] || [ ! -t 2 ]; then printf '%s\n' '此命令需要在交互式终端中运行。' >&2 exit 2 fi output_parent="$(dirname "$output_path")" output_name="$(basename "$output_path")" if [ ! -d "$output_parent" ] || [ "$output_name" = "." ] || [ "$output_name" = "/" ]; then printf '%s\n' '恢复包的目标目录必须已存在。' >&2 exit 2 fi output_parent="$(cd "$output_parent" && pwd)" output_path="$output_parent/$output_name" if [ -e "$output_path" ]; then printf '%s\n' '恢复包目标已存在;不会覆盖已有文件。' >&2 exit 2 fi printf '%s' '恢复包口令(至少 12 个字符): ' >&2 stty -echo password_hidden="true" if ! IFS= read -r passphrase; then stty echo password_hidden="false" printf '\n%s\n' '已取消,未写入恢复包。' >&2 exit 2 fi stty echo password_hidden="false" printf '\n%s' '再次输入恢复包口令: ' >&2 stty -echo password_hidden="true" if ! IFS= read -r confirmation; then stty echo password_hidden="false" printf '\n%s\n' '已取消,未写入恢复包。' >&2 exit 2 fi stty echo password_hidden="false" printf '\n' >&2 if [ "$passphrase" != "$confirmation" ]; then printf '%s\n' '两次输入的口令不一致,未写入恢复包。' >&2 exit 2 fi if [ "${#passphrase}" -lt 12 ]; then printf '%s\n' '恢复包口令至少需要 12 个字符。' >&2 exit 2 fi cleanup_export_key() { result=$? if [ "${password_hidden:-false}" = "true" ]; then stty echo 2>/dev/null || true printf '\n' >&2 fi unset passphrase confirmation trap - 0 HUP INT TERM exit "$result" } trap cleanup_export_key 0 trap 'exit 130' HUP INT TERM # The recovery package is intentionally created outside /data. Run the # one-shot tool as root only so the administrator's protected offline # directory is writable; the long-running Jotdex service remains UID 10001. printf '%s' "$passphrase" | compose run --rm --no-deps -T --user 0:0 \ -v "$output_parent:/recovery" --entrypoint /app/jotdex-storage jotdex \ export-key --storage-key-file /run/secrets/jotdex_storage_key \ --output "/recovery/$output_name" --passphrase-stdin unset passphrase confirmation trap - 0 HUP INT TERM printf '%s\n' "恢复包已写入 $output_path。请把它与口令分别离线保存;两者缺一不可。" } update_status() { # The Agent has no host port. Query its loopback endpoint from inside the # same private container, keeping the bearer token out of host arguments and # shell history. Applying an update remains a Web action so the normal # recent-password, recovery-point and migration confirmations cannot be # bypassed by a host command. compose exec -T jotdex-update-agent sh -c 'wget -qO- --header="Authorization: Bearer $JOTDEX_UPDATE_AGENT_TOKEN" "http://127.0.0.1:8091/v1/update?currentVersion=$JOTDEX_CURRENT_VERSION"' } case "${1:-}" in admin) if [ "${2:-}" = "pwd" ] && [ "$#" -eq 2 ]; then reset_password else usage exit 2 fi ;; system) case "${2:-}" in status) [ "$#" -eq 2 ] && system_status || { usage; exit 2; } ;; logs) [ "$#" -eq 2 ] && system_logs || { usage; exit 2; } ;; doctor) [ "$#" -eq 2 ] && system_doctor || { usage; exit 2; } ;; uninstall) case "$#" in 2) system_uninstall ;; 3) [ "${3:-}" = "--purge-data" ] || { usage; exit 2; }; system_uninstall true ;; *) usage; exit 2 ;; esac ;; *) usage; exit 2 ;; esac ;; backup) if [ "${2:-}" = "run" ] && [ "$#" -eq 2 ]; then backup_run else usage exit 2 fi ;; storage) case "${2:-}" in status) [ "$#" -eq 2 ] && storage_status || { usage; exit 2; } ;; verify) [ "$#" -eq 2 ] && storage_verify || { usage; exit 2; } ;; encrypt) [ "$#" -eq 2 ] && storage_encrypt || { usage; exit 2; } ;; export-key) shift 2; storage_export_key "$@" ;; *) usage; exit 2 ;; esac ;; update) if [ "${2:-}" = "status" ] && [ "$#" -eq 2 ]; then update_status else usage exit 2 fi ;; --help|-h|help|'') usage ;; *) usage; exit 2 ;; esac EOF # Keep the generated command aligned with the deployment mode selected by # the installer. Direct public HTTP intentionally reuses the lean LAN # template: the generated `.env` changes the bind address and access mode. # `compose.public.yaml` is the separate optional Caddy ingress template; # selecting it here would require a domain and secure cookies. sed -i.bak "s|__JOTDEX_COMPOSE_FILE__|$compose_file_name|g" "$INSTALL_DIR/jotdexctl" rm -f "$INSTALL_DIR/jotdexctl.bak" chmod 700 "$INSTALL_DIR/jotdexctl" # Tests and managed appliance images may redirect only the convenience # symlink; the deployment itself still lives at --install-dir. Normal # installations deliberately retain the conventional /usr/local/bin path. command_path="${JOTDEX_COMMAND_PATH:-/usr/local/bin/jotdex}" if [ -e "$command_path" ] && [ "$(readlink -f "$command_path" 2>/dev/null || printf '')" != "$INSTALL_DIR/jotdexctl" ]; then say "Host command was not installed because $command_path already exists. Use $INSTALL_DIR/jotdexctl admin pwd." "未安装全局管理命令,因为 $command_path 已存在。请使用 $INSTALL_DIR/jotdexctl admin pwd。" return fi mkdir -p "$(dirname "$command_path")" ln -sfn "$INSTALL_DIR/jotdexctl" "$command_path" } command -v openssl >/dev/null 2>&1 || die "openssl is required to create private deployment credentials" "生成部署私钥需要 openssl" storage_key_directory="$INSTALL_DIR/secrets" storage_key_path="$storage_key_directory/storage-root.key" case "$MODE" in public-http) compose_file_name="compose.lan.yaml" ;; *) compose_file_name="compose.lan.yaml" ;; esac update_channel="stable" mkdir -p "$storage_key_directory" chmod 700 "$storage_key_directory" if [ -e "$storage_key_path" ]; then die "Storage key path already exists: $storage_key_path" "存储密钥文件已存在:$storage_key_path" fi (umask 077 && random_storage_root_key > "$storage_key_path") # The release containers intentionally run as the unprivileged fixed UID # 10001. Make that UID the sole non-root reader; mounting a root:root 0600 # file would make a correct encrypted installation fail at boot. chown 10001:10001 "$storage_key_path" chmod 400 "$storage_key_path" cat > "$INSTALL_DIR/.env" </dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then if ! firewall-cmd --query-port="${PORT}/tcp" >/dev/null 2>&1; then if firewall-cmd --permanent --add-port="${PORT}/tcp" >/dev/null 2>&1 && firewall-cmd --reload >/dev/null 2>&1; then say "Opened TCP port ${PORT} in firewalld." "已在 firewalld 中放行 TCP 端口 ${PORT}。" else say "Jotdex started, but firewalld could not be updated automatically. Run: firewall-cmd --permanent --add-port=${PORT}/tcp && firewall-cmd --reload" "Jotdex 已启动,但无法自动修改 firewalld。请运行:firewall-cmd --permanent --add-port=${PORT}/tcp && firewall-cmd --reload" fi fi return 0 fi if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then if ufw allow "${PORT}/tcp" >/dev/null 2>&1; then say "Opened TCP port ${PORT} in UFW." "已在 UFW 中放行 TCP 端口 ${PORT}。" else say "Jotdex started, but UFW could not be updated automatically. Run: ufw allow ${PORT}/tcp" "Jotdex 已启动,但无法自动修改 UFW。请运行:ufw allow ${PORT}/tcp" fi fi } if [ "$NO_START" = "false" ]; then set -- docker compose --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/$compose_file_name" [ "$FEATURES" = "backup" ] && set -- "$@" --profile backup "$@" up -d open_host_port fi display_address="$BIND_ADDRESS" if [ "$BIND_ADDRESS" = "0.0.0.0" ] || [ "$BIND_ADDRESS" = "::" ]; then # 0.0.0.0 is a bind address, not a browser destination. Do not guess a # possibly transient public IP; show an explicit replacement hint instead. display_address="" fi say "Installed Jotdex ${RELEASE_VERSION} using image tag ${VERSION}. Open http://${display_address}:${PORT} and complete first-run setup." "Jotdex ${RELEASE_VERSION} 已安装(镜像标签:${VERSION})。请打开 http://${display_address}:${PORT} 完成首次设置。" if [ "$NO_START" = "false" ]; then say "Open the address above and create your administrator directly." "请直接打开上方地址创建管理员。" fi if [ "$MODE" = "public-http" ]; then say "Warning: public-http exposes credentials and session traffic without TLS. Use only on a trusted network or add HTTPS before sharing the address publicly." "警告:public-http 会让账号凭据和会话流量在没有 TLS 的情况下传输。仅应在可信网络使用;公开访问前请先加 HTTPS。" fi say "Keep $INSTALL_DIR/.env and $storage_key_path private." "请妥善保管 $INSTALL_DIR/.env 和 ${storage_key_path}。" say "To reset a forgotten password later, run: jotdex admin pwd" "如忘记密码,可运行:jotdex admin pwd"