From 1cdb1edd86482d3334109dd3960b13544d0423e2 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:26:54 +0800 Subject: [PATCH] feat: add epctl installer, docker validation script, and bilingual docs --- README.en.md | 9 +- README.md | 7 + epctl | 1467 ++++++++++++++++++++++++++++++++++++++++++ epctl-docker-test.sh | 379 +++++++++++ wiki/EPCTL.en.md | 213 ++++++ wiki/EPCTL.md | 213 ++++++ 6 files changed, 2287 insertions(+), 1 deletion(-) create mode 100755 epctl create mode 100755 epctl-docker-test.sh create mode 100644 wiki/EPCTL.en.md create mode 100644 wiki/EPCTL.md diff --git a/README.en.md b/README.en.md index e541b5b..dabbeeb 100644 --- a/README.en.md +++ b/README.en.md @@ -10,7 +10,7 @@

English | - 简体中文 + 简体中文

@@ -88,17 +88,24 @@ Quick-start links: | Guide | Description | |-------|-------------| +| [Repo-local: epctl installer](wiki/EPCTL.en.md) | Linux binary install, upgrade, status inspection, and Docker validation | | [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup | | [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users | | [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control | | [Developer API Docs](https://epusdt.com) | Integration reference | +The repository also ships these top-level scripts: + +- [`./epctl`](./epctl) for Linux binary install, upgrade, config inspection, service status, logs, and initial password retrieval +- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) for real Ubuntu + systemd installation validation inside local Docker + --- ## Project Structure ```text Epusdt +├── epctl Linux binary installation and operations script ├── src/ Core project source code └── wiki/ Documentation assets and knowledge base ``` diff --git a/README.md b/README.md index 8845987..9ddb8eb 100644 --- a/README.md +++ b/README.md @@ -94,17 +94,24 @@ Epusdt 已完成第三方安全审计。 | 教程 | 说明 | |------|------| +| [仓库内:epctl 安装脚本](wiki/EPCTL.md) | Linux 二进制安装、升级、状态查看与 Docker 验收脚本 | | [Docker 部署](https://epusdt.com/guide/installation/docker) | 推荐方式,一键启动 | | [宝塔面板部署](https://epusdt.com/guide/installation/aapanel) | 适合宝塔用户 | | [手动部署](https://epusdt.com/guide/installation/manual.html) | 完全手动控制 | | [开发者 API 文档](https://epusdt.com/zh/guide/integration/gmpay.html) | 接口集成指南 | +仓库内还提供顶层脚本: + +- [`./epctl`](./epctl) 用于 Linux 二进制安装、升级、查看配置、状态和初始化密码 +- [`./epctl-docker-test.sh`](./epctl-docker-test.sh) 用于在本机 Docker 里跑 Ubuntu + systemd 的真实安装验收 + --- ## 项目结构 ```text Epusdt +├── epctl Linux 二进制安装与运维脚本 ├── src/ 项目核心代码 └── wiki/ 文档与知识库 ``` diff --git a/epctl b/epctl new file mode 100755 index 0000000..2d0c0bb --- /dev/null +++ b/epctl @@ -0,0 +1,1467 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly EPCTL_NAME="epctl" +readonly REPO_OWNER="GMWalletApp" +readonly REPO_NAME="epusdt" +readonly SERVICE_NAME="epusdt" +readonly SYSTEM_USER="epusdt" +readonly INSTALL_DIR="/opt/epusdt" +readonly BIN_PATH="${INSTALL_DIR}/epusdt" +readonly CONFIG_PATH="${INSTALL_DIR}/.env" +readonly CONFIG_EXAMPLE_PATH="${INSTALL_DIR}/.env.example" +readonly WWW_PATH="${INSTALL_DIR}/www" +readonly CACHE_ROOT="/tmp/epusdt" +readonly SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service" +readonly SELF_INSTALL_PATH="/usr/local/bin/${EPCTL_NAME}" +readonly DEFAULT_APP_URI="http://127.0.0.1:8000" +readonly DEFAULT_LISTEN="127.0.0.1:8000" +readonly SCRIPT_SOURCE_PATH="$(readlink -f "${BASH_SOURCE[0]}")" + +COLOR_RESET="" +COLOR_BOLD="" +COLOR_DIM="" +COLOR_CYAN="" +COLOR_GREEN="" +COLOR_YELLOW="" +COLOR_RED="" +COLOR_BLUE="" + +ACTIVE_LANG="" +POSITIONAL_ARGS=() + +detect_default_language() { + local raw normalized + + raw="${EPCTL_LANG:-}" + if [[ -z "${raw}" ]]; then + printf 'zh\n' + return + fi + + normalized="${raw,,}" + case "${normalized}" in + zh|zh-*|zh_*|cn|chs|cht|中文) + printf 'zh\n' + ;; + en|en-*|en_*|english) + printf 'en\n' + ;; + *) + printf '%s\n' "${raw}" + ;; + esac +} + +set_language() { + local requested normalized fallback_lang + + requested="${1:-$(detect_default_language)}" + normalized="${requested,,}" + fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}" + case "${normalized}" in + zh|zh-*|zh_*|cn|chs|cht|中文) + ACTIVE_LANG="zh" + ;; + en|en-*|en_*|english) + ACTIVE_LANG="en" + ;; + *) + ACTIVE_LANG="${fallback_lang}" + die "$(trf unsupported_language "${requested}")" + ;; + esac + + export EPCTL_LANG="${ACTIVE_LANG}" +} + +trf() { + local key="$1" + shift || true + local template + + case "${ACTIVE_LANG}:${key}" in + en:label_warn) template='warn' ;; + zh:label_warn) template='警告' ;; + en:label_ok) template='ok' ;; + zh:label_ok) template='完成' ;; + en:label_error) template='error' ;; + zh:label_error) template='错误' ;; + en:only_linux) template='this script only supports Linux' ;; + zh:only_linux) template='本脚本仅支持 Linux' ;; + en:missing_dependency) template='missing dependency: %s' ;; + zh:missing_dependency) template='缺少依赖:%s' ;; + en:root_required) template='this command must be run as root' ;; + zh:root_required) template='此命令必须以 root 身份执行' ;; + en:release_tag_required) template='release tag is required' ;; + zh:release_tag_required) template='必须提供 release tag' ;; + en:release_tag_invalid) template='--tag must match a release tag such as v1.0.6' ;; + zh:release_tag_invalid) template='--tag 必须是有效的 release tag,例如 v1.0.6' ;; + en:unsupported_arch) template='unsupported architecture: %s' ;; + zh:unsupported_arch) template='不支持的架构:%s' ;; + en:checksum_missing) template='checksum entry for %s not found in %s' ;; + zh:checksum_missing) template='在 %s 中找不到 %s 的校验项' ;; + en:latest_release_resolve_failed) template='failed to resolve the latest release tag from GitHub' ;; + zh:latest_release_resolve_failed) template='无法从 GitHub 解析最新 release tag' ;; + en:using_latest_without_confirmation) template='using latest release tag %s without confirmation because EPCTL_ASSUME_YES=1' ;; + zh:using_latest_without_confirmation) template='检测到 EPCTL_ASSUME_YES=1,直接使用最新 release tag %s,不再二次确认' ;; + en:latest_tag_no_tty) template='no --tag provided and no interactive terminal available; pass --tag explicitly or set EPCTL_ASSUME_YES=1' ;; + zh:latest_tag_no_tty) template='未提供 --tag,且当前不是交互终端;请显式传入 --tag,或设置 EPCTL_ASSUME_YES=1' ;; + en:latest_prompt) template='latest release tag is %s. Continue? [y/N] ' ;; + zh:latest_prompt) template='检测到最新 release tag 为 %s,是否继续?[y/N] ' ;; + en:cancelled) template='cancelled' ;; + zh:cancelled) template='已取消' ;; + en:cached_release_files) template='using cached release files in %s' ;; + zh:cached_release_files) template='使用缓存中的 release 文件:%s' ;; + en:downloading_archive) template='downloading %s' ;; + zh:downloading_archive) template='正在下载 %s' ;; + en:checksum_skipped) template='sha256sum not found; skipped checksum verification' ;; + zh:checksum_skipped) template='未检测到 sha256sum,已跳过校验' ;; + en:archive_missing_binary) template='release archive is missing the epusdt binary' ;; + zh:archive_missing_binary) template='release 压缩包中缺少 epusdt 二进制文件' ;; + en:archive_missing_env_example) template='release archive is missing .env.example' ;; + zh:archive_missing_env_example) template='release 压缩包中缺少 .env.example' ;; + en:keeping_existing_config) template='keeping existing config at %s' ;; + zh:keeping_existing_config) template='保留现有配置文件:%s' ;; + en:service_failed_active) template='service %s failed to become active' ;; + zh:service_failed_active) template='服务 %s 未能成功进入 active 状态' ;; + en:service_installed) template='installed %s to %s from %s' ;; + zh:service_installed) template='已将 %s 安装到 %s,来源版本:%s' ;; + en:self_install_current) template='%s already points to the current script' ;; + zh:self_install_current) template='%s 已经指向当前脚本,无需重复安装' ;; + en:self_install_done) template='installed %s into /usr/local/bin' ;; + zh:self_install_done) template='已将 %s 安装到 /usr/local/bin' ;; + en:config_not_found) template='config file not found: %s' ;; + zh:config_not_found) template='找不到配置文件:%s' ;; + en:http_listen_not_found) template='http_listen not found in %s' ;; + zh:http_listen_not_found) template='在 %s 中找不到 http_listen' ;; + en:http_listen_parse_failed) template='failed to parse the port from http_listen=%s' ;; + zh:http_listen_parse_failed) template='无法从 http_listen=%s 中解析端口' ;; + en:curl_partial_body) template='curl failed and returned a partial response body:' ;; + zh:curl_partial_body) template='curl 请求失败,并返回了部分响应体:' ;; + en:init_password_http_failed) template='init-password request failed with HTTP %s: %s' ;; + zh:init_password_http_failed) template='init-password 请求失败,HTTP %s:%s' ;; + en:response_body_label) template='response body:' ;; + zh:response_body_label) template='响应体:' ;; + en:response_body_empty) template='response body is empty' ;; + zh:response_body_empty) template='响应体为空' ;; + en:lines_positive_integer) template='--lines must be a positive integer' ;; + zh:lines_positive_integer) template='--lines 必须是正整数' ;; + en:state_not_installed) template='not installed' ;; + zh:state_not_installed) template='未安装' ;; + en:state_no_systemctl) template='unit exists, but systemctl is unavailable' ;; + zh:state_no_systemctl) template='unit 文件已存在,但 systemctl 不可用' ;; + en:state_no_systemd) template='unit exists, but systemd is unavailable' ;; + zh:state_no_systemd) template='unit 文件已存在,但 systemd 不可用' ;; + en:state_active) template='active' ;; + zh:state_active) template='运行中' ;; + en:state_inactive) template='installed but inactive' ;; + zh:state_inactive) template='已安装,但未运行' ;; + en:menu_title) template='EPCTL Console' ;; + zh:menu_title) template='EPCTL 控制台' ;; + en:menu_subtitle) template='Linux binary manager for epusdt' ;; + zh:menu_subtitle) template='面向 epusdt 的 Linux 二进制管理工具' ;; + en:label_service) template='service' ;; + zh:label_service) template='服务状态' ;; + en:label_install_dir) template='install dir' ;; + zh:label_install_dir) template='安装目录' ;; + en:label_config) template='config' ;; + zh:label_config) template='配置文件' ;; + en:menu_section_setup) template='Setup and release management' ;; + zh:menu_section_setup) template='部署与发布' ;; + en:menu_section_runtime) template='Runtime inspection' ;; + zh:menu_section_runtime) template='运行查看' ;; + en:menu_section_tools) template='Language and help' ;; + zh:menu_section_tools) template='语言与帮助' ;; + en:menu_badge_direct) template='direct' ;; + zh:menu_badge_direct) template='直达' ;; + en:menu_badge_confirm) template='confirm' ;; + zh:menu_badge_confirm) template='确认' ;; + en:menu_exit) template='Quit' ;; + zh:menu_exit) template='退出' ;; + en:menu_hint_direct) template='Direct items run immediately after selection.' ;; + zh:menu_hint_direct) template='带 [直达] 的项目会在选择后直接执行。' ;; + en:menu_hint_confirm) template='Confirm items show an extra confirmation step first.' ;; + zh:menu_hint_confirm) template='带 [确认] 的项目会先显示确认步骤。' ;; + en:command_completed) template='command completed' ;; + zh:command_completed) template='命令执行完成' ;; + en:command_failed_exit) template='command failed with exit code %s' ;; + zh:command_failed_exit) template='命令执行失败,退出码:%s' ;; + en:invalid_selection) template='invalid selection: %s' ;; + zh:invalid_selection) template='无效选择:%s' ;; + en:arg_requires_value) template='%s requires a value' ;; + zh:arg_requires_value) template='%s 需要一个参数值' ;; + en:unknown_arg) template='unknown argument for %s: %s' ;; + zh:unknown_arg) template='%s 命令不支持该参数:%s' ;; + en:unknown_command) template='unknown command: %s' ;; + zh:unknown_command) template='未知命令:%s' ;; + en:input_release_tag) template='Release tag (blank = latest)' ;; + zh:input_release_tag) template='Release tag(留空则使用 latest)' ;; + en:input_upgrade_tag) template='Upgrade tag (blank = latest)' ;; + zh:input_upgrade_tag) template='升级目标 tag(留空则使用 latest)' ;; + en:input_app_uri) template='app_uri' ;; + zh:input_app_uri) template='app_uri' ;; + en:input_http_listen) template='http_listen' ;; + zh:input_http_listen) template='http_listen' ;; + en:input_log_lines) template='Log lines' ;; + zh:input_log_lines) template='日志行数' ;; + en:press_enter_continue) template='Press Enter to return to the menu...' ;; + zh:press_enter_continue) template='按回车返回菜单...' ;; + en:menu_select_action) template='Select an action [0-10]: ' ;; + zh:menu_select_action) template='请选择操作 [0-10]:' ;; + en:menu_language_prompt) template='Choose a language / 请选择语言: 1) 中文 2) English' ;; + zh:menu_language_prompt) template='请选择语言 / Choose a language:1)中文 2)English' ;; + en:menu_language_input) template='Language selection(语言选择)' ;; + zh:menu_language_input) template='语言选择(Language selection)' ;; + en:menu_confirm_prompt) template='Continue? / 是否继续? [y/N] ' ;; + zh:menu_confirm_prompt) template='是否继续?/ Continue? [y/N] ' ;; + en:menu_cancelled_back) template='Cancelled and returned to the menu / 已取消,返回菜单' ;; + zh:menu_cancelled_back) template='已取消,返回菜单 / Cancelled and returned to the menu' ;; + en:language_switched) template='Language switched to %s / 语言已切换为 %s' ;; + zh:language_switched) template='语言已切换为 %s / Language switched to %s' ;; + en:language_name_zh) template='中文' ;; + zh:language_name_zh) template='中文' ;; + en:language_name_en) template='English' ;; + zh:language_name_en) template='English' ;; + en:unsupported_language) template='unsupported language: %s' ;; + zh:unsupported_language) template='不支持的语言:%s' ;; + en:current_language) template='current language' ;; + zh:current_language) template='当前语言' ;; + en:base_image_label) template='base image' ;; + zh:base_image_label) template='基础镜像' ;; + en:action_download_title) template='Download release package' ;; + zh:action_download_title) template='下载 release 包' ;; + en:action_download_desc) template='This will download the selected GitHub release archive into %s, and verify SHA256 when sha256sum is available. It will not install or start the service.' ;; + zh:action_download_desc) template='此操作会把选定的 GitHub release 压缩包下载到 %s,并在本机有 sha256sum 时校验 SHA256。它不会安装,也不会启动服务。' ;; + en:action_install_title) template='Install or refresh the service' ;; + zh:action_install_title) template='安装或刷新服务' ;; + en:action_install_desc) template='This will install epusdt into %s, create or reuse %s, write %s, and enable %s through systemd. Existing .env will be kept.' ;; + zh:action_install_desc) template='此操作会把 epusdt 安装到 %s,创建或复用 %s,写入 %s,并通过 systemd 启用 %s。若已有 .env,则会保留。' ;; + en:action_upgrade_title) template='Upgrade the service' ;; + zh:action_upgrade_title) template='升级服务' ;; + en:action_upgrade_desc) template='This will download and install a newer release into %s, then restart %s. The existing .env configuration will be preserved.' ;; + zh:action_upgrade_desc) template='此操作会下载并安装较新的 release 到 %s,然后重启 %s。现有 .env 配置会被保留。' ;; + en:action_self_install_title) template='Install epctl into PATH' ;; + zh:action_self_install_title) template='安装 epctl 到 PATH' ;; + en:action_self_install_desc) template='This will install %s into /usr/local/bin so it can be called from anywhere. Use %s to force Chinese or %s to force English.' ;; + zh:action_self_install_desc) template='此操作会把 %s 安装到 /usr/local/bin,便于在任意目录直接调用。之后可用 %s 强制中文,或用 %s 强制英文。' ;; + en:action_show_config_title) template='Show active config' ;; + zh:action_show_config_title) template='查看当前配置' ;; + en:action_show_config_desc) template='This will print the full active configuration file %s to the terminal. Sensitive values will be visible.' ;; + zh:action_show_config_desc) template='此操作会把当前生效的配置文件 %s 完整输出到终端,其中敏感配置也会直接显示。' ;; + en:action_init_password_title) template='Request initial admin password' ;; + zh:action_init_password_title) template='获取初始化密码' ;; + en:action_init_password_desc) template='This will call the local HTTP route /admin/api/v1/auth/init-password based on http_listen in %s. It does not read the database directly.' ;; + zh:action_init_password_desc) template='此操作会根据 %s 中的 http_listen,请求本地 HTTP 路由 /admin/api/v1/auth/init-password。它不会直接读取数据库。' ;; + en:action_status_title) template='Show service status' ;; + zh:action_status_title) template='查看服务状态' ;; + en:action_status_desc) template='This will run systemctl status for %s and print the current runtime state, recent logs, and unit summary.' ;; + zh:action_status_desc) template='此操作会对 %s 执行 systemctl status,并输出当前运行状态、最近日志和 unit 摘要。' ;; + en:action_logs_title) template='Show recent logs' ;; + zh:action_logs_title) template='查看最近日志' ;; + en:action_logs_desc) template='This will read recent journal logs for %s. You can choose how many lines to print next.' ;; + zh:action_logs_desc) template='此操作会读取 %s 的最近 journal 日志。下一步你可以选择要输出多少行。' ;; + en:action_language_title) template='Switch language(切换语言)' ;; + zh:action_language_title) template='Switch language(切换语言)' ;; + en:action_language_desc) template='This only changes the current interactive interface language. It does not change the service config or installation files. / 此操作只会切换当前交互界面的语言,不会修改服务配置,也不会改动安装文件。' ;; + zh:action_language_desc) template='此操作只会切换当前交互界面的语言,不会修改服务配置,也不会改动安装文件。 / This only changes the current interactive interface language. It does not change the service config or installation files.' ;; + en:action_help_title) template='Show help' ;; + zh:action_help_title) template='查看帮助' ;; + en:action_help_desc) template='This will print the full command help, including all non-interactive commands and global options.' ;; + zh:action_help_desc) template='此操作会输出完整命令帮助,包括所有非交互命令和全局参数。' ;; + *) + template="missing translation: ${key}" + ;; + esac + + printf -- "${template}" "$@" +} + +init_colors() { + if [[ -n "${NO_COLOR:-}" || "${EPCTL_NO_COLOR:-}" == "1" ]]; then + return + fi + + if [[ -t 1 || -t 2 ]]; then + COLOR_RESET=$'\033[0m' + COLOR_BOLD=$'\033[1m' + COLOR_DIM=$'\033[2m' + COLOR_CYAN=$'\033[36m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_RED=$'\033[31m' + COLOR_BLUE=$'\033[34m' + fi +} + +log() { + printf '%b[%s]%b %s\n' "${COLOR_CYAN}${COLOR_BOLD}" "${EPCTL_NAME}" "${COLOR_RESET}" "$*" >&2 +} + +warn() { + printf '%b[%s] %s:%b %s\n' "${COLOR_YELLOW}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_warn)" "${COLOR_RESET}" "$*" >&2 +} + +success() { + printf '%b[%s] %s:%b %s\n' "${COLOR_GREEN}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_ok)" "${COLOR_RESET}" "$*" >&2 +} + +die() { + printf '%b[%s] %s:%b %s\n' "${COLOR_RED}${COLOR_BOLD}" "${EPCTL_NAME}" "$(trf label_error)" "${COLOR_RESET}" "$*" >&2 + exit 1 +} + +usage() { + if [[ "${ACTIVE_LANG}" == "zh" ]]; then + cat <<'EOF' +用法: + epctl [--lang zh|en] [--no-color] + epctl zh + epctl en + epctl [--lang zh|en] menu + epctl [--lang zh|en] download [--tag ] + epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT] + epctl [--lang zh|en] upgrade [--tag ] + epctl [--lang zh|en] self-install + epctl [--lang zh|en] show-config + epctl [--lang zh|en] init-password + epctl [--lang zh|en] status + epctl [--lang zh|en] logs [--lines N] + +全局选项: + --lang zh|en 切换界面语言 + --no-color 禁用彩色输出 + 快捷方式 `epctl zh` 直接进入中文;`epctl en` 直接进入英文 + +命令说明: + menu 进入交互式菜单;直接执行 `epctl` 也会进入 + download 下载 GitHub Release 包到 /tmp/epusdt// + install 安装或刷新 /opt/epusdt,并注册 epusdt.service + upgrade 升级到新的 release,保留现有 .env + self-install 安装 epctl 到 /usr/local/bin + show-config 输出当前生效的 /opt/epusdt/.env + init-password 通过本地 HTTP 路由请求初始化管理员密码 + status 显示 epusdt 的 systemd 状态 + logs 显示最近的服务日志 + +说明: + - 仅支持 Linux + - 仅支持二进制安装 + - install / upgrade / self-install 会写入 /opt、/etc/systemd、/usr/local/bin + - 需要提权的命令会自动通过 sudo 重新执行 + - tag 需要是真实的 GitHub release tag,例如 v1.0.6 + - 未提供 --tag 时,会自动解析 latest,并要求二次确认 +EOF + else + cat <<'EOF' +Usage: + epctl [--lang zh|en] [--no-color] + epctl zh + epctl en + epctl [--lang zh|en] menu + epctl [--lang zh|en] download [--tag ] + epctl [--lang zh|en] install [--tag ] [--app-uri URL] [--listen ADDR:PORT] + epctl [--lang zh|en] upgrade [--tag ] + epctl [--lang zh|en] self-install + epctl [--lang zh|en] show-config + epctl [--lang zh|en] init-password + epctl [--lang zh|en] status + epctl [--lang zh|en] logs [--lines N] + +Global options: + --lang zh|en Select the interface language + --no-color Disable colored output + Shortcut `epctl zh` enters Chinese directly; `epctl en` enters English directly + +Command summary: + menu Open the interactive menu; `epctl` with no arguments does this too + download Download a GitHub release archive into /tmp/epusdt// + install Install or refresh /opt/epusdt and register epusdt.service + upgrade Upgrade to a newer release while keeping the existing .env + self-install Install epctl into /usr/local/bin + show-config Print the active /opt/epusdt/.env file + init-password Request the initial admin password from the local HTTP route + status Show the systemd status of epusdt + logs Show recent service logs + +Notes: + - Linux only + - Binary installation only + - install / upgrade / self-install write into /opt, /etc/systemd, and /usr/local/bin + - privileged commands automatically re-exec through sudo + - tags must match real GitHub release tags such as v1.0.6 + - if --tag is omitted, epctl resolves the latest release and asks for confirmation +EOF + fi +} + +parse_global_options() { + POSITIONAL_ARGS=() + while [[ $# -gt 0 ]]; do + case "$1" in + --lang) + [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")" + set_language "$2" + shift 2 + ;; + --lang=*) + set_language "${1#*=}" + shift + ;; + --no-color) + export EPCTL_NO_COLOR=1 + shift + ;; + *) + POSITIONAL_ARGS+=("$1") + shift + ;; + esac + done +} + +require_linux() { + [[ "$(uname -s)" == "Linux" ]] || die "$(trf only_linux)" +} + +have_command() { + command -v "$1" >/dev/null 2>&1 +} + +require_command() { + have_command "$1" || die "$(trf missing_dependency "$1")" +} + +is_interactive_terminal() { + [[ -t 0 && -t 1 ]] +} + +require_root() { + [[ "${EUID}" -eq 0 ]] || die "$(trf root_required)" +} + +ensure_root_via_sudo() { + if [[ "${EUID}" -eq 0 ]]; then + return + fi + + require_command sudo + exec sudo -- "${SCRIPT_SOURCE_PATH}" --lang "${ACTIVE_LANG}" "$@" +} + +require_release_tag() { + local tag="$1" + [[ -n "${tag}" ]] || die "$(trf release_tag_required)" + [[ "${tag}" == v* ]] || die "$(trf release_tag_invalid)" +} + +map_arch() { + case "$(uname -m)" in + x86_64|amd64) + printf 'amd64\n' + ;; + aarch64|arm64) + printf 'arm64\n' + ;; + *) + die "$(trf unsupported_arch "$(uname -m)")" + ;; + esac +} + +asset_version_from_tag() { + local tag="$1" + printf '%s\n' "${tag#v}" +} + +cache_dir_for_tag() { + local tag="$1" + printf '%s/%s\n' "${CACHE_ROOT}" "${tag}" +} + +archive_name_for_tag() { + local tag="$1" + local arch="$2" + printf 'epusdt-%s-linux-%s.tar.gz\n' "$(asset_version_from_tag "${tag}")" "${arch}" +} + +release_base_url_for_tag() { + local tag="$1" + printf 'https://github.com/%s/%s/releases/download/%s\n' "${REPO_OWNER}" "${REPO_NAME}" "${tag}" +} + +download_file() { + local url="$1" + local destination="$2" + local partial="${destination}.part" + rm -f "${partial}" + curl --fail --location --silent --show-error --output "${partial}" "${url}" + mv "${partial}" "${destination}" +} + +verify_checksum() { + local sums_path="$1" + local archive_path="$2" + local archive_name + local checksum_line + + archive_name="$(basename "${archive_path}")" + checksum_line="$(grep -F " ${archive_name}" "${sums_path}" || true)" + [[ -n "${checksum_line}" ]] || die "$(trf checksum_missing "${archive_name}" "$(basename "${sums_path}")")" + + ( + cd "$(dirname "${archive_path}")" + printf '%s\n' "${checksum_line}" | sha256sum -c - >&2 + ) +} + +ensure_download_dependencies() { + require_command curl + require_command grep + require_command sed +} + +latest_release_tag() { + local api_url="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" + local response tag + + ensure_download_dependencies + response="$(curl --fail --location --silent --show-error "${api_url}")" + tag="$(printf '%s\n' "${response}" | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]\+\)".*$/\1/p' | head -n 1)" + [[ -n "${tag}" ]] || die "$(trf latest_release_resolve_failed)" + require_release_tag "${tag}" + printf '%s\n' "${tag}" +} + +is_affirmative_reply() { + local reply="${1:-}" + reply="${reply,,}" + + case "${reply}" in + y|yes|ok|true|1|s|shi|是|确认|確認) + return 0 + ;; + *) + return 1 + ;; + esac +} + +confirm_latest_release_tag() { + local tag="$1" + local reply + + if [[ "${EPCTL_ASSUME_YES:-}" == "1" ]]; then + log "$(trf using_latest_without_confirmation "${tag}")" + printf '%s\n' "${tag}" + return + fi + + if [[ ! -t 0 ]]; then + die "$(trf latest_tag_no_tty)" + fi + + printf '%b[%s]%b %s' "${COLOR_CYAN}${COLOR_BOLD}" "${EPCTL_NAME}" "${COLOR_RESET}" "$(trf latest_prompt "${tag}")" >&2 + read -r reply + if is_affirmative_reply "${reply}"; then + printf '%s\n' "${tag}" + return + fi + + die "$(trf cancelled)" +} + +resolve_effective_tag() { + local requested_tag="$1" + local latest_tag + + if [[ -n "${requested_tag}" ]]; then + require_release_tag "${requested_tag}" + printf '%s\n' "${requested_tag}" + return + fi + + latest_tag="$(latest_release_tag)" + confirm_latest_release_tag "${latest_tag}" +} + +download_release() { + local tag="$1" + local arch archive_name release_base_url cache_dir archive_path sums_path + + require_linux + ensure_download_dependencies + + arch="$(map_arch)" + archive_name="$(archive_name_for_tag "${tag}" "${arch}")" + release_base_url="$(release_base_url_for_tag "${tag}")" + cache_dir="$(cache_dir_for_tag "${tag}")" + archive_path="${cache_dir}/${archive_name}" + sums_path="${cache_dir}/SHA256SUMS" + + mkdir -p "${cache_dir}" + + if [[ -f "${archive_path}" && -f "${sums_path}" ]]; then + log "$(trf cached_release_files "${cache_dir}")" + else + log "$(trf downloading_archive "${archive_name}")" + download_file "${release_base_url}/${archive_name}" "${archive_path}" + download_file "${release_base_url}/SHA256SUMS" "${sums_path}" + fi + + if have_command sha256sum; then + verify_checksum "${sums_path}" "${archive_path}" + else + warn "$(trf checksum_skipped)" + fi + + printf '%s\n' "${archive_path}" +} + +escape_sed_replacement() { + printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g' +} + +set_env_key() { + local file="$1" + local key="$2" + local value="$3" + local escaped + + escaped="$(escape_sed_replacement "${value}")" + if grep -q "^${key}=" "${file}"; then + sed -i "s|^${key}=.*$|${key}=${escaped}|" "${file}" + else + printf '%s=%s\n' "${key}" "${value}" >> "${file}" + fi +} + +ensure_system_user() { + if ! getent group "${SYSTEM_USER}" >/dev/null 2>&1; then + groupadd --system "${SYSTEM_USER}" + fi + if ! id -u "${SYSTEM_USER}" >/dev/null 2>&1; then + useradd --system --gid "${SYSTEM_USER}" --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "${SYSTEM_USER}" + fi +} + +extract_release_archive() { + local tag="$1" + local archive_path="$2" + local extract_dir + + extract_dir="$(cache_dir_for_tag "${tag}")/extract" + rm -rf "${extract_dir}" + mkdir -p "${extract_dir}" + tar -xzf "${archive_path}" -C "${extract_dir}" + [[ -f "${extract_dir}/epusdt" ]] || die "$(trf archive_missing_binary)" + [[ -f "${extract_dir}/.env.example" ]] || die "$(trf archive_missing_env_example)" + printf '%s\n' "${extract_dir}" +} + +create_default_config_if_missing() { + local app_uri="$1" + local listen="$2" + + if [[ -f "${CONFIG_PATH}" ]]; then + log "$(trf keeping_existing_config "${CONFIG_PATH}")" + return + fi + + cp "${CONFIG_EXAMPLE_PATH}" "${CONFIG_PATH}" + set_env_key "${CONFIG_PATH}" "install" "false" + set_env_key "${CONFIG_PATH}" "app_uri" "${app_uri}" + set_env_key "${CONFIG_PATH}" "http_listen" "${listen}" + chmod 0644 "${CONFIG_PATH}" +} + +write_systemd_unit() { + cat > "${SYSTEMD_UNIT_PATH}" <&2 + printf '\n' >&2 + fi + rm -f "${body_file}" + return "${curl_rc}" + fi + + if [[ "${http_code}" =~ ^2[0-9][0-9]$ ]]; then + cat "${body_file}" + rm -f "${body_file}" + return 0 + fi + + warn "$(trf init_password_http_failed "${http_code}" "${url}")" + if [[ -s "${body_file}" ]]; then + printf '%b%s%b\n' "${COLOR_YELLOW}${COLOR_BOLD}" "$(trf response_body_label)" "${COLOR_RESET}" >&2 + cat "${body_file}" >&2 + printf '\n' >&2 + else + warn "$(trf response_body_empty)" + fi + rm -f "${body_file}" + return 1 +} + +command_status() { + local original_args=("$@") + + require_linux + while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + *) + die "$(trf unknown_arg "status" "$1")" + ;; + esac + done + + ensure_root_via_sudo status "${original_args[@]}" + require_root + require_command systemctl + systemctl status "${SERVICE_NAME}" --no-pager +} + +command_logs() { + local lines="100" + local original_args=("$@") + + while [[ $# -gt 0 ]]; do + case "$1" in + --lines) + [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lines")" + lines="$2" + shift 2 + ;; + --lines=*) + lines="${1#*=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "$(trf unknown_arg "logs" "$1")" + ;; + esac + done + + [[ "${lines}" =~ ^[1-9][0-9]*$ ]] || die "$(trf lines_positive_integer)" + require_linux + ensure_root_via_sudo logs "${original_args[@]}" + require_root + require_command journalctl + journalctl -u "${SERVICE_NAME}" --no-pager -q -n "${lines}" +} + +prompt_input() { + local label="$1" + local default_value="${2:-}" + local input + + if [[ -n "${default_value}" ]]; then + printf '%b%s%b [%s]: ' "${COLOR_BOLD}" "${label}" "${COLOR_RESET}" "${default_value}" >&2 + else + printf '%b%s%b: ' "${COLOR_BOLD}" "${label}" "${COLOR_RESET}" >&2 + fi + + read -r input || return 1 + if [[ -z "${input}" ]]; then + printf '%s\n' "${default_value}" + else + printf '%s\n' "${input}" + fi +} + +pause_for_menu() { + printf '\n' + printf '%b%s%b' "${COLOR_DIM}" "$(trf press_enter_continue)" "${COLOR_RESET}" + read -r _ || true +} + +print_rule() { + local char="${1:--}" + local width="${2:-64}" + local line + + printf -v line '%*s' "${width}" '' + line="${line// /${char}}" + printf '%b%s%b\n' "${COLOR_DIM}" "${line}" "${COLOR_RESET}" +} + +format_badge() { + local text="$1" + local color="${2:-${COLOR_BLUE}}" + printf '%b[%s]%b' "${color}${COLOR_BOLD}" "${text}" "${COLOR_RESET}" +} + +format_service_state_badge() { + local state="$1" + local color="${COLOR_RED}" + + case "${state}" in + "$(trf state_active)") + color="${COLOR_GREEN}" + ;; + "$(trf state_inactive)") + color="${COLOR_YELLOW}" + ;; + esac + + format_badge "${state}" "${color}" +} + +format_menu_item_badge() { + local kind="$1" + local label color + + case "${kind}" in + direct) + label="$(trf menu_badge_direct)" + color="${COLOR_GREEN}" + ;; + confirm) + label="$(trf menu_badge_confirm)" + color="${COLOR_YELLOW}" + ;; + *) + return 0 + ;; + esac + + format_badge "${label}" "${color}" +} + +print_menu_meta_line() { + local label="$1" + local value="$2" + printf ' %b%s%b %s\n' "${COLOR_DIM}" "${label}:" "${COLOR_RESET}" "${value}" +} + +print_menu_section_title() { + printf '\n%b%s%b\n' "${COLOR_BLUE}${COLOR_BOLD}" "$1" "${COLOR_RESET}" +} + +print_menu_item() { + local index="$1" + local title="$2" + local mode="${3:-}" + + printf ' %b%2s%b. %s' "${COLOR_CYAN}${COLOR_BOLD}" "${index}" "${COLOR_RESET}" "${title}" + if [[ -n "${mode}" ]]; then + printf ' %s' "$(format_menu_item_badge "${mode}")" + fi + printf '\n' +} + +print_menu_action_preview() { + local title="$1" + local description="$2" + + printf '\n' >&2 + print_rule "-" 64 >&2 + printf '%b> %s%b\n' "${COLOR_BLUE}${COLOR_BOLD}" "${title}" "${COLOR_RESET}" >&2 + printf '%s\n' "${description}" >&2 + print_rule "-" 64 >&2 +} + +confirm_menu_action() { + local title="$1" + local description="$2" + local reply + + print_menu_action_preview "${title}" "${description}" + printf '%b%s%b' "${COLOR_BOLD}" "$(trf menu_confirm_prompt)" "${COLOR_RESET}" >&2 + read -r reply || return 1 + if is_affirmative_reply "${reply}"; then + return 0 + fi + + warn "$(trf menu_cancelled_back)" + pause_for_menu + return 1 +} + +service_state_summary() { + if [[ ! -f "${SYSTEMD_UNIT_PATH}" ]]; then + printf '%s\n' "$(trf state_not_installed)" + return + fi + + if ! have_command systemctl; then + printf '%s\n' "$(trf state_no_systemctl)" + return + fi + + if [[ ! -d /run/systemd/system ]]; then + printf '%s\n' "$(trf state_no_systemd)" + return + fi + + if systemctl is-active --quiet "${SERVICE_NAME}" >/dev/null 2>&1; then + printf '%s\n' "$(trf state_active)" + return + fi + + printf '%s\n' "$(trf state_inactive)" +} + +print_menu_header() { + local current_lang_label service_state + + if [[ "${ACTIVE_LANG}" == "zh" ]]; then + current_lang_label="$(trf language_name_zh)" + else + current_lang_label="$(trf language_name_en)" + fi + + service_state="$(service_state_summary)" + + printf '\n' + print_rule "=" 64 + printf '%b%s%b\n' "${COLOR_CYAN}${COLOR_BOLD}" "$(trf menu_title)" "${COLOR_RESET}" + printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_subtitle)" "${COLOR_RESET}" + print_rule "-" 64 + print_menu_meta_line "$(trf label_service)" "$(format_service_state_badge "${service_state}")" + print_menu_meta_line "$(trf label_install_dir)" "${INSTALL_DIR}" + print_menu_meta_line "$(trf label_config)" "${CONFIG_PATH}" + print_menu_meta_line "$(trf current_language)" "$(format_badge "${current_lang_label}" "${COLOR_CYAN}")" + print_rule "-" 64 +} + +print_menu_options() { + print_menu_section_title "$(trf menu_section_setup)" + print_menu_item "1" "$(trf action_download_title)" "confirm" + print_menu_item "2" "$(trf action_install_title)" "confirm" + print_menu_item "3" "$(trf action_upgrade_title)" "confirm" + print_menu_item "4" "$(trf action_self_install_title)" "confirm" + + print_menu_section_title "$(trf menu_section_runtime)" + print_menu_item "5" "$(trf action_show_config_title)" "direct" + print_menu_item "6" "$(trf action_init_password_title)" "confirm" + print_menu_item "7" "$(trf action_status_title)" "direct" + print_menu_item "8" "$(trf action_logs_title)" "direct" + + print_menu_section_title "$(trf menu_section_tools)" + print_menu_item "9" "$(trf action_language_title)" "direct" + print_menu_item "10" "$(trf action_help_title)" "direct" + print_menu_item "0" "$(trf menu_exit)" + + print_rule "-" 64 + printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_hint_direct)" "${COLOR_RESET}" + printf '%b%s%b\n' "${COLOR_DIM}" "$(trf menu_hint_confirm)" "${COLOR_RESET}" +} + +run_menu_command() { + local rc + + printf '\n' + if EPCTL_LANG="${ACTIVE_LANG}" "${SCRIPT_SOURCE_PATH}" --lang "${ACTIVE_LANG}" "$@"; then + success "$(trf command_completed)" + else + rc="$?" + warn "$(trf command_failed_exit "${rc}")" + fi + + pause_for_menu +} + +interactive_download() { + local tag + + confirm_menu_action \ + "$(trf action_download_title)" \ + "$(trf action_download_desc "${CACHE_ROOT}")" || return 0 + + tag="$(prompt_input "$(trf input_release_tag)")" || return 0 + if [[ -n "${tag}" ]]; then + run_menu_command download --tag "${tag}" + else + run_menu_command download + fi +} + +interactive_install() { + local tag app_uri listen + + confirm_menu_action \ + "$(trf action_install_title)" \ + "$(trf action_install_desc "${INSTALL_DIR}" "${SYSTEM_USER}" "${SYSTEMD_UNIT_PATH}" "${SERVICE_NAME}")" || return 0 + + tag="$(prompt_input "$(trf input_release_tag)")" || return 0 + app_uri="$(prompt_input "$(trf input_app_uri)" "${DEFAULT_APP_URI}")" || return 0 + listen="$(prompt_input "$(trf input_http_listen)" "${DEFAULT_LISTEN}")" || return 0 + + if [[ -n "${tag}" ]]; then + run_menu_command install --tag "${tag}" --app-uri "${app_uri}" --listen "${listen}" + else + run_menu_command install --app-uri "${app_uri}" --listen "${listen}" + fi +} + +interactive_upgrade() { + local tag + + confirm_menu_action \ + "$(trf action_upgrade_title)" \ + "$(trf action_upgrade_desc "${INSTALL_DIR}" "${SERVICE_NAME}")" || return 0 + + tag="$(prompt_input "$(trf input_upgrade_tag)")" || return 0 + if [[ -n "${tag}" ]]; then + run_menu_command upgrade --tag "${tag}" + else + run_menu_command upgrade + fi +} + +interactive_logs() { + local lines + + print_menu_action_preview \ + "$(trf action_logs_title)" \ + "$(trf action_logs_desc "${SERVICE_NAME}")" + + lines="$(prompt_input "$(trf input_log_lines)" "100")" || return 0 + run_menu_command logs --lines "${lines}" +} + +interactive_switch_language() { + local choice + + print_menu_action_preview \ + "$(trf action_language_title)" \ + "$(trf action_language_desc)" + + printf '\n%b%s%b\n' "${COLOR_BOLD}" "$(trf menu_language_prompt)" "${COLOR_RESET}" + choice="$(prompt_input "$(trf menu_language_input)")" || return 0 + case "${choice}" in + 1|zh|ZH|cn|CN|中文) + set_language "zh" + success "$(trf language_switched "$(trf language_name_zh)" "$(trf language_name_zh)")" + ;; + 2|en|EN|english|English|英文|英语) + set_language "en" + success "$(trf language_switched "$(trf language_name_en)" "$(trf language_name_en)")" + ;; + *) + warn "$(trf invalid_selection "${choice}")" + ;; + esac + pause_for_menu +} + +interactive_menu() { + local choice + + require_linux + while true; do + print_menu_header + print_menu_options + printf '%b%s%b' "${COLOR_BOLD}" "$(trf menu_select_action)" "${COLOR_RESET}" + read -r choice || return 0 + + case "${choice}" in + 1) + interactive_download + ;; + 2) + interactive_install + ;; + 3) + interactive_upgrade + ;; + 4) + if confirm_menu_action \ + "$(trf action_self_install_title)" \ + "$(trf action_self_install_desc "${EPCTL_NAME}" "${EPCTL_NAME} --lang zh" "${EPCTL_NAME} --lang en")"; then + run_menu_command self-install + fi + ;; + 5) + print_menu_action_preview \ + "$(trf action_show_config_title)" \ + "$(trf action_show_config_desc "${CONFIG_PATH}")" + run_menu_command show-config + ;; + 6) + if confirm_menu_action \ + "$(trf action_init_password_title)" \ + "$(trf action_init_password_desc "${CONFIG_PATH}")"; then + run_menu_command init-password + fi + ;; + 7) + print_menu_action_preview \ + "$(trf action_status_title)" \ + "$(trf action_status_desc "${SERVICE_NAME}")" + run_menu_command status + ;; + 8) + interactive_logs + ;; + 9) + interactive_switch_language + ;; + 10) + print_menu_action_preview \ + "$(trf action_help_title)" \ + "$(trf action_help_desc)" + usage + pause_for_menu + ;; + 0|q|Q|quit|QUIT|exit|EXIT) + return 0 + ;; + *) + warn "$(trf invalid_selection "${choice}")" + pause_for_menu + ;; + esac + done +} + +main() { + local command="${1:-}" + shift || true + + case "${command}" in + zh|zh-cn|zh_cn|cn|中文) + set_language "zh" + if [[ $# -gt 0 ]]; then + main "$@" + elif is_interactive_terminal; then + interactive_menu + else + usage + fi + ;; + en|en-us|en_us|english) + set_language "en" + if [[ $# -gt 0 ]]; then + main "$@" + elif is_interactive_terminal; then + interactive_menu + else + usage + fi + ;; + menu|interactive) + interactive_menu + ;; + download) + command_download "$@" + ;; + install) + command_install "$@" + ;; + upgrade) + command_upgrade "$@" + ;; + self-install) + command_self_install "$@" + ;; + show-config) + command_show_config "$@" + ;; + init-password) + command_init_password "$@" + ;; + status) + command_status "$@" + ;; + logs) + command_logs "$@" + ;; + -h|--help|help) + usage + ;; + "") + if is_interactive_terminal; then + interactive_menu + else + usage + exit 1 + fi + ;; + *) + die "$(trf unknown_command "${command}")" + ;; + esac +} + +set_language "" +parse_global_options "$@" +init_colors +main "${POSITIONAL_ARGS[@]}" diff --git a/epctl-docker-test.sh b/epctl-docker-test.sh new file mode 100755 index 0000000..2a663fb --- /dev/null +++ b/epctl-docker-test.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly IMAGE_TAG="epctl-ubuntu-systemd-test:$(date +%s)-$$" +readonly CONTAINER_NAME="epctl-systemd-test-$$" +readonly BASE_IMAGE="ubuntu:24.04" + +ACTIVE_LANG="" +POSITIONAL_ARGS=() + +detect_default_language() { + local raw normalized + + raw="${EPCTL_LANG:-}" + if [[ -z "${raw}" ]]; then + printf 'zh\n' + return + fi + + normalized="${raw,,}" + case "${normalized}" in + zh|zh-*|zh_*|cn|chs|cht|中文) + printf 'zh\n' + ;; + en|en-*|en_*|english) + printf 'en\n' + ;; + *) + printf '%s\n' "${raw}" + ;; + esac +} + +set_language() { + local requested normalized fallback_lang + + requested="${1:-$(detect_default_language)}" + normalized="${requested,,}" + fallback_lang="${ACTIVE_LANG:-$(detect_default_language)}" + case "${normalized}" in + zh|zh-*|zh_*|cn|chs|cht|中文) + ACTIVE_LANG="zh" + ;; + en|en-*|en_*|english) + ACTIVE_LANG="en" + ;; + *) + ACTIVE_LANG="${fallback_lang}" + die "$(trf unsupported_language "${requested}")" + ;; + esac + + export EPCTL_LANG="${ACTIVE_LANG}" +} + +trf() { + local key="$1" + shift || true + local template + + case "${ACTIVE_LANG}:${key}" in + en:usage) template='Usage:' ;; + zh:usage) template='用法:' ;; + en:example) template='Example:' ;; + zh:example) template='示例:' ;; + en:missing_dependency) template='missing dependency: %s' ;; + zh:missing_dependency) template='缺少依赖:%s' ;; + en:arg_requires_value) template='%s requires a value' ;; + zh:arg_requires_value) template='%s 需要一个参数值' ;; + en:unsupported_language) template='unsupported language: %s' ;; + zh:unsupported_language) template='不支持的语言:%s' ;; + en:unsupported_arch) template='unsupported architecture: %s' ;; + zh:unsupported_arch) template='不支持的架构:%s' ;; + en:test_failed_collecting) template='test failed; collecting container diagnostics' ;; + zh:test_failed_collecting) template='测试失败,正在收集容器诊断信息' ;; + en:install_tag_invalid) template='install tag must be a real GitHub release tag such as v1.0.6' ;; + zh:install_tag_invalid) template='安装 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;; + en:upgrade_tag_invalid) template='upgrade tag must be a real GitHub release tag such as v1.0.6' ;; + zh:upgrade_tag_invalid) template='升级 tag 必须是真实的 GitHub release tag,例如 v1.0.6' ;; + en:base_image) template='base image: %s' ;; + zh:base_image) template='基础镜像:%s' ;; + en:building_image) template='building the Ubuntu systemd test image' ;; + zh:building_image) template='正在构建 Ubuntu systemd 测试镜像' ;; + en:starting_container) template='starting the test container' ;; + zh:starting_container) template='正在启动测试容器' ;; + en:systemd_not_ready) template='systemd did not become ready inside the container' ;; + zh:systemd_not_ready) template='容器内的 systemd 未能及时就绪' ;; + en:self_installing) template='installing epctl into PATH' ;; + zh:self_installing) template='正在将 epctl 安装到 PATH' ;; + en:downloading_release) template='downloading release %s' ;; + zh:downloading_release) template='正在下载 release %s' ;; + en:installing_release) template='installing epusdt from release %s' ;; + zh:installing_release) template='正在从 release %s 安装 epusdt' ;; + en:waiting_assets) template='waiting for epusdt to start and release www assets' ;; + zh:waiting_assets) template='正在等待 epusdt 启动并释放 www 静态文件' ;; + en:upgrading_release) template='upgrading epusdt from %s to %s' ;; + zh:upgrading_release) template='正在将 epusdt 从 %s 升级到 %s' ;; + en:checking_init_password) template='checking init-password behavior' ;; + zh:checking_init_password) template='正在检查 init-password 行为' ;; + en:expected_second_failure) template='expected init-password to fail after the password was changed' ;; + zh:expected_second_failure) template='密码修改后,init-password 本应失败,但它却成功了' ;; + en:verification_succeeded) template='docker verification succeeded for install=%s%s' ;; + zh:verification_succeeded) template='Docker 验证通过,install=%s%s' ;; + *) + template="missing translation: ${key}" + ;; + esac + + printf -- "${template}" "$@" +} + +log() { + printf '[epctl-docker-test] %s\n' "$*" >&2 +} + +die() { + printf '[epctl-docker-test] error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + if [[ "${ACTIVE_LANG}" == "zh" ]]; then + cat <<'EOF' +用法: + ./epctl-docker-test.sh [--lang zh|en] [upgrade-tag] + +示例: + ./epctl-docker-test.sh v1.0.6 + ./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8 +EOF + else + cat <<'EOF' +Usage: + ./epctl-docker-test.sh [--lang zh|en] [upgrade-tag] + +Example: + ./epctl-docker-test.sh v1.0.6 + ./epctl-docker-test.sh --lang en v1.0.6 v1.0.8 +EOF + fi +} + +parse_global_options() { + POSITIONAL_ARGS=() + while [[ $# -gt 0 ]]; do + case "$1" in + --lang) + [[ $# -ge 2 ]] || die "$(trf arg_requires_value "--lang")" + set_language "$2" + shift 2 + ;; + --lang=*) + set_language "${1#*=}" + shift + ;; + *) + POSITIONAL_ARGS+=("$1") + shift + ;; + esac + done +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "$(trf missing_dependency "$1")" +} + +map_arch() { + case "$(uname -m)" in + x86_64|amd64) + printf 'amd64\n' + ;; + aarch64|arm64) + printf 'arm64\n' + ;; + *) + die "$(trf unsupported_arch "$(uname -m)")" + ;; + esac +} + +docker_bash() { + docker exec "${CONTAINER_NAME}" bash -lc "$1" +} + +cleanup() { + local exit_code="$?" + if [[ "${exit_code}" -ne 0 ]]; then + log "$(trf test_failed_collecting)" + docker exec "${CONTAINER_NAME}" bash -lc 'systemctl status epusdt --no-pager || true' || true + docker exec "${CONTAINER_NAME}" bash -lc 'journalctl -u epusdt --no-pager -n 100 || true' || true + fi + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + docker rmi -f "${IMAGE_TAG}" >/dev/null 2>&1 || true + rm -rf "${TMPDIR_PATH:-}" +} + +wait_for_systemd() { + local attempt + for attempt in $(seq 1 30); do + if docker exec "${CONTAINER_NAME}" bash -lc 'systemctl show --property=Version --value >/dev/null 2>&1'; then + return 0 + fi + sleep 1 + done + return 1 +} + +set_language "" +parse_global_options "$@" + +INSTALL_TAG="${POSITIONAL_ARGS[0]:-}" +UPGRADE_TAG="${POSITIONAL_ARGS[1]:-}" +[[ -n "${INSTALL_TAG}" ]] || { + usage + exit 1 +} +[[ "${INSTALL_TAG}" == v* ]] || die "$(trf install_tag_invalid)" +if [[ -n "${UPGRADE_TAG}" && "${UPGRADE_TAG}" != v* ]]; then + die "$(trf upgrade_tag_invalid)" +fi + +require_command docker +require_command curl + +ASSET_ARCH="$(map_arch)" +ASSET_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/epusdt-${INSTALL_TAG#v}-linux-${ASSET_ARCH}.tar.gz" +SUMS_URL="https://github.com/GMWalletApp/epusdt/releases/download/${INSTALL_TAG}/SHA256SUMS" + +curl --fail --silent --show-error --head "${ASSET_URL}" >/dev/null +curl --fail --silent --show-error --head "${SUMS_URL}" >/dev/null +if [[ -n "${UPGRADE_TAG}" ]]; then + curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/epusdt-${UPGRADE_TAG#v}-linux-${ASSET_ARCH}.tar.gz" >/dev/null + curl --fail --silent --show-error --head "https://github.com/GMWalletApp/epusdt/releases/download/${UPGRADE_TAG}/SHA256SUMS" >/dev/null +fi + +TMPDIR_PATH="$(mktemp -d)" +trap cleanup EXIT + +cat > "${TMPDIR_PATH}/Dockerfile" < /etc/sudoers.d/tester \ + && chmod 0440 /etc/sudoers.d/tester + +STOPSIGNAL SIGRTMIN+3 + +CMD ["/sbin/init"] +EOF + +log "$(trf base_image "${BASE_IMAGE}")" +log "$(trf building_image)" +docker build -t "${IMAGE_TAG}" "${TMPDIR_PATH}" >/dev/null + +log "$(trf starting_container)" +docker run -d \ + --name "${CONTAINER_NAME}" \ + --privileged \ + --cgroupns=host \ + -e container=docker \ + --tmpfs /run \ + --tmpfs /run/lock \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v "${SCRIPT_DIR}:/work" \ + -w /work \ + "${IMAGE_TAG}" \ + /sbin/init >/dev/null + +wait_for_systemd || die "$(trf systemd_not_ready)" + +log "$(trf self_installing)" +docker exec "${CONTAINER_NAME}" bash -lc "su - tester -c 'cd /work && ./epctl self-install'" +docker_bash '[[ -x /usr/local/bin/epctl ]]' +docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl help | grep -q \"^用法:\""' +docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh help | grep -q \"^用法:\""' +docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang en help | grep -q \"^Usage:\""' + +log "$(trf downloading_release "${INSTALL_TAG}")" +docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl download --tag ${TAG}"' + +log "$(trf installing_release "${INSTALL_TAG}")" +docker exec "${CONTAINER_NAME}" env TAG="${INSTALL_TAG}" bash -lc 'su - tester -c "epctl install --tag ${TAG} --app-uri http://127.0.0.1:8000"' + +log "$(trf waiting_assets)" +docker exec "${CONTAINER_NAME}" bash -lc ' +for _ in $(seq 1 30); do + if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then + exit 0 + fi + sleep 1 +done +systemctl status epusdt --no-pager || true +journalctl -u epusdt --no-pager -n 100 || true +exit 1 +' + +docker_bash '[[ -x /opt/epusdt/epusdt ]]' +docker_bash '[[ -f /opt/epusdt/.env ]]' +docker_bash 'systemctl is-active --quiet epusdt' +docker_bash '[[ -f /opt/epusdt/www/index.html ]]' +docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' +docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "EPCTL_NO_COLOR=1 epctl --lang zh show-config | grep -q \"^install=false$\""' +docker exec "${CONTAINER_NAME}" bash -lc ' +su - tester -c "epctl status >/tmp/epctl-status.out 2>/tmp/epctl-status.err" +! grep -q "systemd-journal" /tmp/epctl-status.err +' +docker exec "${CONTAINER_NAME}" bash -lc ' +su - tester -c "epctl logs --lines 50 >/tmp/epctl-logs.out 2>/tmp/epctl-logs.err" +! grep -q "systemd-journal" /tmp/epctl-logs.err +' + +if [[ -n "${UPGRADE_TAG}" ]]; then + log "$(trf upgrading_release "${INSTALL_TAG}" "${UPGRADE_TAG}")" + docker exec "${CONTAINER_NAME}" env TAG="${UPGRADE_TAG}" bash -lc 'su - tester -c "epctl upgrade --tag ${TAG}"' + docker exec "${CONTAINER_NAME}" bash -lc ' +for _ in $(seq 1 30); do + if systemctl is-active --quiet epusdt && [[ -f /opt/epusdt/www/index.html ]]; then + exit 0 + fi + sleep 1 +done +systemctl status epusdt --no-pager || true +journalctl -u epusdt --no-pager -n 100 || true +exit 1 +' + docker exec "${CONTAINER_NAME}" bash -lc 'su - tester -c "epctl show-config | grep -q \"^install=false$\""' + docker exec "${CONTAINER_NAME}" bash -lc ' +su - tester -c "epctl status >/tmp/epctl-status-upgrade.out 2>/tmp/epctl-status-upgrade.err" +! grep -q "systemd-journal" /tmp/epctl-status-upgrade.err +' +fi + +log "$(trf checking_init_password)" +docker exec "${CONTAINER_NAME}" bash -lc ' +response="$(su - tester -c "epctl init-password")" +password="$(printf "%s\n" "${response}" | jq -r ".data.password")" +printf "%s\n" "${response}" | jq -e ".status_code == 200 and (.data.password | type == \"string\" and length > 0)" >/dev/null + +login_payload="$(jq -nc --arg username admin --arg password "${password}" "{username:\$username,password:\$password}")" +token="$(curl --fail --silent --show-error \ + -H "Content-Type: application/json" \ + -d "${login_payload}" \ + http://127.0.0.1:8000/admin/api/v1/auth/login | jq -r ".data.token")" +[[ -n "${token}" && "${token}" != "null" ]] + +change_payload="$(jq -nc --arg old_password "${password}" --arg new_password "new-pass-789" "{old_password:\$old_password,new_password:\$new_password}")" +curl --fail --silent --show-error \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -d "${change_payload}" \ + http://127.0.0.1:8000/admin/api/v1/auth/password | jq -e ".status_code == 200" >/dev/null + +failure_output="$(mktemp)" +if su - tester -c "epctl init-password" >"${failure_output}" 2>&1; then + cat "${failure_output}" >&2 + rm -f "${failure_output}" + echo "'"$(trf expected_second_failure)"'" >&2 + exit 1 +fi +grep -Eq "\"status_code\"[[:space:]]*:[[:space:]]*10040" "${failure_output}" +rm -f "${failure_output}" +' + +log "$(trf verification_succeeded "${INSTALL_TAG}" "${UPGRADE_TAG:+ upgrade=${UPGRADE_TAG}}")" diff --git a/wiki/EPCTL.en.md b/wiki/EPCTL.en.md new file mode 100644 index 0000000..3e1ab9b --- /dev/null +++ b/wiki/EPCTL.en.md @@ -0,0 +1,213 @@ +# epctl Installation and Verification Scripts + +`epctl` is the repo-root Linux binary installer and service manager for released `epusdt` binaries on GitHub Releases. +`epctl-docker-test.sh` is the matching end-to-end validation script. It launches Ubuntu + systemd in Docker and verifies download, install, startup, upgrade, and init-password behavior with real release artifacts. + +## Scope + +- Linux only +- Binary installation only +- Release source is fixed to `https://github.com/GMWalletApp/epusdt/releases` +- Service management is based on systemd + +## Dependencies and Privileges + +`epctl` expects these commands: + +- `curl` +- `tar` +- `systemctl` +- `install` +- `grep` +- `sed` + +Notes: + +- `install`, `upgrade`, and `self-install` write into `/opt`, `/etc/systemd`, and `/usr/local/bin` +- `status` and `logs` automatically re-run through `sudo` when needed +- in practice, the operator should have `sudo` access + +## Fixed Paths + +| Item | Path | +|------|------| +| Install directory | `/opt/epusdt` | +| Main binary | `/opt/epusdt/epusdt` | +| Active config | `/opt/epusdt/.env` | +| Example config | `/opt/epusdt/.env.example` | +| Extracted frontend assets | `/opt/epusdt/www` | +| Download cache | `/tmp/epusdt//` | +| systemd unit | `/etc/systemd/system/epusdt.service` | +| Global epctl path | `/usr/local/bin/epctl` | + +## Quick Start + +Run the interactive menu from the repo root: + +```bash +./epctl +``` + +The default interactive language is Chinese. You can force either language: + +```bash +./epctl zh +./epctl en +./epctl --lang zh help +./epctl --lang en help +``` + +Install the script into PATH: + +```bash +./epctl self-install +epctl +``` + +## Common Commands + +Download a specific release: + +```bash +./epctl download --tag v1.0.8 +``` + +Install the service: + +```bash +./epctl install --tag v1.0.8 \ + --app-uri https://pay.example.com \ + --listen 127.0.0.1:18000 +``` + +Upgrade to a newer release: + +```bash +./epctl upgrade --tag v1.0.9 +``` + +Inspect config, status, and logs: + +```bash +./epctl show-config +./epctl status +./epctl logs --lines 200 +``` + +Request the initial admin password: + +```bash +./epctl init-password +``` + +## Behavior When `--tag` Is Omitted + +For `download`, `install`, and `upgrade`, `epctl` resolves the current latest GitHub release tag first, then shows the exact tag and asks for confirmation. + +Example: + +```bash +./epctl install --app-uri https://pay.example.com +``` + +In interactive use, the script will display the resolved latest tag before continuing. +For automation, passing `--tag` explicitly is preferred. If you intentionally want to skip the confirmation, use: + +```bash +EPCTL_ASSUME_YES=1 ./epctl download +``` + +## What Happens on First Install + +`install` performs these steps: + +1. Detect the current CPU architecture and download the matching GitHub Release archive +2. Extract into `/tmp/epusdt//extract/` +3. Install the binary to `/opt/epusdt/epusdt` +4. Install `.env.example` to `/opt/epusdt/.env.example` +5. Create the system user and group `epusdt` +6. Auto-create `/opt/epusdt/.env` from `.env.example` if it does not exist +7. Write and enable `epusdt.service` + +When `.env` is auto-created, the script applies only the minimum bootstrap changes: + +- `install=false` +- `app_uri=<--app-uri, default http://127.0.0.1:8000>` +- `http_listen=<--listen, default 127.0.0.1:8000>` + +If `/opt/epusdt/.env` already exists, install and upgrade keep it unchanged. + +## systemd Service Details + +The service name is fixed to `epusdt.service` and uses these core settings: + +```ini +WorkingDirectory=/opt/epusdt +ExecStart=/opt/epusdt/epusdt http start +User=epusdt +Group=epusdt +Restart=always +RestartSec=3 +``` + +The working directory stays at `/opt/epusdt` because the program extracts its `www/` assets next to the binary. + +## What `init-password` Does + +`epctl init-password` only calls the local HTTP route: + +```text +GET /admin/api/v1/auth/init-password +``` + +It does not read the database directly. + +The script reads `http_listen` from `/opt/epusdt/.env` and normalizes these listen values into a local request target: + +- `:8000` -> `127.0.0.1:8000` +- `0.0.0.0:8000` -> `127.0.0.1:8000` + +If the API returns `10040`, the bootstrap plaintext password is no longer available. Common reasons are: + +- the admin password has already been changed +- the initial password has already been consumed and cannot be fetched again + +In that case, `epctl` prints the original HTTP error body for diagnosis. + +## Docker Validation Script + +The repo also provides: + +```bash +./epctl-docker-test.sh [upgrade-tag] +``` + +Examples: + +```bash +./epctl-docker-test.sh v1.0.6 +./epctl-docker-test.sh --lang en v1.0.6 v1.0.8 +``` + +It performs these checks on the local machine: + +- builds an `ubuntu:24.04` + systemd test image +- starts a privileged container +- runs `epctl self-install` inside the container +- downloads real GitHub Release artifacts +- installs `epusdt` +- verifies the systemd service, `www/index.html`, config output, logs, and status +- verifies that `init-password` succeeds once and later returns `10040` after the admin password is changed + +Requirements: + +- Docker installed locally +- permission to run Docker +- outbound access to GitHub Releases + +## Recommendations + +- Prefer explicit `--tag` values in automation +- After installation, run `./epctl show-config` once to verify the active `.env` +- Change the admin password immediately after retrieving the initial password +- If you want to validate the installer itself, run `./epctl-docker-test.sh` first diff --git a/wiki/EPCTL.md b/wiki/EPCTL.md new file mode 100644 index 0000000..e58dcb8 --- /dev/null +++ b/wiki/EPCTL.md @@ -0,0 +1,213 @@ +# epctl 安装与验证脚本 + +`epctl` 是仓库顶层的 Linux 二进制安装管理脚本,面向已经发布到 GitHub Releases 的 `epusdt` 二进制包。 +`epctl-docker-test.sh` 是配套的真实验收脚本,用本机 Docker 启动 Ubuntu + systemd 容器,完整验证安装、启动、升级和初始化密码流程。 + +## 适用范围 + +- 仅支持 Linux +- 仅支持二进制安装 +- 安装源固定为 `https://github.com/GMWalletApp/epusdt/releases` +- 默认通过 systemd 管理服务 + +## 依赖与权限 + +`epctl` 依赖这些基础命令: + +- `curl` +- `tar` +- `systemctl` +- `install` +- `grep` +- `sed` + +其中: + +- `install`、`upgrade`、`self-install` 需要写入 `/opt`、`/etc/systemd`、`/usr/local/bin` +- `status`、`logs` 会在需要时自动通过 `sudo` 重新执行 +- 所以日常使用建议当前用户具备 `sudo` 权限 + +## 固定路径 + +| 项目 | 路径 | +|------|------| +| 安装目录 | `/opt/epusdt` | +| 主程序 | `/opt/epusdt/epusdt` | +| 配置文件 | `/opt/epusdt/.env` | +| 示例配置 | `/opt/epusdt/.env.example` | +| 前端释放目录 | `/opt/epusdt/www` | +| 下载缓存 | `/tmp/epusdt//` | +| systemd unit | `/etc/systemd/system/epusdt.service` | +| epctl 全局安装位置 | `/usr/local/bin/epctl` | + +## 快速开始 + +直接在仓库根目录运行交互菜单: + +```bash +./epctl +``` + +默认优先进入中文界面。你也可以显式指定语言: + +```bash +./epctl zh +./epctl en +./epctl --lang zh help +./epctl --lang en help +``` + +如果想把脚本装进 PATH: + +```bash +./epctl self-install +epctl +``` + +## 常用命令 + +下载指定版本: + +```bash +./epctl download --tag v1.0.8 +``` + +安装服务: + +```bash +./epctl install --tag v1.0.8 \ + --app-uri https://pay.example.com \ + --listen 127.0.0.1:18000 +``` + +升级到新版本: + +```bash +./epctl upgrade --tag v1.0.9 +``` + +查看配置、状态、日志: + +```bash +./epctl show-config +./epctl status +./epctl logs --lines 200 +``` + +请求初始化管理员密码: + +```bash +./epctl init-password +``` + +## 不传 `--tag` 时的行为 + +`download`、`install`、`upgrade` 在未传 `--tag` 时,会先调用 GitHub API 解析当前 latest release tag,再向用户显示实际 tag 并确认。 + +例如: + +```bash +./epctl install --app-uri https://pay.example.com +``` + +交互模式下会先提示检测到的最新 tag。 +非交互脚本执行时,建议显式传入 `--tag`。如果你明确要跳过确认,可以设置: + +```bash +EPCTL_ASSUME_YES=1 ./epctl download +``` + +## 首次安装时会发生什么 + +执行 `install` 时,脚本会: + +1. 按当前机器架构下载 GitHub Release 压缩包 +2. 解压到 `/tmp/epusdt//extract/` +3. 安装二进制到 `/opt/epusdt/epusdt` +4. 安装 `.env.example` 到 `/opt/epusdt/.env.example` +5. 创建系统用户和组 `epusdt` +6. 若 `/opt/epusdt/.env` 不存在,则从 `.env.example` 自动生成 +7. 写入并启用 `epusdt.service` + +自动生成 `.env` 时,脚本只会补默认上线所需的最小改动: + +- `install=false` +- `app_uri=<--app-uri,默认 http://127.0.0.1:8000>` +- `http_listen=<--listen,默认 127.0.0.1:8000>` + +如果 `/opt/epusdt/.env` 已存在,则安装和升级都会保留它,不会覆盖。 + +## systemd 服务说明 + +脚本注册的服务名固定为 `epusdt.service`,核心参数如下: + +```ini +WorkingDirectory=/opt/epusdt +ExecStart=/opt/epusdt/epusdt http start +User=epusdt +Group=epusdt +Restart=always +RestartSec=3 +``` + +`WorkingDirectory` 固定为 `/opt/epusdt`,因为程序会在二进制同级目录释放 `www/` 静态文件。 + +## `init-password` 的含义 + +`epctl init-password` 只会请求本地 HTTP 路由: + +```text +GET /admin/api/v1/auth/init-password +``` + +它不会直接读数据库。 + +脚本会从 `/opt/epusdt/.env` 解析 `http_listen`,然后自动把这些监听写法转成本地可请求地址: + +- `:8000` -> `127.0.0.1:8000` +- `0.0.0.0:8000` -> `127.0.0.1:8000` + +如果接口返回 `10040`,含义是初始化明文密码已经不可用。常见原因是: + +- 管理员已经登录并修改过密码 +- 初始化密码已经被消费,当前不再允许再次取回 + +此时脚本会直接把接口原始错误输出出来,方便排查。 + +## Docker 验收脚本 + +仓库顶层提供: + +```bash +./epctl-docker-test.sh [upgrade-tag] +``` + +示例: + +```bash +./epctl-docker-test.sh v1.0.6 +./epctl-docker-test.sh --lang zh v1.0.6 v1.0.8 +``` + +它会在本机: + +- 构建 `ubuntu:24.04` + systemd 测试镜像 +- 启动一个特权容器 +- 在容器内执行 `epctl self-install` +- 下载真实 GitHub Release +- 安装 `epusdt` +- 检查 `systemd` 服务、`www/index.html`、配置文件、日志、状态输出 +- 验证 `init-password` 首次成功、修改管理员密码后再次请求返回 `10040` + +运行前提: + +- 本机已安装 Docker +- 当前用户有权限执行 Docker +- 宿主机能够访问 GitHub Releases + +## 建议 + +- 自动化部署场景优先显式传 `--tag` +- 生产环境建议安装完成后先执行一次 `./epctl show-config` +- 首次拿到初始化密码后,建议立即登录后台修改管理员密码 +- 如果只是验证脚本是否可用,优先跑 `./epctl-docker-test.sh`