优化加密配置端口自动联动并完成运行验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 14:15:46 +08:00
parent ce95f1e2df
commit f98814d1cb
5 changed files with 249 additions and 12 deletions

View File

@ -0,0 +1,47 @@
# 加密端口配置优化
## 需求确认
- SMTP 中继选择加密方式后自动填写端口,不要求用户记忆常用端口。
- 网页加密方式联动内置 HTTPS 开关和端口,反代模式继续停用 Smail 自动证书签发与续期。
- 读取配置不覆盖自定义端口;SMTP 主动切换加密方式后填写推荐值,允许再次手动调整。
- 使用可视化表单,不新增 JSON 编辑入口,不修改当前业务数据库或邮件数据。
- 重新编译并运行项目,确认实际运行的是更新后的前端。
## 任务状态
- [✓] 检查当前分支、工作区与运行状态,提交并推送修改前检查点。
- [✓] 完成 SMTP 加密方式与推荐端口联动。
- [✓] 完成网页访问方式与 HTTPS 端口联动。
- [✓] 提供推荐值说明和一键恢复入口,保留手动输入。
- [✓] 63 项前端测试,以及配置、验证邮件、HTTP 服务三个后端包测试通过。
- [✓] 前端和后端构建完成,普通权限启动成功,浏览器实际操作验收通过。
## 配置规则
| 配置方式 | 推荐端口 | 行为 |
| --- | --- | --- |
| SMTP STARTTLS | 587 | 主动切换时自动填写 |
| SMTP 隐式 TLS / TLS 兼容模式 | 465 | 主动切换时自动填写 |
| SMTP 本机不加密 | 25 | 仅限回环地址;远程明文拒绝通过校验 |
| 内置 HTTPS | 443 | 自动开启内置监听;缺少端口时补全,保留有效的自定义端口 |
| 内置模式 HTTP | 80 | 缺少端口时补全 |
| 反代模式 HTTP | 8080 | 缺少端口时补全;已有监听端口不强制修改,HTTPS 端口归零 |
一键使用推荐端口只修改当前草稿,仍需完成校验、保存候选与应用流程。它不会立即重启服务,也不会更改反代服务器的配置。
邮件服务端口与 SMTP 中继端口不是同一组设置。本次不把收信端口 25 替换为客户端提交端口 465/587,也不把已有固定 TLS 监听伪装成可切换的加密模式。
## 验收范围
自动测试在 `test-log/` 下的独立空目录运行,不连接实际业务数据库。开发服务沿用根目录 `config/` 下的现有运行配置。本次不执行清空真实账号、邮件、凭据或密钥的操作,不将表单交互验收等同于外部邮件投递或生产反代验收。
## 验收记录
- 前端测试:`node test/run.mjs frontend`,63 项通过。
- 后端定向回归:`node test/run.mjs backend -count=1 ./config ./services/verification ./listen/http_server`,三个包通过。
- 构建:`powershell -NoProfile -ExecutionPolicy Bypass -File deploy/build.ps1`。
- 浏览器确认内置 HTTPS 自动补全、反代字段切换、四种 SMTP 选项的端口联动、自定义输入、恢复推荐值、远程明文错误提示。
- 重启后刷新保持登录;浏览器控制台无错误或警告,候选配置为空,当前配置未被测试草稿覆盖。
- 测试日志和交互记录位于 `test-log/`。本次未运行完整后端全量回归、真实邮件投递或生产反代端到端验收。
- 构建仍有部分资源包超过 500 kB 的体积警告,不影响构建成功;未将其描述为已消除。

View File

