diff --git a/docs/加密端口配置优化.md b/docs/加密端口配置优化.md new file mode 100644 index 0000000..c4d47c4 --- /dev/null +++ b/docs/加密端口配置优化.md @@ -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 的体积警告,不影响构建成功;未将其描述为已消除。 diff --git a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.css b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.css index 21ea1b6..8a42096 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.css +++ b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.css @@ -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; diff --git a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue index 10c6c5e..7de0beb 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue @@ -112,6 +112,21 @@ autocomplete="off" @update:model-value="update" /> +
+ + {{ value === field.recommendedPort ? '已使用推荐端口' : '推荐端口' }}:{{ field.recommendedPort }} + + + 使用推荐端口 + +

{{ field.hint }}

diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 9fda775..7a643c8 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -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'] diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index e135694..1722a5b 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -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' } }), {}) +})