diff --git a/README.md b/README.md index af8f0d7..d095c77 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ sudo newszxcn-email rollback ```bash sudo ns sudo newszxcn-email guide +sudo newszxcn-email credentials +sudo newszxcn-email reset-password sudo newszxcn-email status sudo newszxcn-email logs sudo newszxcn-email restart @@ -90,6 +92,8 @@ sudo newszxcn-email certificate sudo newszxcn-email uninstall ``` +`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库。 + `uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。 ## DNS 与端口 diff --git a/deploy/README.md b/deploy/README.md index f3c84be..f5016da 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -16,6 +16,8 @@ sudo newszxcn-email status sudo newszxcn-email logs sudo newszxcn-email rollback sudo newszxcn-email guide +sudo newszxcn-email credentials +sudo newszxcn-email reset-password ``` 一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 @@ -24,6 +26,8 @@ sudo newszxcn-email guide 安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。 +菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。 + ## 最简单部署:单容器镜像版 服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 741bb4f..766abdc 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -103,12 +103,16 @@ sudo newszxcn-email logs sudo newszxcn-email certificate sudo newszxcn-email rollback sudo newszxcn-email guide +sudo newszxcn-email credentials +sudo newszxcn-email reset-password ``` 命令行更新会创建完整回滚快照、校验 SQLite 数据库备份、拉取最新镜像并执行健康检查。`rollback` 命令会先备份当前数据库并要求确认,然后恢复上次更新前的镜像、数据库、Compose、环境、安装脚本和 Nginx 配置。回滚镜像会保持锁定,下一次执行更新时解除。 `guide` 命令会读取当前安装地址、管理员用户名、证书到期时间和 acme.sh 续期状态,重新生成仅 root 可读的 `/root/newszxcn-email-guide.txt`。 +`credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库只保存 bcrypt 密码哈希,无法反向查看真实密码;若管理员后来在网页修改过密码,记录值可能已经失效。忘记密码时执行 `reset-password`,脚本会先备份并校验数据库,然后重置配置管理员的统一登录密码,同时同步该管理员名下邮箱的 SMTP/IMAP 密码。该操作不会修改普通用户或其邮箱。 + 超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。 ## 必要端口 diff --git a/install.sh b/install.sh index 1427132..f62c177 100755 --- a/install.sh +++ b/install.sh @@ -35,6 +35,8 @@ NewSzxcn Email 管理命令 certificate 申请或续期自动模式的 SSL 证书 rollback 回滚到上次更新前版本 guide 显示并更新 NewSzxcn 邮箱指南 + credentials 查看管理员登录信息和记录密码 + reset-password 重置管理员统一登录密码(含名下邮箱) uninstall 停止并移除容器,保留邮件与配置 EOF } @@ -233,7 +235,7 @@ prompt_choice() { } prompt_menu_choice() { - local default_value="$1" max_value="${2:-10}" value="${LANQIN_MENU_ACTION:-}" + local default_value="$1" max_value="${2:-12}" value="${LANQIN_MENU_ACTION:-}" if [[ -z "${value}" ]] && ! has_tty; then fail "非交互环境请直接使用 install、update、status 等子命令。" fi @@ -320,6 +322,50 @@ prompt_admin_password() { done } +prompt_reset_password() { + local password="${LANQIN_RESET_PASSWORD:-}" confirm="" + local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$' + if [[ -n "${password}" ]]; then + [[ ${#password} -ge 6 ]] || fail "新密码至少需要 6 个字符。" + [[ "${password}" =~ ${safe_password_re} ]] || fail "新密码包含安装配置不支持的字符。" + printf '%s' "${password}" + return + fi + if ! has_tty; then + password="$(random_admin_password)" + prompt_text "[提示] 已自动生成 12 位新密码:${password}\n" + printf '%s' "${password}" + return + fi + while true; do + read -r -s -p "新密码(回车自动生成 12 位,或输入至少 6 位): " password /dev/tty + if [[ -z "${password}" ]]; then + password="$(random_admin_password)" + prompt_text "[提示] 已自动生成 12 位新密码:${password}\n" + printf '%s' "${password}" + return + fi + if [[ ${#password} -lt 6 ]]; then + prompt_text "[提示] 新密码至少需要 6 个字符。\n" + continue + fi + if [[ ! "${password}" =~ ${safe_password_re} ]]; then + prompt_text "[提示] 密码必须以字母或数字开头,只能使用字母、数字和常用符号。\n" + continue + fi + read -r -s -p "再次输入新密码: " confirm /dev/tty + if [[ "${password}" != "${confirm}" ]]; then + prompt_text "[提示] 两次输入的密码不一致,请重新输入。\n" + password="" + continue + fi + printf '%s' "${password}" + return + done +} + configure_first_install() { if [[ -f "${INSTALL_DIR}/.env" ]]; then return @@ -1030,6 +1076,8 @@ SSL 证书:有效期至 ${certificate_expiry} 查看状态:newszxcn-email status 查看日志:newszxcn-email logs 恢复版本:newszxcn-email rollback +查看管理员登录信息:newszxcn-email credentials +重置管理员密码:newszxcn-email reset-password 公开教程: https://github.com/zxyszx/NewSzxcn-Email/blob/main/docs/GUIDE.md @@ -1044,6 +1092,77 @@ do_guide() { success "指南已更新并保存到 ${GUIDE_FILE}。" } +do_show_admin_credentials() { + [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。" + local username password public_url + username="$(env_value LANQIN_ADMIN_USERNAME || true)" + password="$(env_value LANQIN_ADMIN_PASSWORD || true)" + public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)" + cat </dev/null | tail -n 1)" + hash="${hash#\{BLF-CRYPT\}}" + [[ "${hash}" =~ ^\$2[aby]\$[0-9]{2}\$.{53}$ ]] || return 1 + printf '%s' "${hash}" +} + +do_reset_admin_password() { + [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。" + local username password user_id hash image timestamp backup env_backup result user_changes mailbox_changes + ensure_docker + username="$(env_value LANQIN_ADMIN_USERNAME || true)" + username="${username:-admin}" + [[ "${username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名配置无效,无法安全重置。" + user_id="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "SELECT id FROM users WHERE login_name='${username}' AND role='admin' LIMIT 1;" | tr -d '\r\n')" + [[ "${user_id}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "没有找到管理员账号 ${username}。" + password="$(prompt_reset_password)" + hash="$(generate_admin_password_hash "${password}")" || fail "无法生成安全密码哈希,管理员密码未修改。" + image="$(current_image_id || true)" + [[ -n "${image}" ]] || fail "无法确定当前镜像,管理员密码未修改。" + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + backup="${INSTALL_DIR}/data/backups/password-reset-${timestamp}.db" + backup_database "${backup}" "${image}" || fail "数据库备份失败,管理员密码未修改。" + + env_backup="$(mktemp)" + install -m 0600 "${INSTALL_DIR}/.env" "${env_backup}" + if ! set_env LANQIN_ADMIN_PASSWORD "${password}"; then + rm -f "${env_backup}" + fail "管理员密码记录更新失败,数据库未修改。" + fi + chmod 0600 "${INSTALL_DIR}/.env" + if ! result="$(compose exec -T lanqin-email sqlite3 -batch -noheader /data/lanqin.db "BEGIN IMMEDIATE; UPDATE users SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id='${user_id}' AND role='admin'; SELECT 'user=' || changes(); UPDATE mailboxes SET password_hash='${hash}', updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id='${user_id}' AND EXISTS (SELECT 1 FROM users WHERE id='${user_id}' AND role='admin'); SELECT 'mailboxes=' || changes(); COMMIT;" | tr -d '\r')"; then + install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env" + rm -f "${env_backup}" + fail "管理员密码写入失败,已恢复原密码记录。" + fi + user_changes="$(printf '%s\n' "${result}" | sed -n 's/^user=//p' | tail -n 1)" + mailbox_changes="$(printf '%s\n' "${result}" | sed -n 's/^mailboxes=//p' | tail -n 1)" + if [[ "${user_changes}" != "1" || ! "${mailbox_changes}" =~ ^[0-9]+$ ]]; then + install -m 0600 "${env_backup}" "${INSTALL_DIR}/.env" + rm -f "${env_backup}" + fail "管理员账号不存在或身份已变化,密码未修改;已恢复原密码记录。" + fi + rm -f "${env_backup}" + success "管理员 ${username} 的统一登录密码已重置。" + printf '新密码:%s\n' "${password}" + log "重置前数据库备份:${backup}" + log "已同步 ${mailbox_changes} 个管理员邮箱的 SMTP/IMAP 密码。" + warn "此次操作只修改管理员账号及其名下邮箱,不会修改普通用户或其邮箱密码。" +} + do_status() { [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" compose ps @@ -1181,11 +1300,13 @@ do_menu() { prompt_text ' 7. 申请、检查或续期 SSL 证书\n' prompt_text ' 8. 回滚到上次更新前版本\n' prompt_text ' 9. NewSzxcn 邮箱指南\n' - prompt_text ' 10. 卸载服务(保留数据)\n' + prompt_text ' 10. 查看管理员登录信息\n' + prompt_text ' 11. 重置管理员统一登录密码\n' + prompt_text ' 12. 卸载服务(保留数据)\n' prompt_text ' 0. 退出\n' prompt_text '==================================================\n' - choice="$(prompt_menu_choice "${default_choice}" "10")" + choice="$(prompt_menu_choice "${default_choice}" "12")" if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then fail "尚未安装,请先选择 1。" fi @@ -1201,7 +1322,9 @@ do_menu() { 7) do_certificate ;; 8) ensure_docker; do_rollback ;; 9) do_guide ;; - 10) ensure_docker; do_uninstall ;; + 10) do_show_admin_credentials ;; + 11) do_reset_admin_password ;; + 12) ensure_docker; do_uninstall ;; esac } @@ -1228,6 +1351,8 @@ case "${COMMAND}" in certificate) require_root; require_curl; do_certificate ;; rollback) require_root; require_curl; ensure_docker; do_rollback ;; guide) require_root; require_curl; do_guide ;; + credentials) require_root; require_curl; do_show_admin_credentials ;; + reset-password) require_root; require_curl; do_reset_admin_password ;; uninstall) require_root; require_curl; ensure_docker; do_uninstall ;; *) usage; fail "未知命令:${COMMAND}" ;; esac diff --git a/tests/install_test.sh b/tests/install_test.sh index 15e51ad..50a0170 100644 --- a/tests/install_test.sh +++ b/tests/install_test.sh @@ -36,6 +36,11 @@ test_password_validation() { if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then fail_test "password beginning with an env-file comment marker accepted" fi + LANQIN_RESET_PASSWORD="reset1" + assert_eq "reset1" "$(prompt_reset_password)" "six-character reset password" + if (LANQIN_RESET_PASSWORD="reset" prompt_reset_password >/dev/null 2>&1); then + fail_test "five-character reset password accepted" + fi } test_install_configuration() { @@ -114,14 +119,79 @@ test_menu_choice() { assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action" export LANQIN_MENU_ACTION=1 assert_eq "1" "$(prompt_menu_choice 2)" "menu install action" - export LANQIN_MENU_ACTION=10 - assert_eq "10" "$(prompt_menu_choice 1 10)" "menu uninstall action" - if (has_tty() { return 1; }; LANQIN_MENU_ACTION=11 prompt_menu_choice 1 10 >/dev/null 2>&1); then + export LANQIN_MENU_ACTION=12 + assert_eq "12" "$(prompt_menu_choice 1 12)" "menu uninstall action" + if (has_tty() { return 1; }; LANQIN_MENU_ACTION=13 prompt_menu_choice 1 12 >/dev/null 2>&1); then fail_test "out-of-range menu action accepted" fi unset LANQIN_MENU_ACTION } +test_admin_credentials() ( + local temp_dir output + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + mkdir -p "${INSTALL_DIR}" + cat > "${INSTALL_DIR}/.env" <<'EOF' +LANQIN_PUBLIC_BASE_URL=https://mail.example.com +LANQIN_ADMIN_USERNAME=admin +LANQIN_ADMIN_PASSWORD=recorded-password +EOF + output="$(do_show_admin_credentials 2>&1)" + [[ "${output}" == *'登录地址:https://mail.example.com'* ]] || fail_test "administrator login URL missing" + [[ "${output}" == *'管理员用户名:admin'* ]] || fail_test "administrator username missing" + [[ "${output}" == *'记录密码:recorded-password'* ]] || fail_test "recorded administrator password missing" + [[ "${output}" == *'无法从数据库反向查看'* ]] || fail_test "password hash warning missing" +) + +test_admin_password_hash_parsing() ( + compose() { + # shellcheck disable=SC2016 + printf '{BLF-CRYPT}$2y$10$123456789012345678901u1234567890123456789012345678901\n' + } + # shellcheck disable=SC2016 + assert_eq '$2y$10$123456789012345678901u1234567890123456789012345678901' "$(generate_admin_password_hash 'unused')" "Dovecot bcrypt hash parsing" +) + +test_admin_password_reset_only_updates_admin_account() ( + local temp_dir compose_calls backup_path + temp_dir="$(mktemp -d)" + INSTALL_DIR="${temp_dir}/install" + compose_calls="${temp_dir}/compose-calls" + mkdir -p "${INSTALL_DIR}/data/backups" + cat > "${INSTALL_DIR}/.env" <<'EOF' +LANQIN_ADMIN_USERNAME=admin +LANQIN_ADMIN_PASSWORD=old-password +EOF + printf 'database\n' > "${INSTALL_DIR}/data/lanqin.db" + + ensure_docker() { return 0; } + current_image_id() { printf 'sha256:test-image\n'; } + backup_database() { + backup_path="$1" + printf 'backup\n' > "${backup_path}" + } + prompt_reset_password() { printf 'new-password'; } + # shellcheck disable=SC2016 + generate_admin_password_hash() { printf '$2y$10$123456789012345678901u1234567890123456789012345678901'; } + compose() { + printf '%s\n' "$*" >> "${compose_calls}" + if [[ "$*" == *'SELECT id FROM users'* ]]; then + printf 'admin-user-id\n' + elif [[ "$*" == *'UPDATE users SET password_hash'* ]]; then + printf 'user=1\nmailboxes=2\n' + fi + } + + do_reset_admin_password >/dev/null + assert_eq "new-password" "$(env_value LANQIN_ADMIN_PASSWORD)" "recorded reset password" + [[ -s "${backup_path}" ]] || fail_test "password reset database backup missing" + grep -Fq "login_name='admin' AND role='admin'" "${compose_calls}" || fail_test "administrator lookup is not role restricted" + grep -Fq "UPDATE users SET password_hash=" "${compose_calls}" || fail_test "administrator user password was not updated" + grep -Fq "UPDATE mailboxes SET password_hash=" "${compose_calls}" || fail_test "administrator mailbox passwords were not synchronized" + grep -Fq "WHERE user_id='admin-user-id'" "${compose_calls}" || fail_test "mailbox password update is not restricted to the administrator" +) + test_offline_database_backup() ( local temp_dir destination temp_dir="$(mktemp -d)" @@ -439,6 +509,9 @@ test_nginx_configuration test_compose_configuration test_legacy_configuration_is_preserved test_menu_choice +test_admin_credentials +test_admin_password_hash_parsing +test_admin_password_reset_only_updates_admin_account test_offline_database_backup test_guide_generation test_acme_cron_detection