Compare commits

...

7 Commits

Author SHA1 Message Date
zxyszx 397ce51800 fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 04:14:36 +08:00
zxyszx e789cf9b14 chore: prepare v1.2.6 release
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
2026-08-04 03:44:14 +08:00
zxyszx 7e480795fc feat: add administrator password recovery menu 2026-08-04 03:40:34 +08:00
zxyszx a2eb1fbc52 test: support GNU stat permissions 2026-08-04 02:28:21 +08:00
zxyszx cab71c0539 fix: make snapshot validation shellcheck-safe 2026-08-04 02:26:55 +08:00
zxyszx 6301182cfc feat: harden installer management menu 2026-08-04 02:23:46 +08:00
zxyszx 6d0147b639 docs: add NewSzxcn mailbox guide 2026-08-04 01:44:14 +08:00
14 changed files with 1357 additions and 151 deletions
+36
View File
@@ -0,0 +1,36 @@
## 本次更新
### 邮箱管理跳转
- 账号尚未创建邮箱时,点击“前往邮箱管理”统一进入前端个人邮箱管理页。
- 管理员和普通用户使用相同入口,可在前端创建、申请或查看自己的邮箱,不再误跳到后台邮箱列表。
### 管理员密码恢复
- 管理菜单新增“查看管理员登录信息”,可查看安装时或最近一次命令行重置后记录的管理员用户名和密码。
- 新增“重置管理员统一登录密码”,支持回车自动生成 12 位密码,或输入不少于 6 位的自定义密码。
- 重置前自动备份并校验 SQLite 数据库;只更新配置管理员账号,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不影响普通用户及其邮箱。
- 数据库中的密码使用 bcrypt 哈希,无法反向解密;若密码曾在网页中修改,命令行显示的旧记录可能已经失效,可直接使用重置功能设置新密码。
### 一键安装与运维安全
- 管理菜单扩展为安装、更新、修复、状态、重启、日志、SSL、回滚、邮箱指南、管理员密码恢复和卸载等完整操作。
- 更新前创建完整回滚快照,包含数据库、镜像、Compose、环境配置、安装脚本、Nginx 和证书;更新失败或健康检查失败时自动恢复。
- 修复“更新前先覆盖 Compose 和管理脚本”的风险,改为临时下载、语法与 Compose 校验通过后再原子替换。
- 容器停止时也会离线备份数据库;备份为空或完整性检查失败时终止更新。
- 重新安装会完整保存旧安装,安装失败时自动恢复原目录、Nginx 和旧容器。
- 移除“开放全部端口”危险选项,不再清空服务器现有防火墙规则。
- 卸载时可单独停止当前域名的证书自动续期,不影响 acme.sh 管理的其他域名。
### 邮箱指南与快捷命令
- 新增 `ns` 快捷命令,可随时打开服务器管理菜单。
- 新增 `newszxcn-email guide`,动态显示前后台地址、管理员用户名、证书到期时间和自动续期状态。
- 新增完整的 NewSzxcn 邮箱指南,覆盖域名 DNS、自助申请邮箱、未知收件、SSL 自动续期、更新、回滚与日常运维。
### 兼容与验证
- 本次更新不删除现有邮件、域名、账号、证书和配置。
- 已通过 ShellCheck、安装脚本测试、Go API 测试、shadcn/ui 检查和前端生产构建。
**完整更新日志**[v1.2.5...v1.2.6](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.5...v1.2.6)
+21
View File
@@ -0,0 +1,21 @@
## 本次更新
### 修复网页更新出现 502
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
- 前端遇到更新期间的 `502``503``504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
### 更新说明
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
-`v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
### 验证
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
**完整更新日志**[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Check installer - name: Check installer
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y shellcheck sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh bash tests/install_test.sh
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- name: Check installer - name: Check installer
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y shellcheck sudo apt-get install -y shellcheck sqlite3
bash -n install.sh tests/install_test.sh bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh bash tests/install_test.sh
+16 -6
View File
@@ -7,7 +7,7 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
[![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml) [![CI](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml/badge.svg)](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE) [![License](https://img.shields.io/github/license/zxyszx/NewSzxcn-Email)](LICENSE)
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md) [邮箱指南](docs/GUIDE.md) · [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
## 主要功能 ## 主要功能
@@ -37,13 +37,13 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员 脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将 账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在 `/opt/newszxcn-email` 完整改名备份,失败时自动恢复原目录、Nginx 和旧容器。更新前会
启动失败时自动回滚 校验数据库备份并保存镜像、Compose、环境、安装脚本和 Nginx,失败时执行完整恢复
脚本会自动完成: 脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2 - 安装或检查 Docker Engine 与 Docker Compose v2
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口 - 选择自动添加邮局必要端口规则,或保留现有防火墙由用户自行配置
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位 - 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式 - 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程 - 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。 超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。 更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
### 命令行更新 ### 命令行更新
@@ -70,15 +72,21 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
sudo newszxcn-email update sudo newszxcn-email update
``` ```
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行: 命令行更新会创建完整回滚快照、校验数据库备份并执行健康检查。需要恢复上次更新前的镜像、数据库和配置时运行:
```bash ```bash
sudo newszxcn-email rollback sudo newszxcn-email rollback
``` ```
手动回滚会先要求确认,并额外备份当前数据库,再恢复更新前版本。回滚后的镜像会保持锁定,直到下一次明确执行更新。
常用运维命令: 常用运维命令:
```bash ```bash
sudo ns
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
sudo newszxcn-email status sudo newszxcn-email status
sudo newszxcn-email logs sudo newszxcn-email logs
sudo newszxcn-email restart sudo newszxcn-email restart
@@ -86,7 +94,9 @@ sudo newszxcn-email certificate
sudo newszxcn-email uninstall sudo newszxcn-email uninstall
``` ```
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件 `credentials` 显示安装或最近一次命令行重置时记录的管理员登录信息。数据库中的密码采用 bcrypt 哈希,无法反向查看;如果管理员后来在网页修改过密码,记录值可能已经失效。`reset-password` 只重置配置管理员的统一登录密码,并同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。重置前会先备份并校验数据库
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。卸载时可以单独停止当前域名的 acme.sh 自动续期,不会影响 acme.sh 管理的其他域名。
## DNS 与端口 ## DNS 与端口
+1 -1
View File
@@ -1 +1 @@
1.2.5 1.2.7
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to back up database") respondError(w, http.StatusInternalServerError, "failed to back up database")
return return
} }
if err := a.triggerUpdateService(r.Context()); err != nil {
a.log.Error("trigger system update", "error", err)
respondError(w, http.StatusBadGateway, "failed to start update")
return
}
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath) a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
respondJSON(w, http.StatusAccepted, map[string]any{ respondJSON(w, http.StatusAccepted, map[string]any{
"ok": true, "ok": true,
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
"targetVersion": info.LatestVersion, "targetVersion": info.LatestVersion,
"message": "更新已启动,服务会在完成后自动恢复", "message": "更新已启动,服务会在完成后自动恢复",
}) })
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
} }
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) { func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
} }
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken)) req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 10 * time.Minute,
CheckRedirect: func(*http.Request, []*http.Request) error { CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}, },
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
return nil return nil
} }
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
go func() {
// Let the accepted response reach the browser before Watchtower replaces this container.
time.Sleep(250 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := a.triggerUpdateService(ctx); err != nil {
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
}
}()
}
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) { func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
backupDir := filepath.Join(a.config().DataDir, "backups") backupDir := filepath.Join(a.config().DataDir, "backups")
if err := os.MkdirAll(backupDir, 0o700); err != nil { if err := os.MkdirAll(backupDir, 0o700); err != nil {
@@ -10,8 +10,10 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
) )
func TestSystemVersionAndUpdate(t *testing.T) { func TestSystemVersionAndUpdate(t *testing.T) {
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
defer releaseServer.Close() defer releaseServer.Close()
var updateRequests atomic.Int32 var updateRequests atomic.Int32
updateStarted := make(chan struct{}, 1)
releaseUpdate := make(chan struct{})
var releaseUpdateOnce sync.Once
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
defer releaseBlockedUpdate()
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
t.Errorf("update method = %s", r.Method) t.Errorf("update method = %s", r.Method)
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Errorf("authorization = %q", got) t.Errorf("authorization = %q", got)
} }
updateRequests.Add(1) updateRequests.Add(1)
updateStarted <- struct{}{}
<-releaseUpdate
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
defer updateServer.Close() defer updateServer.Close()
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
t.Fatalf("unexpected version response: %+v", version) t.Fatalf("unexpected version response: %+v", version)
} }
var update map[string]any type updateResponse struct {
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted { code int
t.Fatalf("update code=%d response=%v", code, update) err error
} }
if updateRequests.Load() != 1 { response := make(chan updateResponse, 1)
t.Fatalf("update requests=%d", updateRequests.Load()) go func() {
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
if err != nil {
response <- updateResponse{err: err}
return
}
req.AddCookie(admin.cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
response <- updateResponse{err: err}
return
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
response <- updateResponse{code: resp.StatusCode}
}()
select {
case result := <-response:
if result.err != nil || result.code != http.StatusAccepted {
t.Fatalf("update response=%+v", result)
}
case <-time.After(2 * time.Second):
releaseBlockedUpdate()
t.Fatal("update response waited for container replacement")
}
select {
case <-updateStarted:
case <-time.After(2 * time.Second):
t.Fatal("scheduled update request did not start")
}
releaseBlockedUpdate()
if got := updateRequests.Load(); got != 1 {
t.Fatalf("update requests=%d", got)
} }
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db")) backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
if err != nil || len(backups) != 1 { if err != nil || len(backups) != 1 {
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
const update = useMutation({ const update = useMutation({
mutationFn: async () => { mutationFn: async () => {
setUpdatePhase("starting") setUpdatePhase("starting")
const result = await api.updateSystem() const targetVersion = version.data?.latestVersion
let result: Awaited<ReturnType<typeof api.updateSystem>>
try {
result = await api.updateSystem()
} catch (error) {
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
result = {
ok: true,
currentVersion,
targetVersion,
message: "更新请求已发送,正在等待服务恢复",
}
}
setUpdatePhase("restarting") setUpdatePhase("restarting")
await waitForUpdatedService(result.targetVersion) await waitForUpdatedService(result.targetVersion)
return result return result
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
function delay(ms: number) { function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms)) return new Promise((resolve) => window.setTimeout(resolve, ms))
} }
function isUpdateConnectionInterruption(error: unknown) {
if (!(error instanceof Error)) return false
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
}
+1 -2
View File
@@ -174,7 +174,6 @@ export function MailPage() {
const canDownloadAttachments = hasPermission(user, "mail.attachments.download") const canDownloadAttachments = hasPermission(user, "mail.attachments.download")
const canManageSignatures = hasPermission(user, "mail.signatures.manage") const canManageSignatures = hasPermission(user, "mail.signatures.manage")
const canViewUnknownMail = user?.role === "admin" const canViewUnknownMail = user?.role === "admin"
const canManageMailboxes = hasPermission(user, "admin.mailboxes.view")
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
@@ -1469,7 +1468,7 @@ export function MailPage() {
) : !canReadMail ? ( ) : !canReadMail ? (
<PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} /> <PermissionEmptyState title="无邮件查看权限" description="当前账号可以访问邮箱前台,但未开启邮件查看权限。" onOpenSettings={openSettings} />
) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? ( ) : !mailboxList.isLoading && !hasMailboxes && mailView !== "unknown" ? (
<NoMailboxState onManageMailboxes={() => navigate(canManageMailboxes ? "/admin?section=mailboxes" : "/profile?tab=mailboxes")} /> <NoMailboxState onManageMailboxes={() => navigate("/profile?tab=mailboxes")} />
) : mailView === "scheduled" && canScheduleMail ? ( ) : mailView === "scheduled" && canScheduleMail ? (
<ScheduledSendView <ScheduledSendView
compact={compactMailLayout} compact={compactMailLayout}
+8 -1
View File
@@ -15,11 +15,18 @@ sudo newszxcn-email update
sudo newszxcn-email status sudo newszxcn-email status
sudo newszxcn-email logs sudo newszxcn-email logs
sudo newszxcn-email rollback sudo newszxcn-email rollback
sudo newszxcn-email guide
sudo newszxcn-email credentials
sudo newszxcn-email reset-password
``` ```
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。 一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。 首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。防火墙可以选择自动添加邮局必要端口规则或保留现有规则,不会清空服务器已有防火墙。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
安装后输入 `ns` 可以打开统一管理菜单。更新前会创建包含数据库、镜像、Compose、环境、安装脚本和 Nginx 的回滚快照;更新或健康检查失败时会自动恢复。手动完整回滚前还会单独备份当前数据库,回滚镜像会保持锁定到下一次更新。
菜单可查看安装或最近一次命令行重置时记录的管理员登录信息,也可单独重置配置管理员的统一登录密码。密码采用 bcrypt 哈希,无法从数据库反向解密;网页修改密码后,脚本中的记录可能已经失效。命令行重置前会备份并校验数据库,同时同步该管理员名下邮箱的 SMTP/IMAP 密码,不会修改普通用户或其邮箱。
## 最简单部署:单容器镜像版 ## 最简单部署:单容器镜像版
+154
View File
@@ -0,0 +1,154 @@
# NewSzxcn 邮箱指南
本指南介绍 NewSzxcn Email 的安装入口、首次配置、邮箱申请、无人收件、SSL 证书和日常更新。管理员密码等敏感信息不会保存在本文档中。
## 一键安装
建议使用 Debian 或 Ubuntu,并提前准备一个已经解析到服务器的邮件主机名,例如 `mail.example.com`
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
安装脚本会依次询问防火墙配置、邮件服务器域名、管理员用户名和密码,以及 Web 部署方式。选择“自动配置 Nginx + SSL”时,脚本会安装 Nginx,并使用官方 `acme.sh` 申请 Let's Encrypt 证书。
安装完成后,请记录终端中显示的访问地址、管理员用户名和初始密码。初始密码仅在安装时显示;如果以后在后台修改密码,请以新密码为准。
## 登录入口
假设安装时填写的邮件服务器域名为 `mail.example.com`
| 入口 | 地址 | 用途 |
| --- | --- | --- |
| 邮箱前台 | `https://mail.example.com/` | 收发邮件、申请邮箱和账号设置 |
| 管理后台 | `https://mail.example.com/admin` | 管理域名、账号、邮箱、DNS 和系统设置 |
管理员账号是安装时填写的用户名,默认为 `admin`。管理员用户名不是邮箱地址。
## 首次配置
### 1. 添加邮件域名
1. 登录 NewSzxcn Email 管理后台。
2. 进入“域名管理”,点击“添加域名”。
3. 填写需要收发邮件的域名并保存。
4. 点击该域名右侧的“DNS”,查看系统生成的记录。
5. 前往域名服务商的 DNS 管理页面,逐项添加 MX、SPF、DKIM 和 DMARC 记录。
6. 返回管理后台,点击“检测”。
7. 所有记录检测通过后,即可使用该域名创建邮箱。
DNS 生效通常需要几分钟到数小时。系统只能检测记录,不能代替你修改域名服务商的 DNS。
### 2. 开启账号自助申请邮箱
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“账号自助申请邮箱”。
3. 在“开放域名”中勾选允许用户申请邮箱的域名。
4. 保存设置。
开启后,用户登录邮箱前台,进入“设置 -> 邮箱管理”,即可在账号配额范围内自行申请邮箱,无需管理员逐个分配。
如果账号还没有邮箱,邮箱前台会显示“还没有可用邮箱”。此时应点击“前往邮箱管理”,进入个人中心申请邮箱。
### 3. 开启无人收件
1. 进入“管理后台 -> 系统设置 -> 邮件”。
2. 开启“无人收件”并保存。
开启后,对于系统中已经添加并启用的邮件域名,即使收件地址尚未注册,服务器仍会接收邮件。例如已经启用 `example.com` 后,发送到 `111@example.com` 的邮件也会被保留。
无人收件不会自动创建邮箱,也不会把邮件分配给普通用户。只有管理员可以在邮箱前台左侧的“未知收件”中查看这些邮件。
## SSL 证书与自动续期
选择“自动配置 Nginx + SSL”后,官方 `acme.sh` 会安装定时检查任务。证书接近到期时会自动续期,续期成功后自动重载 NewSzxcn Email 和 Nginx。
查看当前域名的证书和续期信息:
```bash
/root/.acme.sh/acme.sh --info --domain mail.example.com --ecc
```
查看证书实际到期时间:
```bash
openssl x509 -in /opt/newszxcn-email/certs/fullchain.pem -noout -enddate
```
手动申请、检查或重新安装证书:
```bash
sudo newszxcn-email certificate
```
证书续期计划由 `acme.sh` 和证书颁发机构动态决定,不应把预计续期日期写死在配置或文档中。
## 更新与运维
重新打开安装与运维菜单:
```bash
sudo ns
```
也可以执行 `sudo newszxcn-email menu`,或重新运行一键安装命令。
常用命令:
```bash
sudo newszxcn-email update
sudo newszxcn-email status
sudo newszxcn-email restart
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 密码。该操作不会修改普通用户或其邮箱。
超级管理员也可以点击管理后台侧栏中的版本号,在版本更新页面检查并安装新版本。
## 必要端口
请同时检查服务器防火墙和云服务商安全组:
| 端口 | 用途 |
| --- | --- |
| `25/TCP` | 邮件服务器之间收发邮件 |
| `80/TCP` | HTTP 跳转和证书签发验证 |
| `443/TCP` | 邮箱前台和管理后台 |
| `465/TCP` | SMTP SSL 发信 |
| `587/TCP` | SMTP Submission 发信 |
| `993/TCP` | IMAP SSL 收信 |
| `995/TCP` | POP3 SSL 收信 |
部分云服务商默认封锁出站 `25/TCP`。网页可以正常打开并不代表公网邮件一定能够成功投递。
## 数据与备份
默认数据目录为 `/opt/newszxcn-email`。重要数据包括:
```text
/opt/newszxcn-email/
|-- .env
|-- data/
|-- mail/
|-- dkim/
`-- certs/
```
执行服务器快照或异地备份时,应同时保存这些目录。不要公开 `.env`、证书私钥、数据库备份或管理员登录信息。
## 更多文档
- [项目说明](../README.md)
- [Docker 部署说明](../deploy/README.md)
- [API 文档](API.md)
- [版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases)
+653 -115
View File
@@ -6,9 +6,14 @@ RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}" INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
COMMAND="${1:-menu}" COMMAND="${1:-menu}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image" ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf" NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
ACME_WEBROOT="/var/www/newszxcn-acme" ACME_WEBROOT="/var/www/newszxcn-acme"
CERT_DIR="${INSTALL_DIR}/certs" CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="/root/newszxcn-email-guide.txt"
CLI_PATH="${LANQIN_CLI_PATH:-/usr/local/bin/newszxcn-email}"
CLI_ALIAS_PATH="${LANQIN_CLI_ALIAS_PATH:-/usr/local/bin/ns}"
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; } log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; } success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
@@ -28,7 +33,10 @@ NewSzxcn Email 管理命令
logs 持续查看运行日志 logs 持续查看运行日志
restart 重启服务并重载 Nginx restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书 certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次命令行更新前的镜像 rollback 回滚到上次更新前版本
guide 显示并更新 NewSzxcn 邮箱指南
credentials 查看管理员登录信息和记录密码
reset-password 重置管理员统一登录密码(含名下邮箱)
uninstall 停止并移除容器,保留邮件与配置 uninstall 停止并移除容器,保留邮件与配置
EOF EOF
} }
@@ -68,31 +76,93 @@ ensure_docker() {
} }
compose() { compose() {
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@" local runtime_image=""
if [[ -s "${RUNTIME_IMAGE_PIN}" ]]; then
runtime_image="$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")"
fi
if [[ -n "${runtime_image}" ]]; then
LANQIN_IMAGE="${runtime_image}" docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
else
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
fi
}
clear_runtime_image_pin() {
rm -f "${RUNTIME_IMAGE_PIN}"
} }
script_dir() { script_dir() {
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd
} }
refresh_assets() { stage_assets() {
local source_dir local_source="false" local source_dir local_source="false"
local compose_new env_example_new installer_new
source_dir="$(script_dir || true)" source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
local_source="true" local_source="true"
fi fi
install -d -m 0755 "${INSTALL_DIR}" install -d -m 0755 "${INSTALL_DIR}"
compose_new="${INSTALL_DIR}/.docker-compose.yml.new"
env_example_new="${INSTALL_DIR}/.env.example.new"
installer_new="${INSTALL_DIR}/.install.sh.new"
rm -f "${compose_new}" "${env_example_new}" "${installer_new}"
if [[ "${local_source}" == "true" && -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then if [[ "${local_source}" == "true" && -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then
install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${compose_new}"
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example" install -m 0644 "${source_dir}/deploy/.env.example" "${env_example_new}"
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email install -m 0755 "${source_dir}/install.sh" "${installer_new}"
else else
curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml" curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${compose_new}"
curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example" curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${env_example_new}"
curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new curl -fsSL "${RAW_BASE}/install.sh" -o "${installer_new}"
chmod 0755 /usr/local/bin/newszxcn-email.new
mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email
fi fi
chmod 0755 "${installer_new}"
bash -n "${installer_new}" || fail "新版安装脚本语法检查失败,现有文件未修改。"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -x "${installer_new}" || fail "新版安装脚本 ShellCheck 未通过,现有文件未修改。"
fi
if [[ -f "${INSTALL_DIR}/.env" ]] && command -v docker >/dev/null 2>&1; then
docker compose --project-directory "${INSTALL_DIR}" --env-file "${INSTALL_DIR}/.env" -f "${compose_new}" config >/dev/null \
|| fail "新版 Docker Compose 配置检查失败,现有文件未修改。"
fi
}
apply_staged_assets() {
install -m 0644 "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/docker-compose.yml" || return 1
install -m 0644 "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.env.example" || return 1
install -m 0755 "${INSTALL_DIR}/.install.sh.new" "${CLI_PATH}.new" || return 1
mv "${CLI_PATH}.new" "${CLI_PATH}" || return 1
rm -f "${INSTALL_DIR}/.docker-compose.yml.new" "${INSTALL_DIR}/.env.example.new" "${INSTALL_DIR}/.install.sh.new"
}
ensure_cli_alias() {
local target="${CLI_PATH}" alias_path="${CLI_ALIAS_PATH}" existing
[[ -x "${target}" ]] || return 0
if [[ -L "${alias_path}" ]]; then
existing="$(readlink "${alias_path}" 2>/dev/null || true)"
if [[ "${existing}" == "${target}" ]]; then
return 0
fi
warn "未修改 ${alias_path}:现有符号链接指向 ${existing:-未知目标}"
return 0
fi
if [[ -e "${alias_path}" ]]; then
warn "未创建快捷命令 ns${alias_path} 已被其他文件占用。"
return 0
fi
existing="$(type -P ns 2>/dev/null || true)"
if [[ -n "${existing}" && "${existing}" != "${alias_path}" ]]; then
warn "未创建快捷命令 ns:系统中已存在 ${existing}"
return 0
fi
ln -s "${target}" "${alias_path}"
success "快捷命令已创建:输入 ns 可打开管理菜单。"
}
refresh_assets() {
stage_assets
apply_staged_assets
ensure_cli_alias
} }
random_secret() { random_secret() {
@@ -165,7 +235,7 @@ prompt_choice() {
} }
prompt_menu_choice() { prompt_menu_choice() {
local default_value="$1" value="${LANQIN_MENU_ACTION:-}" local default_value="$1" max_value="${2:-12}" value="${LANQIN_MENU_ACTION:-}"
if [[ -z "${value}" ]] && ! has_tty; then if [[ -z "${value}" ]] && ! has_tty; then
fail "非交互环境请直接使用 install、update、status 等子命令。" fail "非交互环境请直接使用 install、update、status 等子命令。"
fi fi
@@ -174,13 +244,13 @@ prompt_menu_choice() {
read -r -p "请选择 [${default_value}]: " value </dev/tty read -r -p "请选择 [${default_value}]: " value </dev/tty
fi fi
value="${value:-${default_value}}" value="${value:-${default_value}}"
if [[ "${value}" =~ ^[0-9]$ ]]; then if [[ "${value}" =~ ^[0-9]+$ ]] && (( value >= 0 && value <= max_value )); then
printf '%s' "${value}" printf '%s' "${value}"
return return
fi fi
prompt_text "[提示] 请输入 0 至 9。\n" prompt_text "[提示] 请输入 0 至 ${max_value}。\n"
value="" value=""
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。" has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 ${max_value}"
done done
} }
@@ -252,14 +322,58 @@ prompt_admin_password() {
done 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
printf '\n' >/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
printf '\n' >/dev/tty
if [[ "${password}" != "${confirm}" ]]; then
prompt_text "[提示] 两次输入的密码不一致,请重新输入。\n"
password=""
continue
fi
printf '%s' "${password}"
return
done
}
configure_first_install() { configure_first_install() {
if [[ -f "${INSTALL_DIR}/.env" ]]; then if [[ -f "${INSTALL_DIR}/.env" ]]; then
return return
fi fi
local firewall_mode hostname admin_username admin_password web_mode public_url update_token local firewall_mode hostname admin_username admin_password web_mode public_url update_token
prompt_text '\n防火墙配置 [1]\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n' prompt_text '\n防火墙配置 [1]\n1. 自动添加邮局必要端口规则(推荐)\n2. 保留现有防火墙,由用户自行配置\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")" firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1" "2")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")" hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。" valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
@@ -343,7 +457,7 @@ configure_restricted_firewall() {
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done done
firewall-cmd --reload >/dev/null firewall-cmd --reload >/dev/null
success "firewalld 已仅开放 SSH 和邮局必要端口。" success "firewalld 已添加 SSH 和邮局必要端口规则。"
return return
fi fi
@@ -355,36 +469,17 @@ configure_restricted_firewall() {
ufw allow "${ssh_port}/tcp" >/dev/null ufw allow "${ssh_port}/tcp" >/dev/null
done done
ufw --force enable >/dev/null ufw --force enable >/dev/null
success "UFW 已开放 SSH 和邮局必要端口。" success "UFW 已添加 SSH 和邮局必要端口规则。"
return return
fi fi
fail "没有找到可管理的 UFW 或 firewalld。" fail "没有找到可管理的 UFW 或 firewalld。"
} }
configure_open_firewall() {
warn "正在按选择开放全部端口,请同时检查云厂商安全组。"
if command -v ufw >/dev/null 2>&1; then
ufw --force disable >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl disable --now firewalld >/dev/null 2>&1 || true
fi
if command -v iptables >/dev/null 2>&1; then
iptables -P INPUT ACCEPT
iptables -F INPUT
fi
if command -v ip6tables >/dev/null 2>&1; then
ip6tables -P INPUT ACCEPT
ip6tables -F INPUT
fi
success "主机防火墙已调整为开放入站;云厂商安全组仍需单独配置。"
}
configure_firewall() { configure_firewall() {
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
1) configure_restricted_firewall ;; 1) configure_restricted_firewall ;;
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;; 2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
3) configure_open_firewall ;; 3) warn "检测到旧版开放全部端口配置。为避免破坏现有安全规则,本次不再修改防火墙。" ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;; "") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;; *) fail "防火墙模式配置无效。" ;;
esac esac
@@ -415,6 +510,16 @@ ensure_nginx() {
fi fi
} }
reload_nginx() {
command -v nginx >/dev/null 2>&1 || return 0
nginx -t >/dev/null 2>&1 || return 1
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
}
write_nginx_http_config() { write_nginx_http_config() {
local hostname tmp local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)" hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -505,6 +610,7 @@ EOF
} }
ensure_acme() { ensure_acme() {
ensure_cron_scheduler
if [[ ! -x /root/.acme.sh/acme.sh ]]; then if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)" hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
@@ -512,6 +618,33 @@ ensure_acme() {
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}" curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。" [[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
if ! acme_cron_enabled; then
/root/.acme.sh/acme.sh --install-cronjob >/dev/null || fail "acme.sh 自动续期任务安装失败。"
fi
}
acme_cron_enabled() {
command -v crontab >/dev/null 2>&1 \
&& crontab -l 2>/dev/null | grep -Eq 'acme\.sh"?/acme\.sh[[:space:]]+--cron'
}
ensure_cron_scheduler() {
if ! command -v crontab >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
install_packages cron
else
install_packages cronie
fi
fi
command -v crontab >/dev/null 2>&1 || fail "系统缺少 Cron,无法配置证书自动续期。"
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now cron >/dev/null 2>&1 \
|| systemctl enable --now crond >/dev/null 2>&1 \
|| fail "Cron 服务启动失败,无法保证证书自动续期。"
elif command -v service >/dev/null 2>&1; then
service cron start >/dev/null 2>&1 || service crond start >/dev/null 2>&1 \
|| fail "Cron 服务启动失败,无法保证证书自动续期。"
fi
} }
install_certificate() { install_certificate() {
@@ -531,7 +664,7 @@ install_certificate() {
--domain "${hostname}" \ --domain "${hostname}" \
--fullchain-file "${CERT_DIR}/fullchain.pem" \ --fullchain-file "${CERT_DIR}/fullchain.pem" \
--key-file "${CERT_DIR}/privkey.pem" \ --key-file "${CERT_DIR}/privkey.pem" \
--reloadcmd "/usr/local/bin/newszxcn-email reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。" --reloadcmd "${CLI_PATH} reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
chmod 0644 "${CERT_DIR}/fullchain.pem" chmod 0644 "${CERT_DIR}/fullchain.pem"
chmod 0600 "${CERT_DIR}/privkey.pem" chmod 0600 "${CERT_DIR}/privkey.pem"
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem" set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
@@ -564,50 +697,208 @@ configure_web_mode() {
esac esac
} }
backup_database() { current_image_id() {
local timestamp local container_id image_ref
timestamp="$(date -u +%Y%m%dT%H%M%SZ)" container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then if [[ -n "${container_id}" ]]; then
compose exec -T lanqin-email sh -c "mkdir -p /data/backups && sqlite3 /data/lanqin.db \".backup '/data/backups/cli-update-${timestamp}.db'\"" >/dev/null docker inspect --format '{{.Image}}' "${container_id}"
log "数据库已备份到 data/backups/cli-update-${timestamp}.db" return
fi fi
image_ref="$(env_value LANQIN_IMAGE || true)"
image_ref="${image_ref:-ghcr.io/zxyszx/newszxcn-email:latest}"
docker image inspect --format '{{.Id}}' "${image_ref}" 2>/dev/null
} }
remember_current_image() { sqlite_integrity_check() {
local container_id image_id rollback_tag local database="$1" image="$2" relative result
container_id="$(compose ps -q lanqin-email 2>/dev/null || true)" if command -v sqlite3 >/dev/null 2>&1; then
[[ -n "${container_id}" ]] || return 0 result="$(sqlite3 "${database}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
image_id="$(docker inspect --format '{{.Image}}' "${container_id}")" else
rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)" relative="${database#"${INSTALL_DIR}"/data/}"
docker image tag "${image_id}" "${rollback_tag}" [[ "${relative}" != "${database}" ]] || return 1
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}" result="$(docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" "/data/${relative}" 'PRAGMA integrity_check;' 2>/dev/null || true)"
fi
[[ "${result}" == "ok" ]]
}
backup_database() {
local destination="$1" image="$2" container_id running="false" relative container_destination
[[ -s "${INSTALL_DIR}/data/lanqin.db" ]] || { warn "未找到可备份的数据库:${INSTALL_DIR}/data/lanqin.db"; return 1; }
install -d -m 0700 "$(dirname "${destination}")"
rm -f "${destination}"
relative="${destination#"${INSTALL_DIR}"/data/}"
[[ "${relative}" != "${destination}" ]] || { warn "数据库备份必须保存在 ${INSTALL_DIR}/data 内。"; return 1; }
container_destination="/data/${relative}"
container_id="$(compose ps -aq lanqin-email 2>/dev/null | head -n 1 || true)"
if [[ -n "${container_id}" ]] && [[ "$(docker inspect --format '{{.State.Running}}' "${container_id}" 2>/dev/null || true)" == "true" ]]; then
running="true"
fi
if [[ "${running}" == "true" ]]; then
docker exec "${container_id}" sqlite3 /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
|| { warn "运行中数据库备份失败。"; return 1; }
elif command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "${INSTALL_DIR}/data/lanqin.db" ".backup '${destination}'" >/dev/null \
|| { warn "离线数据库备份失败。"; return 1; }
else
docker image inspect "${image}" >/dev/null 2>&1 || { warn "无法找到用于离线备份的旧镜像。"; return 1; }
docker run --rm --entrypoint sqlite3 -v "${INSTALL_DIR}/data:/data" "${image}" /data/lanqin.db ".backup '${container_destination}'" >/dev/null \
|| { warn "离线数据库备份失败。"; return 1; }
fi
[[ -s "${destination}" ]] || { warn "数据库备份文件为空。"; return 1; }
sqlite_integrity_check "${destination}" "${image}" || { warn "数据库备份完整性检查未通过。"; return 1; }
log "数据库已备份并校验:${destination}"
}
create_update_snapshot() {
local timestamp snapshot image version pointer_tmp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
snapshot="${INSTALL_DIR}/data/backups/cli-rollback-${timestamp}"
image="$(current_image_id || true)"
[[ -n "${image}" ]] || { warn "无法确定当前运行镜像。"; return 1; }
docker image inspect "${image}" >/dev/null 2>&1 || { warn "当前镜像不存在:${image}"; return 1; }
install -d -m 0700 "${snapshot}"
cp -p "${INSTALL_DIR}/docker-compose.yml" "${snapshot}/docker-compose.yml" || return 1
cp -p "${INSTALL_DIR}/.env" "${snapshot}/.env" || return 1
if [[ -f "${INSTALL_DIR}/.env.example" ]]; then
cp -p "${INSTALL_DIR}/.env.example" "${snapshot}/.env.example" || return 1
else
: > "${snapshot}/env-example.absent"
fi
if [[ -f "${CLI_PATH}" ]]; then
cp -p "${CLI_PATH}" "${snapshot}/newszxcn-email" || return 1
else
: > "${snapshot}/installer.absent"
fi
if [[ -f "${NGINX_CONFIG}" ]]; then
cp -p "${NGINX_CONFIG}" "${snapshot}/nginx.conf" || return 1
else
: > "${snapshot}/nginx.absent"
fi
if [[ -d "${CERT_DIR}" ]]; then
cp -a "${CERT_DIR}" "${snapshot}/certs" || return 1
else
: > "${snapshot}/certs.absent"
fi
backup_database "${snapshot}/database.db" "${image}" || return 1
version="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${image}" 2>/dev/null || true)"
printf '%s\n' "${image}" > "${snapshot}/image"
printf '%s\n' "${version:-unknown}" > "${snapshot}/version"
cat > "${snapshot}/rollback-manifest.json" <<EOF
{
"image": "${image}",
"database_backup": "${snapshot}/database.db",
"compose_backup": "${snapshot}/docker-compose.yml",
"installer_backup": "${snapshot}/newszxcn-email",
"version": "${version:-unknown}",
"created_at": "${timestamp}"
}
EOF
find "${snapshot}" -maxdepth 1 -type f -exec chmod 0600 {} +
pointer_tmp="${ROLLBACK_POINTER}.new"
printf '%s\n' "${snapshot}" > "${pointer_tmp}"
mv "${pointer_tmp}" "${ROLLBACK_POINTER}"
printf '%s\n' "${image}" > "${ROLLBACK_FILE}"
log "更新回滚快照已创建:${snapshot}"
}
restore_update_snapshot() {
local snapshot="${1:-}" restore_database="${2:-true}" image
if [[ -z "${snapshot}" && -f "${ROLLBACK_POINTER}" ]]; then
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
fi
[[ -d "${snapshot}" && -s "${snapshot}/database.db" && -s "${snapshot}/image" && -s "${snapshot}/docker-compose.yml" && -s "${snapshot}/.env" ]] \
|| { warn "回滚快照不完整。"; return 1; }
if ! {
[[ -f "${snapshot}/.env.example" || -f "${snapshot}/env-example.absent" ]] \
&& [[ -f "${snapshot}/newszxcn-email" || -f "${snapshot}/installer.absent" ]] \
&& [[ -f "${snapshot}/nginx.conf" || -f "${snapshot}/nginx.absent" ]] \
&& [[ -d "${snapshot}/certs" || -f "${snapshot}/certs.absent" ]]
}; then
warn "回滚快照缺少文件状态标记。"
return 1
fi
image="$(tr -d '\r\n' < "${snapshot}/image")"
docker image inspect "${image}" >/dev/null 2>&1 || { warn "回滚镜像已不存在:${image}"; return 1; }
sqlite_integrity_check "${snapshot}/database.db" "${image}" || { warn "回滚数据库完整性检查未通过。"; return 1; }
log "正在恢复更新前快照:${snapshot}"
compose down --remove-orphans >/dev/null 2>&1 || true
install -m 0644 "${snapshot}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" || return 1
install -m 0600 "${snapshot}/.env" "${INSTALL_DIR}/.env" || return 1
if [[ -f "${snapshot}/.env.example" ]]; then
install -m 0644 "${snapshot}/.env.example" "${INSTALL_DIR}/.env.example" || return 1
elif [[ -f "${snapshot}/env-example.absent" ]]; then
rm -f "${INSTALL_DIR}/.env.example"
fi
if [[ -f "${snapshot}/newszxcn-email" ]]; then
install -m 0755 "${snapshot}/newszxcn-email" "${CLI_PATH}" || return 1
elif [[ -f "${snapshot}/installer.absent" ]]; then
rm -f "${CLI_PATH}"
fi
if [[ "${restore_database}" == "true" ]]; then
rm -f "${INSTALL_DIR}/data/lanqin.db-wal" "${INSTALL_DIR}/data/lanqin.db-shm"
install -m 0600 "${snapshot}/database.db" "${INSTALL_DIR}/data/lanqin.db" || return 1
fi
if [[ -d "${snapshot}/certs" ]]; then
rm -rf "${CERT_DIR}"
cp -a "${snapshot}/certs" "${CERT_DIR}" || return 1
elif [[ -f "${snapshot}/certs.absent" ]]; then
rm -rf "${CERT_DIR}"
fi
if [[ -f "${snapshot}/nginx.conf" ]]; then
install -m 0644 "${snapshot}/nginx.conf" "${NGINX_CONFIG}" || return 1
elif [[ -f "${snapshot}/nginx.absent" ]]; then
rm -f "${NGINX_CONFIG}"
fi
printf '%s\n' "${image}" > "${RUNTIME_IMAGE_PIN}"
chmod 0600 "${RUNTIME_IMAGE_PIN}"
compose up -d --remove-orphans --force-recreate || return 1
reload_nginx || return 1
wait_for_health 90 || return 1
ensure_cli_alias
} }
do_repair_install() { do_repair_install() {
refresh_assets
ensure_update_token
configure_runtime_bindings
ensure_docker ensure_docker
backup_database create_update_snapshot || fail "修复前备份失败,未修改现有安装。"
remember_current_image stage_assets
configure_firewall clear_runtime_image_pin
prepare_directories if ! apply_staged_assets || ! ensure_update_token || ! configure_runtime_bindings; then
restore_update_snapshot "" false || true
fail "修复准备失败,已恢复原安装。"
fi
if ! (configure_firewall && prepare_directories); then
restore_update_snapshot "" false || true
fail "修复环境准备失败,已恢复原安装。"
fi
log "正在拉取并修复 NewSzxcn Email 服务..." log "正在拉取并修复 NewSzxcn Email 服务..."
compose pull if ! compose pull; then
restore_update_snapshot "" false || true
fail "修复镜像拉取失败,已恢复原安装。"
fi
log "正在启动服务..." log "正在启动服务..."
if ! compose up -d --remove-orphans; then if ! compose up -d --remove-orphans; then
warn "修复后容器启动失败,正在自动回滚。" warn "修复后容器启动失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已回滚到原镜像。" fail "修复失败,已恢复到修复前版本。"
fi fi
if ! wait_for_health 90; then if ! wait_for_health 90; then
warn "修复后健康检查失败,正在自动回滚。" warn "修复后健康检查失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "修复失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "修复失败,已回滚到原镜像。" fail "修复失败,已恢复到修复前版本。"
fi fi
configure_web_mode if ! (configure_web_mode); then
restore_update_snapshot || fail "Web 配置失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "Web 配置失败,已恢复到修复前版本。"
fi
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
} }
do_install() { do_install() {
@@ -629,54 +920,65 @@ do_install() {
compose up -d --remove-orphans compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。" wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode configure_web_mode
generate_guide >/dev/null || warn "安装成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)" success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。" warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
warn "输入 ns 可打开管理菜单;输入 newszxcn-email guide 可查看邮箱指南。"
} }
do_update() { do_update() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。" [[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
ensure_docker ensure_docker
refresh_assets create_update_snapshot || fail "更新前备份失败,未修改现有安装。"
ensure_update_token stage_assets
backup_database clear_runtime_image_pin
remember_current_image if ! apply_staged_assets || ! ensure_update_token; then
restore_update_snapshot "" false || true
fail "更新文件替换失败,已恢复原安装。"
fi
log "正在拉取最新版..." log "正在拉取最新版..."
compose pull if ! compose pull; then
restore_update_snapshot "" false || true
fail "镜像拉取失败,已恢复到更新前版本。"
fi
if ! compose up -d --remove-orphans; then if ! compose up -d --remove-orphans; then
warn "新版本容器启动失败,正在自动回滚。" warn "新版本容器启动失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已回滚到原镜像。" fail "更新失败,已恢复到更新前版本。"
fi fi
if ! wait_for_health 90; then if ! wait_for_health 90; then
warn "新版本健康检查失败,正在自动回滚。" warn "新版本健康检查失败,正在自动回滚。"
do_rollback restore_update_snapshot || fail "更新失败,且自动恢复未完成,请使用回滚快照手动恢复。"
fail "更新失败,已回滚到原镜像。" fail "更新失败,已恢复到更新前版本。"
fi fi
ensure_cli_alias
generate_guide >/dev/null || warn "更新成功,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "系统已更新,配置、邮件、证书和数据库均已保留。" success "系统已更新,配置、邮件、证书和数据库均已保留。"
} }
do_rollback() { do_rollback() {
[[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。" [[ -f "${ROLLBACK_POINTER}" ]] || fail "没有可用的完整回滚快照。"
local image local confirm="${LANQIN_ROLLBACK_CONFIRM:-}" image timestamp emergency_backup
image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")" if [[ -z "${confirm}" ]] && has_tty; then
docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}" read -r -p "回滚会用更新前数据库覆盖当前数据库,确认继续吗?[y/N]: " confirm </dev/tty
log "正在回滚到 ${image}..." fi
LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email [[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消回滚。"; return 0; }
wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。" image="$(current_image_id || true)"
success "已回滚到 ${image}" [[ -n "${image}" ]] || fail "无法确定当前镜像,已取消回滚。"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
emergency_backup="${INSTALL_DIR}/data/backups/pre-rollback-${timestamp}.db"
backup_database "${emergency_backup}" "${image}" || fail "当前数据库备份失败,已取消回滚。"
restore_update_snapshot || fail "完整回滚失败,请检查回滚快照和服务日志。"
log "回滚前数据库已保留:${emergency_backup}"
success "已恢复镜像、数据库、Compose、环境配置、安装脚本和 Nginx 配置。"
} }
reload_services() { reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0 [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
ensure_docker ensure_docker
compose restart lanqin-email >/dev/null compose restart lanqin-email >/dev/null
if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then if [[ -f "${NGINX_CONFIG}" ]]; then
nginx -t >/dev/null reload_nginx || fail "Nginx 配置检查或重载失败。"
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi fi
} }
@@ -694,9 +996,173 @@ do_certificate() {
install_certificate install_certificate
write_nginx_https_config write_nginx_https_config
reload_services reload_services
generate_guide >/dev/null || warn "证书已应用,但邮箱指南生成失败,可稍后执行 newszxcn-email guide 重试。"
success "SSL 证书已安装并应用。" success "SSL 证书已安装并应用。"
} }
generate_guide() {
[[ -f "${INSTALL_DIR}/.env" ]] || return 1
local public_url admin_url hostname admin_username certificate_expiry="未安装" renewal_status="未开启" next_renewal="等待 acme.sh 生成续期计划"
local acme_info="" tmp
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
admin_username="$(env_value LANQIN_ADMIN_USERNAME || true)"
public_url="${public_url:-http://${hostname}}"
admin_url="${public_url%/}/admin"
if [[ -s "${CERT_DIR}/fullchain.pem" ]] && command -v openssl >/dev/null 2>&1; then
certificate_expiry="$(openssl x509 -in "${CERT_DIR}/fullchain.pem" -noout -enddate 2>/dev/null | sed 's/^notAfter=//' || true)"
certificate_expiry="${certificate_expiry:-无法读取}"
fi
if [[ -x /root/.acme.sh/acme.sh ]]; then
acme_info="$(/root/.acme.sh/acme.sh --info --domain "${hostname}" --ecc 2>/dev/null || true)"
next_renewal="$(printf '%s\n' "${acme_info}" | sed -n "s/^Le_NextRenewTimeStr=['\"]*\([^'\"]*\).*/\1/p" | tail -n 1)"
next_renewal="${next_renewal:-等待 acme.sh 生成续期计划}"
if acme_cron_enabled; then
renewal_status="已开启"
else
renewal_status="已安装 acme.sh,但未检测到定时任务"
fi
fi
tmp="$(mktemp)"
cat > "${tmp}" <<EOF
==================================================
NewSzxcn 邮箱指南
==================================================
【安装信息】
邮箱前台:${public_url}
管理后台:${admin_url}
管理员账号:${admin_username:-admin}
管理员密码:仅在安装完成时显示;修改后请使用新密码
SSL 证书:有效期至 ${certificate_expiry}
自动续期:${renewal_status}
预计续期:${next_renewal}
--------------------------------------------------
一、添加邮件域名并配置 DNS
--------------------------------------------------
1. 登录管理后台,进入「域名管理」。
2. 添加邮件域名并保存,然后点击该域名右侧的「DNS」。
3. 在域名服务商处添加系统列出的 MX、SPF、DKIM、DMARC 记录。
4. 返回管理后台点击「检测」,确认所有记录均已通过。
--------------------------------------------------
二、开启账号自助申请邮箱
--------------------------------------------------
1. 进入「管理后台 -> 系统设置 -> 邮件」。
2. 开启「账号自助申请邮箱」,并勾选至少一个开放域名。
3. 用户登录邮箱前台后,可在「设置 -> 邮箱管理」中自行申请邮箱。
--------------------------------------------------
三、开启无人收件
--------------------------------------------------
1. 进入「管理后台 -> 系统设置 -> 邮件」。
2. 开启「无人收件」并保存。
3. 已启用域名下未注册地址收到的邮件,只能由管理员在「未知收件」中查看。
--------------------------------------------------
四、服务器管理与更新
--------------------------------------------------
打开菜单:ns
完整命令:newszxcn-email menu
更新系统:newszxcn-email update
查看状态: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
EOF
install -m 0600 "${tmp}" "${GUIDE_FILE}"
rm -f "${tmp}"
}
do_guide() {
generate_guide || fail "尚未安装,无法生成邮箱指南。"
cat "${GUIDE_FILE}"
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 <<EOF
==================================================
NewSzxcn 管理员登录信息
==================================================
登录地址:${public_url:-未记录}
管理员用户名:${username:-admin}
记录密码:${password:-未记录}
==================================================
EOF
warn "当前密码采用 bcrypt 加密,无法从数据库反向查看。这里显示的是安装或最近一次命令行重置时记录的密码;如果之后在网页修改过密码,该记录可能已经失效。"
}
generate_admin_password_hash() {
local password="$1" hash
hash="$(compose exec -T lanqin-email doveadm pw -s BLF-CRYPT -r 10 -p "${password}" 2>/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() { do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps compose ps
@@ -709,14 +1175,27 @@ do_status() {
do_uninstall() { do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。" [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
local confirm="${LANQIN_UNINSTALL_CONFIRM:-}" remove_renewal="${LANQIN_REMOVE_CERT_RENEWAL:-}" hostname
if [[ -z "${confirm}" ]] && has_tty; then
read -r -p "确认停止并卸载服务吗?邮件和配置将保留。[y/N]: " confirm </dev/tty
fi
[[ "${confirm}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]] || { success "已取消卸载。"; return 0; }
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME || true)"
compose down --remove-orphans compose down --remove-orphans
if [[ -f "${NGINX_CONFIG}" ]]; then if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}" rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then reload_nginx || warn "Nginx 未能重载,请检查并手动重载。"
if command -v systemctl >/dev/null 2>&1; then fi
systemctl reload nginx if [[ -x /root/.acme.sh/acme.sh && -n "${hostname}" ]]; then
if [[ -z "${remove_renewal}" ]] && has_tty; then
read -r -p "是否停止 ${hostname} 的证书自动续期?[Y/n]: " remove_renewal </dev/tty
fi
remove_renewal="${remove_renewal:-y}"
if [[ "${remove_renewal}" =~ ^([Yy]|[Yy][Ee][Ss])$ ]]; then
if /root/.acme.sh/acme.sh --remove --domain "${hostname}" --ecc >/dev/null 2>&1; then
success "已停止 ${hostname} 的证书自动续期。"
else else
nginx -s reload warn "未能移除 ${hostname} 的续期记录,请使用 acme.sh 手动检查。"
fi fi
fi fi
fi fi
@@ -724,28 +1203,74 @@ do_uninstall() {
} }
do_backup_reinstall() { do_backup_reinstall() {
local backup_dir local backup_dir failed_dir old_image
backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)" backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)"
failed_dir="${INSTALL_DIR}.failed-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] && command -v docker >/dev/null 2>&1; then [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "旧安装缺少 docker-compose.yml,请先选择修复现有安装。"
ensure_docker ensure_docker
compose down --remove-orphans old_image="$(current_image_id || true)"
[[ -n "${old_image}" ]] || fail "无法确定旧安装镜像,已取消重新安装。"
printf '%s\n' "${old_image}" > "${INSTALL_DIR}/.reinstall-image"
if [[ -f "${CLI_PATH}" ]]; then
cp -p "${CLI_PATH}" "${INSTALL_DIR}/.reinstall-installer"
fi fi
if [[ -f "${NGINX_CONFIG}" ]]; then
cp -p "${NGINX_CONFIG}" "${INSTALL_DIR}/.reinstall-nginx.conf"
else
: > "${INSTALL_DIR}/.reinstall-nginx.absent"
fi
compose down --remove-orphans
if [[ -f "${NGINX_CONFIG}" ]]; then if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}" rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then if ! reload_nginx; then
if command -v systemctl >/dev/null 2>&1; then install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
systemctl reload nginx reload_nginx || true
else LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
nginx -s reload || fail "Nginx 配置已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}"
fi wait_for_health 90 || fail "Nginx 配置已恢复,但旧服务健康检查失败,请查看日志。"
fail "Nginx 重载失败,已恢复旧配置并取消重新安装。"
fi fi
fi fi
mv "${INSTALL_DIR}" "${backup_dir}" if ! mv "${INSTALL_DIR}" "${backup_dir}"; then
if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
fi
LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate || true
reload_nginx || true
fail "旧安装目录备份失败,已取消重新安装。"
fi
success "旧安装已完整备份到 ${backup_dir}" success "旧安装已完整备份到 ${backup_dir}"
log "现在开始全新安装。" log "现在开始全新安装。"
do_install if (do_install); then
success "重新安装完成;旧安装备份保留在 ${backup_dir}"
return 0
fi
warn "重新安装失败,正在自动恢复旧安装。"
if [[ -d "${INSTALL_DIR}" ]]; then
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]]; then
compose down --remove-orphans >/dev/null 2>&1 || true
fi
mv "${INSTALL_DIR}" "${failed_dir}"
fi
mv "${backup_dir}" "${INSTALL_DIR}" || fail "无法恢复旧安装目录,备份仍位于 ${backup_dir}"
old_image="$(tr -d '\r\n' < "${INSTALL_DIR}/.reinstall-image")"
if [[ -f "${INSTALL_DIR}/.reinstall-installer" ]]; then
install -m 0755 "${INSTALL_DIR}/.reinstall-installer" "${CLI_PATH}"
fi
if [[ -f "${INSTALL_DIR}/.reinstall-nginx.conf" ]]; then
install -m 0644 "${INSTALL_DIR}/.reinstall-nginx.conf" "${NGINX_CONFIG}"
elif [[ -f "${INSTALL_DIR}/.reinstall-nginx.absent" ]]; then
rm -f "${NGINX_CONFIG}"
fi
LANQIN_IMAGE="${old_image}" compose up -d --remove-orphans --force-recreate \
|| fail "旧安装目录已恢复,但旧容器启动失败,请检查 ${INSTALL_DIR}"
reload_nginx || fail "旧安装目录和容器已恢复,但 Nginx 重载失败,请手动检查。"
wait_for_health 90 || fail "旧安装已恢复,但健康检查失败,请查看日志。"
ensure_cli_alias
fail "重新安装失败,旧安装已自动恢复。失败的新安装保存在 ${failed_dir}"
} }
do_menu() { do_menu() {
@@ -766,19 +1291,22 @@ do_menu() {
prompt_text ' 状态:未安装\n' prompt_text ' 状态:未安装\n'
fi fi
prompt_text '--------------------------------------------------\n' prompt_text '--------------------------------------------------\n'
prompt_text ' 1. 安装 / 重新安装(旧数据自动备份\n' prompt_text ' 1. 安装 / 重新安装(完整备份,失败自动恢复\n'
prompt_text ' 2. 更新系统(数据库自动备份)\n' prompt_text ' 2. 更新系统(数据库备份,失败自动回滚\n'
prompt_text ' 3. 修复现有安装\n' prompt_text ' 3. 检查并修复现有安装\n'
prompt_text ' 4. 查看运行状态\n' prompt_text ' 4. 查看运行状态\n'
prompt_text ' 5. 重启服务\n' prompt_text ' 5. 重启服务\n'
prompt_text ' 6. 查看实时日志\n' prompt_text ' 6. 查看实时日志\n'
prompt_text ' 7. 申请或续期 SSL 证书\n' prompt_text ' 7. 申请、检查或续期 SSL 证书\n'
prompt_text ' 8. 回滚上个命令行版本\n' prompt_text ' 8. 回滚到上次更新前版本\n'
prompt_text ' 9. 卸载服务(保留数据)\n' prompt_text ' 9. NewSzxcn 邮箱指南\n'
prompt_text ' 10. 查看管理员登录信息\n'
prompt_text ' 11. 重置管理员统一登录密码\n'
prompt_text ' 12. 卸载服务(保留数据)\n'
prompt_text ' 0. 退出\n' prompt_text ' 0. 退出\n'
prompt_text '==================================================\n' prompt_text '==================================================\n'
choice="$(prompt_menu_choice "${default_choice}")" choice="$(prompt_menu_choice "${default_choice}" "12")"
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
fail "尚未安装,请先选择 1。" fail "尚未安装,请先选择 1。"
fi fi
@@ -793,7 +1321,10 @@ do_menu() {
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;; 6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
7) do_certificate ;; 7) do_certificate ;;
8) ensure_docker; do_rollback ;; 8) ensure_docker; do_rollback ;;
9) ensure_docker; do_uninstall ;; 9) do_guide ;;
10) do_show_admin_credentials ;;
11) do_reset_admin_password ;;
12) ensure_docker; do_uninstall ;;
esac esac
} }
@@ -804,6 +1335,10 @@ if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
exit 0 exit 0
fi fi
if [[ "${EUID}" -eq 0 ]]; then
ensure_cli_alias
fi
case "${COMMAND}" in case "${COMMAND}" in
help|-h|--help) usage ;; help|-h|--help) usage ;;
menu) require_root; require_curl; do_menu ;; menu) require_root; require_curl; do_menu ;;
@@ -815,6 +1350,9 @@ case "${COMMAND}" in
reload) require_root; require_curl; reload_services ;; reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;; certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;; 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 ;; uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;; *) usage; fail "未知命令:${COMMAND}" ;;
esac esac
+387 -11
View File
@@ -36,6 +36,11 @@ test_password_validation() {
if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then if (LANQIN_ADMIN_PASSWORD="#abc123" prompt_admin_password >/dev/null 2>&1); then
fail_test "password beginning with an env-file comment marker accepted" fail_test "password beginning with an env-file comment marker accepted"
fi 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() { test_install_configuration() {
@@ -114,38 +119,409 @@ test_menu_choice() {
assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action" assert_eq "0" "$(prompt_menu_choice 1)" "menu exit action"
export LANQIN_MENU_ACTION=1 export LANQIN_MENU_ACTION=1
assert_eq "1" "$(prompt_menu_choice 2)" "menu install action" assert_eq "1" "$(prompt_menu_choice 2)" "menu install action"
export LANQIN_MENU_ACTION=9 export LANQIN_MENU_ACTION=12
assert_eq "9" "$(prompt_menu_choice 1)" "menu uninstall action" 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 unset LANQIN_MENU_ACTION
} }
test_backup_reinstall_preserves_existing_directory() ( test_admin_credentials() (
local temp_dir backup_dir 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)"
INSTALL_DIR="${temp_dir}/install"
mkdir -p "${INSTALL_DIR}/data/backups"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (id INTEGER PRIMARY KEY, value TEXT); INSERT INTO test_items(value) VALUES ("saved");'
compose() { return 0; }
destination="${INSTALL_DIR}/data/backups/offline.db"
backup_database "${destination}" "unused-image"
[[ -s "${destination}" ]] || fail_test "offline database backup missing"
assert_eq "saved" "$(sqlite3 "${destination}" 'SELECT value FROM test_items LIMIT 1;')" "offline database content"
)
test_guide_generation() (
local temp_dir
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
GUIDE_FILE="${temp_dir}/guide.txt"
mkdir -p "${CERT_DIR}"
cp "${ROOT_DIR}/deploy/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
set_env LANQIN_PUBLIC_BASE_URL "https://mail.example.com"
set_env LANQIN_ADMIN_USERNAME "admin"
generate_guide
grep -Fq '邮箱前台:https://mail.example.com' "${GUIDE_FILE}" || fail_test "guide frontend URL missing"
grep -Fq '管理后台:https://mail.example.com/admin' "${GUIDE_FILE}" || fail_test "guide admin URL missing"
grep -Fq '管理员密码:仅在安装完成时显示' "${GUIDE_FILE}" || fail_test "guide password safety text missing"
[[ "$(stat -c '%a' "${GUIDE_FILE}" 2>/dev/null || stat -f '%Lp' "${GUIDE_FILE}")" == "600" ]] || fail_test "guide permissions are not 600"
)
test_acme_cron_detection() (
crontab() {
printf '49 0,6,12,18 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null\n'
}
acme_cron_enabled || fail_test "quoted acme.sh Cron entry was not detected"
)
test_cli_alias_safety() (
local temp_dir
temp_dir="$(mktemp -d)"
CLI_PATH="${temp_dir}/newszxcn-email"
CLI_ALIAS_PATH="${temp_dir}/ns"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_cli_alias
[[ -L "${CLI_ALIAS_PATH}" ]] || fail_test "ns alias was not created"
assert_eq "${CLI_PATH}" "$(readlink "${CLI_ALIAS_PATH}")" "ns alias target"
rm -f "${CLI_ALIAS_PATH}"
printf 'occupied\n' > "${CLI_ALIAS_PATH}"
ensure_cli_alias
grep -Fq 'occupied' "${CLI_ALIAS_PATH}" || fail_test "existing ns command was overwritten"
)
test_compose_runtime_image_pin() (
local temp_dir calls
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
calls="${temp_dir}/docker-calls"
mkdir -p "${INSTALL_DIR}"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'sha256:rollback-image\n' > "${RUNTIME_IMAGE_PIN}"
docker() {
printf '%s|%s\n' "${LANQIN_IMAGE:-}" "$*" >> "${calls}"
}
compose ps
grep -Fq 'sha256:rollback-image|compose ' "${calls}" || fail_test "rollback image pin was not applied to Compose"
clear_runtime_image_pin
compose ps
[[ "$(tail -n 1 "${calls}" | cut -d '|' -f 1)" == "" ]] || fail_test "cleared image pin still affected Compose"
)
test_update_snapshot_restore() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups" "${CERT_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\nOLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf 'old-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("before-update");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() {
if [[ "$*" == *'org.opencontainers.image.version'* ]]; then
printf '1.2.4\n'
fi
return 0
}
compose() {
if [[ "${1:-}" == "up" ]]; then
grep -Fq 'sha256:old-image' "${RUNTIME_IMAGE_PIN}" || fail_test "restore started without image pin"
fi
return 0
}
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -s "${snapshot}/rollback-manifest.json" ]] || fail_test "rollback manifest missing"
printf 'new-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'NEW_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho new-installer\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'DELETE FROM test_items; INSERT INTO test_items VALUES ("after-update");'
restore_update_snapshot "${snapshot}"
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "Compose file was not restored"
grep -Fq 'OLD_ENV=yes' "${INSTALL_DIR}/.env" || fail_test "environment file was not restored"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "environment example was not restored"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "installer was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored"
grep -Fq 'old-certificate' "${CERT_DIR}/fullchain.pem" || fail_test "certificate was not restored"
assert_eq "before-update" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT value FROM test_items;')" "restored database content"
assert_eq "sha256:old-image" "$(tr -d '\r\n' < "${RUNTIME_IMAGE_PIN}")" "restored runtime image pin"
)
test_snapshot_restores_absent_optional_files() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE test_items (value TEXT); INSERT INTO test_items VALUES ("saved");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
[[ -f "${snapshot}/env-example.absent" ]] || fail_test "missing env example marker"
[[ -f "${snapshot}/installer.absent" ]] || fail_test "missing installer marker"
[[ -f "${snapshot}/nginx.absent" ]] || fail_test "missing Nginx marker"
[[ -f "${snapshot}/certs.absent" ]] || fail_test "missing certificate marker"
mkdir -p "${CERT_DIR}"
printf 'new-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\n' > "${CLI_PATH}"
printf 'new-nginx\n' > "${NGINX_CONFIG}"
printf 'new-certificate\n' > "${CERT_DIR}/fullchain.pem"
restore_update_snapshot "${snapshot}"
[[ ! -e "${INSTALL_DIR}/.env.example" ]] || fail_test "new env example survived rollback"
[[ ! -e "${CLI_PATH}" ]] || fail_test "new installer survived rollback"
[[ ! -e "${NGINX_CONFIG}" ]] || fail_test "new Nginx configuration survived rollback"
[[ ! -e "${CERT_DIR}" ]] || fail_test "new certificate directory survived rollback"
)
test_pre_start_restore_preserves_current_database() (
local temp_dir snapshot
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/install"
CERT_DIR="${INSTALL_DIR}/certs"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
ROLLBACK_POINTER="${INSTALL_DIR}/.rollback-manifest"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${INSTALL_DIR}/data/backups"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'LANQIN_IMAGE=ghcr.io/example/mail:latest\n' > "${INSTALL_DIR}/.env"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'CREATE TABLE received_mail (subject TEXT); INSERT INTO received_mail VALUES ("before-snapshot");'
current_image_id() { printf 'sha256:old-image\n'; }
docker() { return 0; }
compose() { return 0; }
reload_nginx() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
create_update_snapshot
snapshot="$(tr -d '\r\n' < "${ROLLBACK_POINTER}")"
sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'INSERT INTO received_mail VALUES ("received-during-pull");'
restore_update_snapshot "${snapshot}" false
assert_eq "2" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT COUNT(*) FROM received_mail;')" "database preserved before new container start"
assert_eq "received-during-pull" "$(sqlite3 "${INSTALL_DIR}/data/lanqin.db" 'SELECT subject FROM received_mail ORDER BY rowid DESC LIMIT 1;')" "mail received during pull"
)
test_failed_asset_validation_preserves_production() (
local temp_dir source_dir
temp_dir="$(mktemp -d)"
source_dir="${temp_dir}/source"
INSTALL_DIR="${temp_dir}/install"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
RUNTIME_IMAGE_PIN="${INSTALL_DIR}/.rollback-runtime-image"
mkdir -p "${source_dir}/deploy" "${INSTALL_DIR}"
printf 'old-compose\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'OLD_ENV=yes\n' > "${INSTALL_DIR}/.env"
printf 'old-example\n' > "${INSTALL_DIR}/.env.example"
printf '#!/bin/sh\necho old-installer\n' > "${CLI_PATH}"
printf 'sha256:pinned-image\n' > "${RUNTIME_IMAGE_PIN}"
printf 'invalid compose\n' > "${source_dir}/deploy/docker-compose.yml"
cp "${ROOT_DIR}/deploy/.env.example" "${source_dir}/deploy/.env.example"
cp "${ROOT_DIR}/install.sh" "${source_dir}/install.sh"
script_dir() { printf '%s\n' "${source_dir}"; }
docker() { return 1; }
if (stage_assets >/dev/null 2>&1); then
fail_test "invalid Compose file passed staging validation"
fi
grep -Fq 'old-compose' "${INSTALL_DIR}/docker-compose.yml" || fail_test "production Compose changed after failed validation"
grep -Fq 'old-example' "${INSTALL_DIR}/.env.example" || fail_test "production env example changed after failed validation"
grep -Fq 'old-installer' "${CLI_PATH}" || fail_test "production installer changed after failed validation"
grep -Fq 'sha256:pinned-image' "${RUNTIME_IMAGE_PIN}" || fail_test "runtime image pin changed after failed validation"
)
test_backup_reinstall_restores_on_failure() (
local temp_dir failed_dir
temp_dir="$(mktemp -d)" temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email" INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf" NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
mkdir -p "${INSTALL_DIR}" mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker" printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '#!/bin/sh\nexit 0\n' > "${CLI_PATH}"
chmod 0755 "${CLI_PATH}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { return 0; }
nginx() { return 0; }
systemctl() { return 0; }
wait_for_health() { return 0; }
ensure_cli_alias() { return 0; }
do_install() { do_install() {
[[ ! -e "${INSTALL_DIR}" ]] || fail_test "fresh install started before old directory was moved" mkdir -p "${INSTALL_DIR}"
printf 'failed-install\n' > "${INSTALL_DIR}/failed-marker"
return 1
} }
do_backup_reinstall if (do_backup_reinstall); then
backup_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.backup-*' -print -quit)" fail_test "failed reinstall unexpectedly succeeded"
[[ -n "${backup_dir}" ]] || fail_test "existing install backup directory missing" fi
grep -Fq 'existing-data' "${backup_dir}/marker" || fail_test "existing install data was not preserved" grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install directory was not restored"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "old Nginx configuration was not restored"
failed_dir="$(find "${temp_dir}" -maxdepth 1 -type d -name 'newszxcn-email.failed-*' -print -quit)"
[[ -n "${failed_dir}" ]] || fail_test "failed reinstall directory was not preserved"
)
test_backup_reinstall_recovers_from_nginx_reload_failure() (
local temp_dir compose_calls reload_count_file
temp_dir="$(mktemp -d)"
INSTALL_DIR="${temp_dir}/newszxcn-email"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
CLI_PATH="${temp_dir}/newszxcn-email-cli"
CLI_ALIAS_PATH="${temp_dir}/ns"
compose_calls="${temp_dir}/compose-calls"
reload_count_file="${temp_dir}/reload-count"
mkdir -p "${INSTALL_DIR}"
printf 'existing-data\n' > "${INSTALL_DIR}/marker"
printf 'services: {}\n' > "${INSTALL_DIR}/docker-compose.yml"
printf 'old-nginx\n' > "${NGINX_CONFIG}"
printf '0\n' > "${reload_count_file}"
ensure_docker() { return 0; }
current_image_id() { printf 'sha256:old-image\n'; }
compose() { printf '%s\n' "$*" >> "${compose_calls}"; return 0; }
reload_nginx() {
local count
count="$(cat "${reload_count_file}")"
printf '%s\n' "$((count + 1))" > "${reload_count_file}"
[[ "${count}" -gt 0 ]]
}
wait_for_health() { return 0; }
do_install() { fail_test "fresh install started after Nginx reload failure"; }
if (do_backup_reinstall >/dev/null 2>&1); then
fail_test "reinstall continued after Nginx reload failure"
fi
grep -Fq 'existing-data' "${INSTALL_DIR}/marker" || fail_test "old install changed after Nginx reload failure"
grep -Fq 'old-nginx' "${NGINX_CONFIG}" || fail_test "Nginx configuration was not restored after reload failure"
grep -Fq 'up -d --remove-orphans --force-recreate' "${compose_calls}" || fail_test "old containers were not restarted after Nginx reload failure"
) )
test_hostname_validation test_hostname_validation
test_password_validation test_password_validation
test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false" test_install_configuration 1 1 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false" test_install_configuration 2 2 "127.0.0.1:8088" "https://mail.example.com" "false"
test_install_configuration 3 3 "80" "http://mail.example.com" "true"
test_nginx_configuration test_nginx_configuration
test_compose_configuration test_compose_configuration
test_legacy_configuration_is_preserved test_legacy_configuration_is_preserved
test_menu_choice test_menu_choice
test_backup_reinstall_preserves_existing_directory 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
test_cli_alias_safety
test_compose_runtime_image_pin
test_update_snapshot_restore
test_snapshot_restores_absent_optional_files
test_pre_start_restore_preserves_current_database
test_failed_asset_validation_preserves_production
test_backup_reinstall_restores_on_failure
test_backup_reinstall_recovers_from_nginx_reload_failure
printf 'install.sh tests passed\n' printf 'install.sh tests passed\n'