This commit is contained in:
parent
ce95f1e2df
commit
f98814d1cb
47
docs/加密端口配置优化.md
Normal file
47
docs/加密端口配置优化.md
Normal 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 的体积警告,不影响构建成功;未将其描述为已消除。
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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' } }), {})
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user