feat: add guided mail server installer

This commit is contained in:
zxyszx
2026-08-03 22:39:51 +08:00
parent 2f7494e5e6
commit da888234b9
18 changed files with 689 additions and 67 deletions
+8
View File
@@ -40,6 +40,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
+8
View File
@@ -27,6 +27,14 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Check installer
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
bash -n install.sh tests/install_test.sh
shellcheck -x install.sh tests/install_test.sh
bash tests/install_test.sh
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
+5 -1
View File
@@ -23,6 +23,8 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
## Update
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
@@ -39,10 +41,12 @@ Useful commands:
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, configuration, messages, and the database under `/opt/newszxcn-email`.
## Required ports
+16 -4
View File
@@ -29,10 +29,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员用户名和密码
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -67,10 +76,12 @@ sudo newszxcn-email rollback
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 移除容器不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
`uninstall` 移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
@@ -104,10 +115,11 @@ sudo newszxcn-email uninstall
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|-- data/ # SQLite、附件和更新前备份
|-- mail/ # Maildir 邮件原文
`-- dkim/ # DKIM 私钥
|-- dkim/ # DKIM 私钥
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+16 -4
View File
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
```
已使用 `root` 登录时,也可以使用:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
```
脚本会自动完成:
- 安装或检查 Docker Engine 与 Docker Compose v2
- 询问邮件域名、访问地址、管理员邮箱和密码
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
- 创建 `/opt/newszxcn-email` 持久化目录
- 拉取 GHCR 镜像并启动邮件服务
- 生成后台在线更新所需的内部鉴权令牌
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
```bash
sudo newszxcn-email status
sudo newszxcn-email logs
sudo newszxcn-email restart
sudo newszxcn-email certificate
sudo newszxcn-email uninstall
```
`uninstall` 移除容器不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
`uninstall` 移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
## DNS 与端口
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
├── docker-compose.yml # 邮箱主服务与内部更新服务
├── data/ # SQLite、附件和更新前备份
├── mail/ # Maildir 邮件原文
── dkim/ # DKIM 私钥
── dkim/ # DKIM 私钥
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
```
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``.env`
升级和重建容器不会删除这些目录。备份时应同时保存 `data``mail``dkim``certs``.env`
## 手动部署
+6 -6
View File
@@ -143,8 +143,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if role == "admin" {
mailboxLimitOverride = nil
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -344,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
@@ -562,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
role := req.Role
+4 -4
View File
@@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("邮箱地址无效"))
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("密码至少需要 6 个字符"))
return
}
displayName := strings.TrimSpace(req.DisplayName)
@@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if len(req.NewPassword) < 8 {
badRequest(w, errors.New("新密码至少需要 8 个字符"))
if !hasMinimumPasswordLength(req.NewPassword) {
badRequest(w, errors.New("新密码至少需要 6 个字符"))
return
}
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
+4 -4
View File
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
domain, err := a.domainByID(r.Context(), req.DomainID)
@@ -371,8 +371,8 @@ func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.R
badRequest(w, err)
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
if !hasMinimumPasswordLength(req.Password) {
badRequest(w, errors.New("password must be at least 6 characters"))
return
}
var userID string
+23
View File
@@ -0,0 +1,23 @@
package app
import "testing"
func TestHasMinimumPasswordLength(t *testing.T) {
tests := []struct {
name string
password string
want bool
}{
{name: "five ASCII characters", password: "abc12", want: false},
{name: "six ASCII characters", password: "abc123", want: true},
{name: "six Unicode characters", password: "密码测试六位", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
}
})
}
}
+7
View File
@@ -14,12 +14,19 @@ import (
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/microcosm-cc/bluemonday"
)
type HTMLPolicy struct{ policy *bluemonday.Policy }
const minimumPasswordLength = 6
func hasMinimumPasswordLength(password string) bool {
return utf8.RuneCountInString(password) >= minimumPasswordLength
}
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowElements("html", "head", "body", "center", "font")
+3 -3
View File
@@ -1858,7 +1858,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
<Field name="displayName" label="显示名称" placeholder="账号名称" />
<Field name="password" label="初始密码" type="password" minLength={8} />
<Field name="password" label="初始密码" type="password" minLength={6} />
<div className="grid grid-cols-2 gap-3">
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
@@ -1928,7 +1928,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user:
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={6} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
}
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
@@ -1948,7 +1948,7 @@ function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: Adm
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" /></Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle></DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 6 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}></Button></DialogFooter></form></DialogContent></Dialog>
}
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
+2 -2
View File
@@ -905,8 +905,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
<SettingsCard title="密码管理">
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前密码"><PasswordInput name="currentPassword" required /></Field>
<Field label="新密码"><PasswordInput name="newPassword" minLength={8} required placeholder="输入新密码" /></Field>
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={8} required placeholder="再次输入密码" /></Field>
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
</form>
</SettingsCard>
+2 -2
View File
@@ -113,11 +113,11 @@ export function RegisterPage() {
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"></Label>
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium"></Label>
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
+5 -2
View File
@@ -20,9 +20,12 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
# 手动部署可执行:openssl rand -hex 24
LANQIN_UPDATE_TOKEN=
# 可选端口绑定。使用宿主机反向代理时,可将 HTTP 设为 127.0.0.1:8088
# 一键安装器记录的部署方式。手动部署时可以留空
LANQIN_INSTALL_FIREWALL_MODE=
LANQIN_INSTALL_WEB_MODE=
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
LANQIN_HTTP_BIND=80
LANQIN_HTTPS_BIND=443
LANQIN_SMTP_BIND=25
LANQIN_SMTPS_BIND=465
LANQIN_SUBMISSION_BIND=587
+4 -3
View File
@@ -19,6 +19,8 @@ sudo newszxcn-email rollback
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
## 最简单部署:单容器镜像版
服务器上不需要源码构建,只要 `docker-compose.yml``.env` 即可。
@@ -158,10 +160,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
```dotenv
LANQIN_HTTP_BIND=127.0.0.1:8088
LANQIN_HTTPS_BIND=127.0.0.1:8443
```
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`不使用宿主机反向代理时保留默认的 `80``443` 即可
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
@@ -182,7 +183,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
- /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
```
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
+1 -3
View File
@@ -7,7 +7,6 @@ services:
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
ports:
- "${LANQIN_HTTP_BIND:-80}:80"
- "${LANQIN_HTTPS_BIND:-443}:443"
- "${LANQIN_SMTP_BIND:-25}:25"
- "${LANQIN_SMTPS_BIND:-465}:465"
- "${LANQIN_SUBMISSION_BIND:-587}:587"
@@ -17,8 +16,7 @@ services:
- ./data:/data
- ./mail:/var/mail/vhosts
- ./dkim:/var/lib/rspamd/dkim
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
# - /etc/letsencrypt:/etc/letsencrypt:ro
- ./certs:/certs:ro
labels:
com.centurylinklabs.watchtower.enable: "true"
com.centurylinklabs.watchtower.scope: "newszxcn-email"
+454 -29
View File
@@ -6,6 +6,9 @@ RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
COMMAND="${1:-install}"
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
ACME_WEBROOT="/var/www/newszxcn-acme"
CERT_DIR="${INSTALL_DIR}/certs"
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
@@ -22,6 +25,8 @@ NewSzxcn Email 管理命令
update 备份数据库并更新到最新版
status 查看容器与健康状态
logs 持续查看运行日志
restart 重启服务并重载 Nginx
certificate 申请或续期自动模式的 SSL 证书
rollback 回滚到上次命令行更新前的镜像
uninstall 停止并移除容器,保留邮件与配置
EOF
@@ -37,6 +42,19 @@ require_curl() {
command -v curl >/dev/null 2>&1 || fail "系统缺少 curl,请先安装 curl。"
}
install_packages() {
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y "$@"
elif command -v yum >/dev/null 2>&1; then
yum install -y "$@"
else
fail "暂不支持当前系统的软件包管理器,请使用 Ubuntu、Debian、CentOS、Rocky Linux 或 AlmaLinux。"
fi
}
ensure_docker() {
if ! command -v docker >/dev/null 2>&1; then
log "未检测到 Docker,正在安装 Docker Engine..."
@@ -57,10 +75,13 @@ script_dir() {
}
refresh_assets() {
local source_dir
local source_dir local_source="false"
source_dir="$(script_dir || true)"
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
local_source="true"
fi
install -d -m 0755 "${INSTALL_DIR}"
if [[ -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/.env.example" "${INSTALL_DIR}/.env.example"
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
@@ -81,6 +102,16 @@ random_secret() {
fi
}
random_admin_password() {
local value
if command -v openssl >/dev/null 2>&1; then
value="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')"
else
value="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
fi
printf '%.12s' "${value}"
}
set_env() {
local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp
tmp="$(mktemp)"
@@ -102,9 +133,9 @@ env_value() {
prompt_value() {
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
local value="${!variable:-}"
if [[ -z "${value}" && -r /dev/tty ]]; then
if [[ -z "${value}" ]] && has_tty; then
if [[ "${secret}" == "true" ]]; then
read -r -s -p "${prompt}${default_value:+ [自动生成]}: " value </dev/tty
read -r -s -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
printf '\n' >/dev/tty
else
read -r -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
@@ -114,30 +145,124 @@ prompt_value() {
printf '%s' "${value}"
}
prompt_choice() {
local variable="$1" prompt="$2" default_value="$3" value
value="${!variable:-}"
while true; do
if [[ -z "${value}" ]] && has_tty; then
read -r -p "${prompt}" value </dev/tty
fi
value="${value:-${default_value}}"
if [[ "${value}" =~ ^[123]$ ]]; then
printf '%s' "${value}"
return
fi
prompt_text "[提示] 请输入 1、2 或 3。\n"
value=""
has_tty || fail "${variable} 必须设置为 1、2 或 3。"
done
}
has_tty() {
[[ -e /dev/tty ]] && (: </dev/tty) 2>/dev/null
}
prompt_text() {
if has_tty; then
printf '%b' "$1" >/dev/tty
else
printf '%b' "$1" >&2
fi
}
valid_hostname() {
local hostname="$1" label tld
local -a labels
[[ ${#hostname} -le 253 && "${hostname}" == *.* ]] || return 1
IFS='.' read -r -a labels <<<"${hostname}"
for label in "${labels[@]}"; do
[[ ${#label} -ge 1 && ${#label} -le 63 ]] || return 1
[[ "${label}" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]] || return 1
done
tld="${labels[${#labels[@]}-1]}"
[[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]
}
prompt_admin_password() {
local password="${LANQIN_ADMIN_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 "[提示] 已自动生成管理员密码:${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 "[提示] 已自动生成管理员密码:${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"
continue
fi
printf '%s' "${password}"
return
done
}
configure_first_install() {
if [[ -f "${INSTALL_DIR}/.env" ]]; then
return
fi
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
local hostname public_url admin_username admin_password update_token
local firewall_mode hostname admin_username admin_password web_mode public_url update_token
prompt_text '\n防火墙配置 [1]:\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n'
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")"
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
[[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。"
public_url="$(prompt_value LANQIN_PUBLIC_BASE_URL "Webmail 访问地址" "https://${hostname}")"
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "初始管理员用户名" "admin")"
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名格式不正确,需为 2-80 位且不能包含 @。"
admin_password="$(prompt_value LANQIN_ADMIN_PASSWORD "初始管理员密码" "" true)"
if [[ -z "${admin_password}" ]]; then
admin_password="$(random_secret)"
warn "已自动生成管理员密码:${admin_password}"
fi
[[ ${#admin_password} -ge 10 ]] || fail "管理员密码至少需要 10 个字符。"
update_token="$(random_secret)"
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "管理员用户名" "admin")"
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名需为 2-80 位且不能包含 @。"
admin_password="$(prompt_admin_password)"
prompt_text '\nWeb 部署方式 [1]\n1. 自动配置 Nginx + SSL\n2. 宝塔/已有 Nginx 反代\n3. 仅 HTTP 测试\n'
web_mode="$(prompt_choice LANQIN_INSTALL_WEB_MODE "请选择 [1]: " "1")"
if [[ "${web_mode}" == "3" ]]; then
public_url="http://${hostname}"
else
public_url="https://${hostname}"
fi
update_token="$(random_secret)"
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
set_env LANQIN_INSTALL_FIREWALL_MODE "${firewall_mode}"
set_env LANQIN_PUBLIC_HOSTNAME "${hostname}"
set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
set_env LANQIN_ADMIN_USERNAME "${admin_username}"
set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
set_env LANQIN_INSTALL_WEB_MODE "${web_mode}"
set_env LANQIN_UPDATE_TOKEN "${update_token}"
chmod 0600 "${INSTALL_DIR}/.env"
}
@@ -152,10 +277,98 @@ ensure_update_token() {
}
prepare_directories() {
install -d -m 0755 "${INSTALL_DIR}/data" "${INSTALL_DIR}/mail" "${INSTALL_DIR}/dkim"
install -d -m 0755 "${INSTALL_DIR}/data" "${INSTALL_DIR}/mail" "${INSTALL_DIR}/dkim" "${CERT_DIR}"
install -d -m 0700 "${INSTALL_DIR}/data/backups"
}
configure_runtime_bindings() {
local web_mode
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
case "${web_mode}" in
1|2)
set_env LANQIN_HTTP_BIND "127.0.0.1:8088"
set_env LANQIN_ALLOW_INSECURE_HTTP "false"
;;
3)
set_env LANQIN_HTTP_BIND "80"
set_env LANQIN_ALLOW_INSECURE_HTTP "true"
;;
"")
warn "这是旧版安装配置,保留现有 Web 端口和反向代理设置。"
;;
esac
}
detect_ssh_ports() {
local ports=""
if command -v sshd >/dev/null 2>&1; then
ports="$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | sort -nu || true)"
fi
if [[ -z "${ports}" ]] && command -v ss >/dev/null 2>&1; then
ports="$(ss -lntp 2>/dev/null | awk '/sshd/ {sub(/.*:/, "", $4); print $4}' | sort -nu || true)"
fi
printf '%s\n' "${ports:-22}"
}
configure_restricted_firewall() {
local ports=(25 80 443 465 587 993 995) ssh_port
while IFS= read -r ssh_port; do
[[ "${ssh_port}" =~ ^[0-9]+$ ]] && ports+=("${ssh_port}")
done < <(detect_ssh_ports)
if command -v firewall-cmd >/dev/null 2>&1; then
systemctl enable --now firewalld >/dev/null 2>&1 || fail "firewalld 启动失败。"
for ssh_port in "${ports[@]}"; do
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
done
firewall-cmd --reload >/dev/null
success "firewalld 已仅开放 SSH 和邮局必要端口。"
return
fi
if ! command -v ufw >/dev/null 2>&1; then
install_packages ufw
fi
if command -v ufw >/dev/null 2>&1; then
for ssh_port in "${ports[@]}"; do
ufw allow "${ssh_port}/tcp" >/dev/null
done
ufw --force enable >/dev/null
success "UFW 已开放 SSH 和邮局必要端口。"
return
fi
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() {
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
1) configure_restricted_firewall ;;
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
3) configure_open_firewall ;;
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
*) fail "防火墙模式配置无效。" ;;
esac
}
wait_for_health() {
local attempts="${1:-60}" bind port
bind="$(env_value LANQIN_HTTP_BIND || true)"
@@ -170,6 +383,166 @@ wait_for_health() {
return 1
}
ensure_nginx() {
if ! command -v nginx >/dev/null 2>&1; then
log "正在安装宿主机 Nginx..."
install_packages nginx
fi
install -d -m 0755 "$(dirname "${NGINX_CONFIG}")" "${ACME_WEBROOT}/.well-known/acme-challenge"
if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce)" == "Enforcing" ]] && command -v setsebool >/dev/null 2>&1; then
setsebool -P httpd_can_network_connect 1
fi
}
write_nginx_http_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
tmp="$(mktemp)"
cat >"${tmp}" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${hostname};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_WEBROOT};
default_type text/plain;
}
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
client_max_body_size 50m;
}
}
EOF
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
rm -f "${tmp}"
nginx -t || fail "Nginx 配置检查失败,请检查 ${NGINX_CONFIG}"
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now nginx
systemctl reload nginx
else
nginx -s reload 2>/dev/null || nginx
fi
}
write_nginx_https_config() {
local hostname tmp
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
tmp="$(mktemp)"
cat >"${tmp}" <<EOF
server {
listen 80;
listen [::]:80;
server_name ${hostname};
location ^~ /.well-known/acme-challenge/ {
root ${ACME_WEBROOT};
default_type text/plain;
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${hostname};
ssl_certificate ${CERT_DIR}/fullchain.pem;
ssl_certificate_key ${CERT_DIR}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:NewSzxcnSSL:10m;
ssl_session_timeout 1d;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 50m;
}
}
EOF
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
rm -f "${tmp}"
nginx -t || fail "HTTPS 配置检查失败,请检查 ${NGINX_CONFIG}"
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
}
ensure_acme() {
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
log "正在安装官方 acme.sh..."
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
fi
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
}
install_certificate() {
local hostname
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
ensure_acme
log "正在为 ${hostname} 申请或检查 Let's Encrypt 证书..."
if ! /root/.acme.sh/acme.sh --issue \
--server letsencrypt \
--keylength ec-256 \
--domain "${hostname}" \
--webroot "${ACME_WEBROOT}"; then
warn "证书签发命令未创建新证书,将尝试安装已有的有效证书。"
fi
/root/.acme.sh/acme.sh --install-cert \
--ecc \
--domain "${hostname}" \
--fullchain-file "${CERT_DIR}/fullchain.pem" \
--key-file "${CERT_DIR}/privkey.pem" \
--reloadcmd "/usr/local/bin/newszxcn-email reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
chmod 0644 "${CERT_DIR}/fullchain.pem"
chmod 0600 "${CERT_DIR}/privkey.pem"
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
set_env LANQIN_TLS_KEY_FILE "/certs/privkey.pem"
set_env LANQIN_SUBMISSION_ADDR ":587"
set_env LANQIN_SUBMISSION_TLS_ADDR ":465"
}
configure_web_mode() {
local web_mode
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
case "${web_mode}" in
1)
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
compose up -d --remove-orphans --force-recreate lanqin-email
wait_for_health 90 || fail "启用证书后服务未通过健康检查,请执行 newszxcn-email logs。"
;;
2)
warn "请在宝塔或现有 Nginx 中把域名反代到 http://127.0.0.1:8088。"
warn "邮件客户端证书仍需放入 ${CERT_DIR} 并配置 LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE。"
;;
3)
warn "当前为 HTTP 测试模式,不适合正式公网运行。"
;;
"") ;;
*) fail "Web 部署模式配置无效。" ;;
esac
}
backup_database() {
local timestamp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
@@ -190,16 +563,19 @@ remember_current_image() {
}
do_install() {
ensure_docker
refresh_assets
configure_first_install
ensure_update_token
configure_runtime_bindings
ensure_docker
configure_firewall
prepare_directories
log "正在拉取 NewSzxcn Email 镜像..."
compose pull
log "正在启动服务..."
compose up -d --remove-orphans
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
configure_web_mode
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
}
@@ -223,7 +599,7 @@ do_update() {
do_rollback
fail "更新失败,已回滚到原镜像。"
fi
success "系统已更新,配置、邮件和数据库均已保留。"
success "系统已更新,配置、邮件、证书和数据库均已保留。"
}
do_rollback() {
@@ -237,6 +613,37 @@ do_rollback() {
success "已回滚到 ${image}"
}
reload_services() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
ensure_docker
compose restart lanqin-email >/dev/null
if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then
nginx -t >/dev/null
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi
}
do_restart() {
reload_services
wait_for_health 90 || fail "重启后服务未通过健康检查。"
success "邮局服务已重启。"
}
do_certificate() {
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
ensure_nginx
write_nginx_http_config
install_certificate
write_nginx_https_config
reload_services
success "SSL 证书已安装并应用。"
}
do_status() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose ps
@@ -250,18 +657,36 @@ do_status() {
do_uninstall() {
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
compose down --remove-orphans
success "容器已移除,${INSTALL_DIR} 中的配置、邮件和数据库仍然保留。"
if [[ -f "${NGINX_CONFIG}" ]]; then
rm -f "${NGINX_CONFIG}"
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
if command -v systemctl >/dev/null 2>&1; then
systemctl reload nginx
else
nginx -s reload
fi
fi
fi
success "容器和自动生成的 Nginx 配置已移除,${INSTALL_DIR} 中的邮件、证书、配置和数据库仍然保留。"
}
require_root
require_curl
if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
return 0
fi
exit 0
fi
case "${COMMAND}" in
install) do_install ;;
update) do_update ;;
status) ensure_docker; do_status ;;
logs) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
rollback) ensure_docker; do_rollback ;;
uninstall) ensure_docker; do_uninstall ;;
help|-h|--help) usage ;;
install) require_root; require_curl; do_install ;;
update) require_root; require_curl; do_update ;;
status) require_root; require_curl; ensure_docker; do_status ;;
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
restart) require_root; require_curl; do_restart ;;
reload) require_root; require_curl; reload_services ;;
certificate) require_root; require_curl; do_certificate ;;
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
*) usage; fail "未知命令:${COMMAND}" ;;
esac
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export LANQIN_SOURCE_ONLY=true
# shellcheck source=install.sh
source "${ROOT_DIR}/install.sh"
fail_test() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
assert_eq() {
local want="$1" got="$2" label="$3"
[[ "${got}" == "${want}" ]] || fail_test "${label}: got '${got}', want '${want}'"
}
test_hostname_validation() {
valid_hostname "mail.example.com" || fail_test "valid hostname rejected"
valid_hostname "mx-1.example.co.uk" || fail_test "valid multi-label hostname rejected"
! valid_hostname "mail_example.com" || fail_test "hostname with underscore accepted"
! valid_hostname "localhost" || fail_test "single-label hostname accepted"
! valid_hostname "-mail.example.com" || fail_test "hostname with leading hyphen accepted"
}
test_password_validation() {
LANQIN_ADMIN_PASSWORD="abc123"
assert_eq "abc123" "$(prompt_admin_password)" "six-character password"
if (LANQIN_ADMIN_PASSWORD="abc12" prompt_admin_password >/dev/null 2>&1); then
fail_test "five-character password accepted"
fi
if (LANQIN_ADMIN_PASSWORD="abc\$123" prompt_admin_password >/dev/null 2>&1); then
fail_test "unsafe env-file password accepted"
fi
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
}
test_install_configuration() {
local firewall_mode="$1" web_mode="$2" want_bind="$3" want_url="$4" want_insecure="$5"
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env.example"
export INSTALL_DIR="${temp_dir}"
export LANQIN_INSTALL_FIREWALL_MODE="${firewall_mode}"
export LANQIN_PUBLIC_HOSTNAME="mail.example.com"
export LANQIN_ADMIN_USERNAME="admin"
export LANQIN_ADMIN_PASSWORD="abc123"
export LANQIN_INSTALL_WEB_MODE="${web_mode}"
configure_first_install
configure_runtime_bindings
assert_eq "${firewall_mode}" "$(env_value LANQIN_INSTALL_FIREWALL_MODE)" "firewall mode"
assert_eq "${web_mode}" "$(env_value LANQIN_INSTALL_WEB_MODE)" "web mode"
assert_eq "${want_bind}" "$(env_value LANQIN_HTTP_BIND)" "HTTP bind"
assert_eq "${want_url}" "$(env_value LANQIN_PUBLIC_BASE_URL)" "public URL"
assert_eq "${want_insecure}" "$(env_value LANQIN_ALLOW_INSECURE_HTTP)" "insecure HTTP flag"
assert_eq "abc123" "$(env_value LANQIN_ADMIN_PASSWORD)" "administrator password"
}
test_nginx_configuration() {
local temp_dir old_path
temp_dir="$(mktemp -d)"
old_path="${PATH}"
mkdir -p "${temp_dir}/bin" "${temp_dir}/install" "${temp_dir}/certs" "${temp_dir}/acme"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/nginx"
printf '#!/bin/sh\nexit 0\n' >"${temp_dir}/bin/systemctl"
chmod 0755 "${temp_dir}/bin/nginx" "${temp_dir}/bin/systemctl"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/install/.env"
export PATH="${temp_dir}/bin:${PATH}"
INSTALL_DIR="${temp_dir}/install"
NGINX_CONFIG="${temp_dir}/newszxcn-email.conf"
ACME_WEBROOT="${temp_dir}/acme"
CERT_DIR="${temp_dir}/certs"
set_env LANQIN_PUBLIC_HOSTNAME "mail.example.com"
write_nginx_http_config
grep -Fq 'proxy_pass http://127.0.0.1:8088;' "${NGINX_CONFIG}" || fail_test "HTTP proxy target missing"
grep -Fq 'root '"${ACME_WEBROOT}"';' "${NGINX_CONFIG}" || fail_test "ACME webroot missing"
write_nginx_https_config
grep -Fq 'listen 443 ssl http2;' "${NGINX_CONFIG}" || fail_test "HTTPS listener missing"
# shellcheck disable=SC2016
grep -Fq 'return 301 https://$host$request_uri;' "${NGINX_CONFIG}" || fail_test "HTTPS redirect missing"
grep -Fq "ssl_certificate ${CERT_DIR}/fullchain.pem;" "${NGINX_CONFIG}" || fail_test "certificate path missing"
PATH="${old_path}"
}
test_compose_configuration() {
# shellcheck disable=SC2016
grep -Fq '${LANQIN_HTTP_BIND:-80}:80' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "HTTP port mapping missing"
! grep -Fq 'LANQIN_HTTPS_BIND' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "dead container HTTPS mapping remains"
grep -Fq './certs:/certs:ro' "${ROOT_DIR}/deploy/docker-compose.yml" || fail_test "certificate mount missing"
}
test_legacy_configuration_is_preserved() {
local temp_dir
temp_dir="$(mktemp -d)"
cp "${ROOT_DIR}/deploy/.env.example" "${temp_dir}/.env"
export INSTALL_DIR="${temp_dir}"
set_env LANQIN_INSTALL_WEB_MODE ""
set_env LANQIN_HTTP_BIND "127.0.0.1:9090"
configure_first_install
configure_runtime_bindings
assert_eq "127.0.0.1:9090" "$(env_value LANQIN_HTTP_BIND)" "legacy HTTP bind"
}
test_hostname_validation
test_password_validation
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 3 3 "80" "http://mail.example.com" "true"
test_nginx_configuration
test_compose_configuration
test_legacy_configuration_is_preserved
printf 'install.sh tests passed\n'