feat: add guided mail server installer
This commit is contained in:
@@ -40,6 +40,14 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
+5
-1
@@ -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.
|
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
|
## 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.
|
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
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
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
|
## Required ports
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
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
|
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||||
- 询问邮件域名、访问地址、管理员用户名和密码
|
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||||
|
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||||
|
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||||
|
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -67,10 +76,12 @@ sudo newszxcn-email rollback
|
|||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -104,10 +115,11 @@ sudo newszxcn-email uninstall
|
|||||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
|-- data/ # SQLite、附件和更新前备份
|
|-- data/ # SQLite、附件和更新前备份
|
||||||
|-- mail/ # Maildir 邮件原文
|
|-- mail/ # Maildir 邮件原文
|
||||||
`-- dkim/ # DKIM 私钥
|
|-- dkim/ # DKIM 私钥
|
||||||
|
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||||
```
|
```
|
||||||
|
|
||||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||||
|
|
||||||
## 手动部署
|
## 手动部署
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -24,10 +24,19 @@ NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后
|
|||||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
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
|
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||||
|
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||||
|
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||||
|
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||||
- 拉取 GHCR 镜像并启动邮件服务
|
- 拉取 GHCR 镜像并启动邮件服务
|
||||||
- 生成后台在线更新所需的内部鉴权令牌
|
- 生成后台在线更新所需的内部鉴权令牌
|
||||||
@@ -62,10 +71,12 @@ sudo newszxcn-email rollback
|
|||||||
```bash
|
```bash
|
||||||
sudo newszxcn-email status
|
sudo newszxcn-email status
|
||||||
sudo newszxcn-email logs
|
sudo newszxcn-email logs
|
||||||
|
sudo newszxcn-email restart
|
||||||
|
sudo newszxcn-email certificate
|
||||||
sudo newszxcn-email uninstall
|
sudo newszxcn-email uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/opt/newszxcn-email` 中的配置、证书、数据库与邮件。
|
||||||
|
|
||||||
## DNS 与端口
|
## DNS 与端口
|
||||||
|
|
||||||
@@ -99,10 +110,11 @@ sudo newszxcn-email uninstall
|
|||||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||||
├── data/ # SQLite、附件和更新前备份
|
├── data/ # SQLite、附件和更新前备份
|
||||||
├── mail/ # Maildir 邮件原文
|
├── mail/ # Maildir 邮件原文
|
||||||
└── dkim/ # DKIM 私钥
|
├── dkim/ # DKIM 私钥
|
||||||
|
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||||
```
|
```
|
||||||
|
|
||||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.env`。
|
||||||
|
|
||||||
## 手动部署
|
## 手动部署
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
mailboxLimitOverride = nil
|
mailboxLimitOverride = nil
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
role := req.Role
|
role := req.Role
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, errors.New("邮箱地址无效"))
|
badRequest(w, errors.New("邮箱地址无效"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
displayName := strings.TrimSpace(req.DisplayName)
|
displayName := strings.TrimSpace(req.DisplayName)
|
||||||
@@ -245,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.NewPassword) < 8 {
|
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request)
|
|||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
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)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Password) < 8 {
|
if !hasMinimumPasswordLength(req.Password) {
|
||||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var userID string
|
var userID string
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||||
|
|
||||||
|
const minimumPasswordLength = 6
|
||||||
|
|
||||||
|
func hasMinimumPasswordLength(password string) bool {
|
||||||
|
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||||
|
}
|
||||||
|
|
||||||
func NewHTMLPolicy() *HTMLPolicy {
|
func NewHTMLPolicy() *HTMLPolicy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
p.AllowElements("html", "head", "body", "center", "font")
|
p.AllowElements("html", "head", "body", "center", "font")
|
||||||
|
|||||||
@@ -1858,7 +1858,7 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
|||||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
<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="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
<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">
|
<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={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
<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 }) {
|
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 }) })
|
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 }) {
|
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("")
|
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])
|
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 }) })
|
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[] }) {
|
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||||
|
|||||||
@@ -905,8 +905,8 @@ function SecuritySettingsSection({ user, password, passwordFormRef, twoFactorFor
|
|||||||
<SettingsCard title="密码管理">
|
<SettingsCard title="密码管理">
|
||||||
<form ref={passwordFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); password.mutate(new FormData(e.currentTarget)) }}>
|
<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="currentPassword" required /></Field>
|
||||||
<Field label="新密码"><PasswordInput name="newPassword" minLength={8} required placeholder="输入新密码" /></Field>
|
<Field label="新密码"><PasswordInput name="newPassword" minLength={6} required placeholder="输入新密码" /></Field>
|
||||||
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={8} required placeholder="再次输入密码" /></Field>
|
<Field label="确认新密码"><PasswordInput name="confirmPassword" minLength={6} required placeholder="再次输入密码" /></Field>
|
||||||
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
<Button disabled={password.isPending}>{password.isPending ? "设置中..." : "设置密码"}</Button>
|
||||||
</form>
|
</form>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ export function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
<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>
|
</div>
|
||||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||||
|
|||||||
+5
-2
@@ -20,9 +20,12 @@ LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
|||||||
# 手动部署可执行:openssl rand -hex 24
|
# 手动部署可执行:openssl rand -hex 24
|
||||||
LANQIN_UPDATE_TOKEN=
|
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_HTTP_BIND=80
|
||||||
LANQIN_HTTPS_BIND=443
|
|
||||||
LANQIN_SMTP_BIND=25
|
LANQIN_SMTP_BIND=25
|
||||||
LANQIN_SMTPS_BIND=465
|
LANQIN_SMTPS_BIND=465
|
||||||
LANQIN_SUBMISSION_BIND=587
|
LANQIN_SUBMISSION_BIND=587
|
||||||
|
|||||||
+4
-3
@@ -19,6 +19,8 @@ sudo newszxcn-email rollback
|
|||||||
|
|
||||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||||
|
|
||||||
|
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||||
|
|
||||||
## 最简单部署:单容器镜像版
|
## 最简单部署:单容器镜像版
|
||||||
|
|
||||||
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
服务器上不需要源码构建,只要 `docker-compose.yml` 和 `.env` 即可。
|
||||||
@@ -158,10 +160,9 @@ Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
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 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||||
|
|
||||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||||
@@ -182,7 +183,7 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
- ./certs:/certs:ro
|
||||||
```
|
```
|
||||||
|
|
||||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ services:
|
|||||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||||
- "${LANQIN_HTTPS_BIND:-443}:443"
|
|
||||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||||
@@ -17,8 +16,7 @@ services:
|
|||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
- ./dkim:/var/lib/rspamd/dkim
|
- ./dkim:/var/lib/rspamd/dkim
|
||||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
- ./certs:/certs:ro
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||||
|
|||||||
+454
-29
@@ -6,6 +6,9 @@ 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:-install}"
|
COMMAND="${1:-install}"
|
||||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
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' "$*"; }
|
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' "$*"; }
|
||||||
@@ -22,6 +25,8 @@ NewSzxcn Email 管理命令
|
|||||||
update 备份数据库并更新到最新版
|
update 备份数据库并更新到最新版
|
||||||
status 查看容器与健康状态
|
status 查看容器与健康状态
|
||||||
logs 持续查看运行日志
|
logs 持续查看运行日志
|
||||||
|
restart 重启服务并重载 Nginx
|
||||||
|
certificate 申请或续期自动模式的 SSL 证书
|
||||||
rollback 回滚到上次命令行更新前的镜像
|
rollback 回滚到上次命令行更新前的镜像
|
||||||
uninstall 停止并移除容器,保留邮件与配置
|
uninstall 停止并移除容器,保留邮件与配置
|
||||||
EOF
|
EOF
|
||||||
@@ -37,6 +42,19 @@ require_curl() {
|
|||||||
command -v curl >/dev/null 2>&1 || fail "系统缺少 curl,请先安装 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() {
|
ensure_docker() {
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
log "未检测到 Docker,正在安装 Docker Engine..."
|
log "未检测到 Docker,正在安装 Docker Engine..."
|
||||||
@@ -57,10 +75,13 @@ script_dir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh_assets() {
|
refresh_assets() {
|
||||||
local source_dir
|
local source_dir local_source="false"
|
||||||
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
|
||||||
|
local_source="true"
|
||||||
|
fi
|
||||||
install -d -m 0755 "${INSTALL_DIR}"
|
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/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
|
||||||
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example"
|
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example"
|
||||||
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
|
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
|
||||||
@@ -81,6 +102,16 @@ random_secret() {
|
|||||||
fi
|
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() {
|
set_env() {
|
||||||
local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp
|
local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
@@ -102,9 +133,9 @@ env_value() {
|
|||||||
prompt_value() {
|
prompt_value() {
|
||||||
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
|
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
|
||||||
local value="${!variable:-}"
|
local value="${!variable:-}"
|
||||||
if [[ -z "${value}" && -r /dev/tty ]]; then
|
if [[ -z "${value}" ]] && has_tty; then
|
||||||
if [[ "${secret}" == "true" ]]; 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
|
printf '\n' >/dev/tty
|
||||||
else
|
else
|
||||||
read -r -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
|
read -r -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
|
||||||
@@ -114,30 +145,124 @@ prompt_value() {
|
|||||||
printf '%s' "${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() {
|
configure_first_install() {
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||||
return
|
return
|
||||||
fi
|
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="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
|
||||||
[[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。"
|
valid_hostname "${hostname}" || 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)"
|
|
||||||
|
|
||||||
|
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_HOSTNAME "${hostname}"
|
||||||
set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
|
set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
|
||||||
set_env LANQIN_ADMIN_USERNAME "${admin_username}"
|
set_env LANQIN_ADMIN_USERNAME "${admin_username}"
|
||||||
set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
|
set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
|
||||||
|
set_env LANQIN_INSTALL_WEB_MODE "${web_mode}"
|
||||||
set_env LANQIN_UPDATE_TOKEN "${update_token}"
|
set_env LANQIN_UPDATE_TOKEN "${update_token}"
|
||||||
chmod 0600 "${INSTALL_DIR}/.env"
|
chmod 0600 "${INSTALL_DIR}/.env"
|
||||||
}
|
}
|
||||||
@@ -152,10 +277,98 @@ ensure_update_token() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepare_directories() {
|
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"
|
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() {
|
wait_for_health() {
|
||||||
local attempts="${1:-60}" bind port
|
local attempts="${1:-60}" bind port
|
||||||
bind="$(env_value LANQIN_HTTP_BIND || true)"
|
bind="$(env_value LANQIN_HTTP_BIND || true)"
|
||||||
@@ -170,6 +383,166 @@ wait_for_health() {
|
|||||||
return 1
|
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() {
|
backup_database() {
|
||||||
local timestamp
|
local timestamp
|
||||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
@@ -190,16 +563,19 @@ remember_current_image() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do_install() {
|
do_install() {
|
||||||
ensure_docker
|
|
||||||
refresh_assets
|
refresh_assets
|
||||||
configure_first_install
|
configure_first_install
|
||||||
ensure_update_token
|
ensure_update_token
|
||||||
|
configure_runtime_bindings
|
||||||
|
ensure_docker
|
||||||
|
configure_firewall
|
||||||
prepare_directories
|
prepare_directories
|
||||||
log "正在拉取 NewSzxcn Email 镜像..."
|
log "正在拉取 NewSzxcn Email 镜像..."
|
||||||
compose pull
|
compose pull
|
||||||
log "正在启动服务..."
|
log "正在启动服务..."
|
||||||
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
|
||||||
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 端口可访问。"
|
||||||
}
|
}
|
||||||
@@ -223,7 +599,7 @@ do_update() {
|
|||||||
do_rollback
|
do_rollback
|
||||||
fail "更新失败,已回滚到原镜像。"
|
fail "更新失败,已回滚到原镜像。"
|
||||||
fi
|
fi
|
||||||
success "系统已更新,配置、邮件和数据库均已保留。"
|
success "系统已更新,配置、邮件、证书和数据库均已保留。"
|
||||||
}
|
}
|
||||||
|
|
||||||
do_rollback() {
|
do_rollback() {
|
||||||
@@ -237,6 +613,37 @@ do_rollback() {
|
|||||||
success "已回滚到 ${image}。"
|
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() {
|
do_status() {
|
||||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||||
compose ps
|
compose ps
|
||||||
@@ -250,18 +657,36 @@ do_status() {
|
|||||||
do_uninstall() {
|
do_uninstall() {
|
||||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||||
compose down --remove-orphans
|
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
|
if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
|
||||||
require_curl
|
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
case "${COMMAND}" in
|
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 ;;
|
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}" ;;
|
*) usage; fail "未知命令:${COMMAND}" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user