@ -72,6 +72,28 @@
margin-left: 0;
}
.field-port-guide {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px 12px;
min-height: 22px;
color: var(--el-color-success);
font-size: 12px;
line-height: 1.6;
}
.field-port-guide .port-custom {
color: var(--pm-text-secondary);
}
.field-port-guide :deep(.el-button) {
margin: 0;
padding: 0;
font-size: 12px;
}
.field-hint,
.field-error {
margin: 0;

View File

@ -112,6 +112,21 @@
autocomplete="off"
@update:model-value="update"
/>
<div v-if="field.recommendedPort !== undefined" class="field-port-guide">
<span :class="{ 'port-custom': value !== field.recommendedPort }">
{{ value === field.recommendedPort ? '已使用推荐端口' : '推荐端口' }}:{{ field.recommendedPort }}
</span>
<el-button
v-if="value !== field.recommendedPort"
link
type="primary"
:disabled="disabled || field.locked"
:aria-label="`将${field.label}恢复为推荐端口 ${field.recommendedPort}`"
@click="update(field.recommendedPort)"
>
使用推荐端口
</el-button>
</div>
<p v-if="field.hint" :id="`${id}-hint`" class="field-hint">{{ field.hint }}</p>
<p v-if="error" :id="`${id}-error`" class="field-error" role="alert">{{ error }}</p>
</div>

View File

@ -5,6 +5,7 @@ const loginOptions = options([
])
const port = (path, label, extra = {}) => field(path, label, 'number', { min: 0, max: 65535, integer: true, ...extra })
const group = (id, title, description, fields) => ({ id, title, description, fields })
const smtpPorts = new Map([['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]])
export function readFormConfig(text) {
try {
@ -23,6 +24,35 @@ export function fieldKey(item) {
return item.path.join('.')
}
// 推荐值只用于用户主动操作,不在读取配置时覆盖已有的自定义端口。
export function getRecommendedPort(config, path) {
switch (path.join('.')) {
case 'http.port':
return config.http?.tlsMode === 'proxy' ? 8080 : 80
case 'http.httpsPort':
return 443
case 'relay.relayPort':
return smtpPorts.get(config.relay?.security)
default:
return undefined
}
}
const validPort = value => Number.isInteger(value) && value > 0 && value <= 65535
const localSMTPHost = value => {
if (typeof value !== 'string') return false
const host = value.trim().toLowerCase().replace(/\.$/, '')
if (host === 'localhost') return true
if (/^127(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(host)) {
return host.split('.').every(part => Number(part) <= 255)
}
try {
return new URL(`http://[${host}]`).hostname === '[::1]'
} catch {
return false
}
}
const networkDatabase = config => config.database?.type !== 'sqlite'
const uploadedCertificate = config => ['managed_upload', 'custom'].includes(config.tls?.mode)
const normalizedDomain = value => value.toLowerCase().replace(/\.$/, '')
@ -50,11 +80,27 @@ const baseGroups = [
field('database.username', '数据库用户名', 'text', { required: true, show: networkDatabase })
]),
group('http', '网页访问服务', '端口和加密方式变更可能需要重启,并影响当前访问地址。', [
port('http.port', 'HTTP 端口', { hint: '填写 0 使用后端默认值。' }),
field('http.tlsMode', '加密连接方式', 'select', { options: options([['', '使用默认方式'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]), hint: '选择反向代理后,Smail 不自动申请或续期 SSL 证书,由反代服务器负责;邮件协议仍需有效的本地证书。' }),
field('http.httpsEnabled', '启用内置 HTTPS', 'switch', { show: config => config.http?.tlsMode !== 'proxy' }),
port('http.httpsPort', 'HTTPS 端口', { show: config => config.http?.tlsMode !== 'proxy', hint: '启用内置 HTTPS 时需填写有效端口;关闭时 0 表示不使用。' }),
field('http.trustedProxyCidrs', '可信代理网段', 'list', { placeholder: '127.0.0.1/32', hint: '仅填写实际代理的 CIDR 网段,不要无条件信任所有来源。' })
field('http.tlsMode', '加密连接方式', 'select', {
options: options([['', '沿用当前设置'], ['direct', '由 Smail 提供 HTTPS'], ['proxy', '由反向代理提供 HTTPS']]),
hint: '选择内置 HTTPS 自动启用并补全端口;选择反向代理后,Smail 不自动申请或续期 SSL 证书,由反代服务器负责;邮件协议仍需有效的本地证书。'
}),
port('http.port', 'HTTP 监听端口', {
hint: '保留已有自定义端口;未设置时自动补全。反代请转发到此端口,外部 HTTPS 端口在反代服务器配置。'
}),
field('http.httpsEnabled', '启用内置 HTTPS', 'switch', {
show: config => config.http?.tlsMode !== 'proxy',
hint: '开启时自动补全 HTTPS 端口,关闭时清空端口;正式站点须由内置服务或反代提供 HTTPS。'
}),
port('http.httpsPort', 'HTTPS 监听端口', {
min: 1,
show: config => config.http?.tlsMode !== 'proxy' && !!config.http?.httpsEnabled,
hint: '默认使用 443;需要避开端口占用时可手动修改。'
}),
field('http.trustedProxyCidrs', '可信代理网段', 'list', {
show: config => config.http?.tlsMode === 'proxy',
placeholder: '127.0.0.1/32',
hint: '仅填写实际代理的 CIDR 网段,不要无条件信任所有来源。'
})
]),
group('security', '全局安全选项', '只调整功能开关与站点信息,密码算法和加密密钥由系统维护。', [
field('security.externalEmailEnabled', '启用外部邮箱功能', 'switch'),
@ -68,8 +114,17 @@ const baseGroups = [
const smtpGroups = [
group('relay', 'SMTP 中继连接', '用于发送验证及通知邮件。未使用中继时可保留空值。', [
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
port('relay.relayPort', 'SMTP 端口', { hint: '常见端口:STARTTLS 使用 587,隐式 TLS 使用 465。' }),
field('relay.security', '连接加密', 'select', { options: options([['', '使用默认方式'], ['starttls', 'STARTTLS'], ['implicit_tls', '隐式 TLS'], ['tls', 'TLS(兼容模式)'], ['plain', '不加密(仅限本机)']]) }),
field('relay.security', '连接加密', 'select', {
options: options([
['', '尚未配置'],
['starttls', 'STARTTLS(推荐端口 587)'],
['implicit_tls', '隐式 TLS(推荐端口 465)'],
['tls', 'TLS 兼容模式(推荐端口 465)'],
['plain', '不加密(仅限本机,端口 25)']
]),
hint: '切换加密方式会自动匹配推荐端口,之后仍可手动填写服务商指定的端口。'
}),
port('relay.relayPort', 'SMTP 端口', { hint: '先选择加密方式,系统自动填写端口;自定义端口不会改变加密方式。' }),
field('relay.username', '认证用户名', 'text', { placeholder: '需要认证时填写' }),
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
@ -128,7 +183,12 @@ const pluginDefinitions = {
export function getFieldGroups(section, config) {
if (section !== 'plugins') {
return ({ base: baseGroups, smtp: smtpGroups, email: emailGroups, ssl: sslGroups }[section] || [])
.map(item => ({ ...item, fields: item.fields.filter(input => !input.show || input.show(config)) }))
.map(item => ({
...item,
fields: item.fields
.filter(input => !input.show || input.show(config))
.map(input => ({ ...input, recommendedPort: getRecommendedPort(config, input.path) }))
}))
}
return Object.entries(config.plugins || {}).map(([id, plugin]) => {
const definition = pluginDefinitions[id]
@ -158,15 +218,30 @@ export function updateFormConfig(text, section, path, value) {
const key = path.join('.')
const input = getFieldGroups(section, config).flatMap(item => item.fields).find(item => fieldKey(item) === key)
if (!input || input.locked) return text
const previousValue = getValue(config, path)
let parent = config
for (const part of path.slice(0, -1)) {
if (!parent[part] || typeof parent[part] !== 'object') parent[part] = {}
parent = parent[part]
}
parent[path.at(-1)] = value ?? (input.type === 'number' ? null : '')
if (key === 'http.tlsMode' && value === 'proxy') {
config.http.httpsEnabled = false
config.http.httpsPort = 0
if (previousValue !== value) {
if (key === 'relay.security') {
const recommended = getRecommendedPort(config, ['relay', 'relayPort'])
if (recommended !== undefined) config.relay.relayPort = recommended
}
if (key === 'http.tlsMode' && ['direct', 'proxy'].includes(value)) {
if (!validPort(config.http.port)) {
config.http.port = getRecommendedPort(config, ['http', 'port'])
}
config.http.httpsEnabled = value === 'direct'
config.http.httpsPort = value === 'proxy' ? 0 : (
validPort(config.http.httpsPort) ? config.http.httpsPort : 443
)
}
if (key === 'http.httpsEnabled') {
config.http.httpsPort = value ? (validPort(config.http.httpsPort) ? config.http.httpsPort : 443) : 0
}
}
if (key === 'tls.mode' && ['managed_default', 'default'].includes(value)) config.tls.customDirectory = ''
return JSON.stringify(config, null, 2)
@ -203,6 +278,21 @@ export function validateFormConfig(section, config) {
errors['domains.accepted'] = '请逐项填写有效的接收邮件域名'
}
if (config.http?.httpsEnabled && !config.http.httpsPort) errors['http.httpsPort'] = '启用内置 HTTPS 时请填写 1~65535 的端口'
if (config.http?.httpsEnabled && (config.http.port || 80) === config.http.httpsPort) {
errors['http.httpsPort'] = 'HTTP 与 HTTPS 监听端口不能相同'
}
}
if (section === 'smtp') {
const relay = config.relay || {}
if (relay.verificationEnabled || (relay.relayHost && relay.security)) {
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) {
errors['relay.security'] = '请选择明确的连接加密方式,系统会自动匹配端口'
}
if (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口'
}
if (relay.security === 'plain' && !localSMTPHost(relay.relayHost)) {
errors['relay.relayHost'] = '不加密连接仅限本机地址;远程服务器请选择 STARTTLS 或隐式 TLS'
}
}
if (section === 'email') {
const ports = ['smtpPort', 'imapPort', 'pop3Port']

View File

@ -54,7 +54,7 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
assert.match(modeField.hint, /邮件协议仍需有效的本地证书/)
let text = JSON.stringify({ http: { tlsMode: 'direct', httpsEnabled: true, httpsPort: 443 } })
text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy')
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0 })
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', httpsEnabled: false, httpsPort: 0, port: 8080 })
assert.equal(updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true), text)
text = updateFormConfig(text, 'base', ['security', 'externalEmailEnabled'], true)
assert.equal(readFormConfig(text).security.externalEmailEnabled, true)
@ -116,3 +116,66 @@ test('证书路径始终受管且引用成对,切回默认来源清除自定
assert.equal(updated.tls.customDirectory, '')
assert.equal(updated.tls.privateKeyRef, tls.privateKeyRef)
})
test('SMTP 加密方式自动匹配端口,手动端口和读取过程不改变原配置', () => {
for (const [security, expectedPort] of [['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]]) {
const config = { generation: 9, relay: { relayHost: 'localhost', relayPort: 2525, future: true } }
let text = JSON.stringify(config)
getFieldGroups('smtp', config)
assert.equal(JSON.stringify(config), text)
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
assert.deepEqual(readFormConfig(text), { ...config, relay: { ...config.relay, security, relayPort: expectedPort } })
const fields = getFieldGroups('smtp', readFormConfig(text)).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, expectedPort)
text = updateFormConfig(text, 'smtp', ['relay', 'relayPort'], 2526)
assert.equal(readFormConfig(text).relay.security, security)
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
assert.equal(readFormConfig(text).relay.relayPort, 2526)
}
const fields = getFieldGroups('smtp', {}).flatMap(group => group.fields)
assert.ok(fields.findIndex(field => field.path.at(-1) === 'security') < fields.findIndex(field => field.path.at(-1) === 'relayPort'))
const unused = JSON.stringify({ relay: { relayPort: 2525, security: 'starttls' } })
assert.equal(readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], '')).relay.relayPort, 2525)
for (const security of ['constructor', '__proto__', 'future']) {
const updated = readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], security))
assert.equal(updated.relay.relayPort, 2525)
const fields = getFieldGroups('smtp', updated).flatMap(group => group.fields)
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, undefined)
}
})
test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定义 HTTP 端口保留', () => {
let text = updateFormConfig('{}', 'base', ['http', 'tlsMode'], 'direct')
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'direct', port: 80, httpsEnabled: true, httpsPort: 443 })
text = updateFormConfig(text, 'base', ['http', 'httpsEnabled'], false)
assert.equal(readFormConfig(text).http.httpsPort, 0)
assert.ok(!getFieldGroups('base', readFormConfig(text)).flatMap(group => group.fields).some(field => field.path.at(-1) === 'httpsPort'))
text = updateFormConfig(text, 'base', ['http', 'httpsEnabled'], true)
assert.equal(readFormConfig(text).http.httpsPort, 443)
text = updateFormConfig(text, 'base', ['http', 'port'], 9080)
text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'proxy')
assert.deepEqual(readFormConfig(text).http, { tlsMode: 'proxy', port: 9080, httpsEnabled: false, httpsPort: 0 })
assert.equal(updateFormConfig(text, 'base', ['http', 'httpsPort'], 8443), text)
text = updateFormConfig(text, 'base', ['http', 'tlsMode'], 'direct')
assert.equal(readFormConfig(text).http.port, 9080)
assert.equal(readFormConfig(text).http.httpsPort, 443)
const custom = JSON.stringify({ http: { httpsEnabled: false, httpsPort: 8443, port: 9080 } })
assert.equal(readFormConfig(updateFormConfig(custom, 'base', ['http', 'tlsMode'], 'direct')).http.httpsPort, 8443)
})
test('加密配置校验拦截监听冲突、远程明文和已启用但未选择加密的中继', () => {
for (const port of [80, 0]) {
assert.ok(validateFormConfig('base', { http: { port, httpsEnabled: true, httpsPort: 80 } })['http.httpsPort'])
}
for (const relayHost of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '::1', '0:0:0:0:0:0:0:1']) {
assert.deepEqual(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } }), {})
}
for (const relayHost of ['smtp.example.com', '127.0.0.999', '127.1', '127.0.0.1.evil.com', '192.168.1.1']) {
assert.ok(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } })['relay.relayHost'])
}
const errors = validateFormConfig('smtp', { relay: { verificationEnabled: true, relayHost: 'smtp.example.com', relayPort: 0 } })
assert.ok(errors['relay.security'])
assert.ok(errors['relay.relayPort'])
assert.deepEqual(validateFormConfig('smtp', { relay: {} }), {})
assert.deepEqual(validateFormConfig('smtp', { relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com' } }), {})
})