diff --git a/backend/config/runtime_access.go b/backend/config/runtime_access.go index 34847f7..705ac32 100644 --- a/backend/config/runtime_access.go +++ b/backend/config/runtime_access.go @@ -77,7 +77,9 @@ func CurrentHTTPConfig() HTTPConfig { func ResolveHTTPPortDefaults(value HTTPConfig, setupPort int) HTTPConfig { result := cloneHTTPConfig(value) result.TLSMode = strings.ToLower(strings.TrimSpace(result.TLSMode)) - if result.Port == 0 { + if override := environmentPort("SMAIL_HTTP_PORT_OVERRIDE"); override != 0 { + result.Port = override + } else if result.Port == 0 { result.Port = environmentPort("SMAIL_HTTP_PORT") if result.Port == 0 && validServicePort(setupPort) { result.Port = setupPort @@ -86,6 +88,9 @@ func ResolveHTTPPortDefaults(value HTTPConfig, setupPort int) HTTPConfig { result.Port = 80 } } + if override := strings.ToLower(strings.TrimSpace(os.Getenv("SMAIL_HTTP_TLS_MODE_OVERRIDE"))); override == HTTPSTLSModeDirect || override == HTTPSTLSModeProxy { + result.TLSMode = override + } if result.TLSMode == HTTPSTLSModeProxy { result.HTTPSPort = 0 result.HTTPSEnabled = false diff --git a/backend/config/runtime_access_port_test.go b/backend/config/runtime_access_port_test.go index faece9b..31f34df 100644 --- a/backend/config/runtime_access_port_test.go +++ b/backend/config/runtime_access_port_test.go @@ -3,6 +3,8 @@ package config import "testing" func TestResolveHTTPPortDefaultsPreservesConfiguredPorts(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT_OVERRIDE", "") + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", "") t.Setenv("SMAIL_HTTP_PORT", "19001") t.Setenv("SMAIL_HTTPS_PORT", "19002") @@ -17,6 +19,8 @@ func TestResolveHTTPPortDefaultsPreservesConfiguredPorts(t *testing.T) { } func TestResolveHTTPPortDefaultsUsesEnvironmentPorts(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT_OVERRIDE", "") + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", "") t.Setenv("SMAIL_HTTP_PORT", "9001") t.Setenv("SMAIL_HTTPS_PORT", "9002") @@ -27,6 +31,8 @@ func TestResolveHTTPPortDefaultsUsesEnvironmentPorts(t *testing.T) { } func TestResolveHTTPPortDefaultsUsesSetupAndDirectDefaults(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT_OVERRIDE", "") + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", "") t.Setenv("SMAIL_HTTP_PORT", "") t.Setenv("SMAIL_HTTPS_PORT", "") @@ -37,6 +43,8 @@ func TestResolveHTTPPortDefaultsUsesSetupAndDirectDefaults(t *testing.T) { } func TestResolveHTTPPortDefaultsDisablesInternalHTTPSInProxyMode(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT_OVERRIDE", "") + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", "") t.Setenv("SMAIL_HTTP_PORT", "9001") t.Setenv("SMAIL_HTTPS_PORT", "9002") @@ -49,3 +57,20 @@ func TestResolveHTTPPortDefaultsDisablesInternalHTTPSInProxyMode(t *testing.T) { t.Fatalf("proxy HTTP config = %+v, want HTTP 9001 with internal HTTPS disabled", got) } } + +func TestResolveHTTPPortDefaultsUsesExplicitRuntimeOverride(t *testing.T) { + t.Setenv("SMAIL_HTTP_PORT_OVERRIDE", "5201") + t.Setenv("SMAIL_HTTP_TLS_MODE_OVERRIDE", HTTPSTLSModeProxy) + t.Setenv("SMAIL_HTTP_PORT", "9001") + t.Setenv("SMAIL_HTTPS_PORT", "9002") + + got := ResolveHTTPPortDefaults(HTTPConfig{ + Port: 80, + HTTPSPort: 443, + HTTPSEnabled: true, + TLSMode: HTTPSTLSModeDirect, + }, 18080) + if got.Port != 5201 || got.HTTPSPort != 0 || got.HTTPSEnabled || got.TLSMode != HTTPSTLSModeProxy { + t.Fatalf("runtime override HTTP config = %+v, want proxy API on port 5201", got) + } +} diff --git a/backend/config/webauthn.go b/backend/config/webauthn.go index 9590b53..36243e0 100644 --- a/backend/config/webauthn.go +++ b/backend/config/webauthn.go @@ -31,9 +31,10 @@ func ResolveWebAuthnSecurity(root RootConfig) (SecurityConfig, error) { } if len(security.WebAuthnRPOrigins) == 0 { origin := "https://" + web + httpConfig := ResolveHTTPPortDefaults(root.HTTP, 0) // 反代的外部端口不等于内部 HTTP 端口,非标准外部端口需显式配置。 - if root.HTTP.TLSMode != HTTPSTLSModeProxy { - port := ResolveHTTPPortDefaults(root.HTTP, 0).HTTPSPort + if httpConfig.TLSMode != HTTPSTLSModeProxy { + port := httpConfig.HTTPSPort if !validServicePort(port) { return SecurityConfig{}, fmt.Errorf("安全密钥站点 HTTPS 端口无效") } diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 10f624d..878ed37 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -463,10 +463,7 @@ func dirInit() error { // 等待 HTTP 服务启动,最长约 90 秒。 func waitHTTPReady(serviceErrors <-chan error) error { - port := 80 - if cfg := config.CurrentConfig(); cfg != nil && cfg.HttpPort > 0 { - port = cfg.HttpPort - } + port := config.CurrentHTTPConfig().Port url := fmt.Sprintf("http://127.0.0.1:%d/api/ping", port) client := &http.Client{Timeout: 2 * time.Second} diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 index 03fc552..311c269 100644 --- a/deploy/start-dev.ps1 +++ b/deploy/start-dev.ps1 @@ -9,10 +9,13 @@ $logs = Join-Path $root 'test-log\runtime' New-Item -ItemType Directory -Path $logs -Force | Out-Null $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' $previousRoot = $env:SMAIL_ROOT +$previousHTTPPortOverride = $env:SMAIL_HTTP_PORT_OVERRIDE +$previousHTTPTLSModeOverride = $env:SMAIL_HTTP_TLS_MODE_OVERRIDE +$previousSetupPort = $env:setup_port try { if ($Service -eq 'backend') { $binary = Join-Path $root 'out\backend\smail-dev.exe' - if (Get-NetTCPConnection -State Listen -LocalPort 443 -ErrorAction SilentlyContinue) { throw '443 端口已占用,请先确认并停止旧后端' } + if (Get-NetTCPConnection -State Listen -LocalPort 5201 -ErrorAction SilentlyContinue) { throw '5201 端口已占用,请先确认并停止旧后端' } $running = Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath -eq $binary } @@ -32,8 +35,11 @@ try { if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { throw '请先运行 deploy/build.ps1 编译后端' } # SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。 $env:SMAIL_ROOT = $root + $env:SMAIL_HTTP_PORT_OVERRIDE = '5201' + $env:SMAIL_HTTP_TLS_MODE_OVERRIDE = 'proxy' + $env:setup_port = '5201' $arguments = @('--dev-disable-admin-reset') - Write-Output '开发启动:沿用 config/config.json 和现有数据库;明确停用管理员恢复通道,不请求权限提升。' + Write-Output '开发启动:后端 API 仅监听 5201;沿用 config/config.json 和现有数据库;明确停用管理员恢复通道,不请求权限提升。' if ($SkipBuild) { Write-Warning '已跳过编译,请自行确认二进制与当前源码一致。' } # 插件从 out/plugins 加载;账号、配置和密钥仍通过 SMAIL_ROOT 定位到根目录 config。 foreach ($name in @('spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe')) { @@ -43,11 +49,13 @@ try { } $workingDirectory = Join-Path $root 'out' } else { - if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' } + foreach ($port in @(80, 443)) { + if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { throw "$port 端口已占用,请先确认并停止旧前端或旧后端" } + } $binary = (Get-Command node -ErrorAction Stop).Source $vite = Join-Path $root 'frontend\node_modules\vite\bin\vite.js' if (-not (Test-Path -LiteralPath $vite)) { throw '请先安装 frontend 的依赖' } - $arguments = @(('"{0}"' -f $vite), '--host', '127.0.0.1', '--port', '15173', '--strictPort') + $arguments = @(('"{0}"' -f $vite), '--host', '127.0.0.1', '--port', '443', '--strictPort') $workingDirectory = Join-Path $root 'frontend' } $stdout = Join-Path $logs "$Service-$stamp.stdout.log" @@ -61,4 +69,7 @@ try { Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr" } finally { $env:SMAIL_ROOT = $previousRoot + $env:SMAIL_HTTP_PORT_OVERRIDE = $previousHTTPPortOverride + $env:SMAIL_HTTP_TLS_MODE_OVERRIDE = $previousHTTPTLSModeOverride + $env:setup_port = $previousSetupPort } diff --git a/frontend/.env.development b/frontend/.env.development index 7bc91ab..1221c08 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1 +1,5 @@ -VITE_DEV_BACKEND_URL=https://127.0.0.1:443 +VITE_DEV_BACKEND_URL=http://127.0.0.1:5201 +VITE_DEV_BACKEND_ORIGIN=https://xuans.com +VITE_DEV_FRONTEND_ORIGIN=https://xuans.com +SMAIL_DEV_TLS_CERTIFICATE=../config/ssl/xuans.com.crt +SMAIL_DEV_TLS_PRIVATE_KEY=../config/ssl/xuans.com.key diff --git a/frontend/vite.config.js b/frontend/vite.config.js index fbdd0f8..d2e1d6e 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,4 +1,7 @@ /* eslint-env node */ +import { readFileSync } from 'node:fs' +import { createServer as createHTTPServer } from 'node:http' +import path from 'node:path' import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import AutoImport from 'unplugin-auto-import/vite' @@ -6,27 +9,40 @@ import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import vue from '@vitejs/plugin-vue' +const developmentHTTPRedirect = (frontendOrigin, host = '127.0.0.1', port = 80) => ({ + name: 'smail-development-http-redirect', + apply: 'serve', + async configureServer(server) { + const redirectServer = createHTTPServer((request, response) => { + const requestURL = new URL(request.url || '/', 'http://127.0.0.1') + const location = new URL(`${requestURL.pathname}${requestURL.search}`, frontendOrigin) + response.writeHead(308, { + Location: location.toString(), + 'Cache-Control': 'no-store' + }) + response.end() + }) + + await new Promise((resolve, reject) => { + redirectServer.once('error', reject) + redirectServer.listen(port, host, resolve) + }) + server.httpServer?.once('close', () => redirectServer.close()) + } +}) + // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') - const backendUrl = (env.VITE_DEV_BACKEND_URL || 'https://127.0.0.1:443').replace(/\/$/, '') + const backendUrl = (env.VITE_DEV_BACKEND_URL || 'http://127.0.0.1:5201').replace(/\/$/, '') const backendOrigin = (env.VITE_DEV_BACKEND_ORIGIN || 'https://xuans.com').replace(/\/$/, '') - - const rewriteDevelopmentCookies = (proxyResponse) => { - const setCookies = proxyResponse.headers['set-cookie'] - if (!Array.isArray(setCookies)) { - return - } - - proxyResponse.headers['set-cookie'] = setCookies.map((cookie) => ( - cookie - .replace(/;\s*Secure/gi, '') - .replace(/;\s*Domain=[^;]*/gi, '') - )) - } + const frontendOrigin = (env.VITE_DEV_FRONTEND_ORIGIN || 'https://xuans.com').replace(/\/$/, '') + const certificatePath = path.resolve(process.cwd(), env.SMAIL_DEV_TLS_CERTIFICATE || '../config/ssl/xuans.com.crt') + const privateKeyPath = path.resolve(process.cwd(), env.SMAIL_DEV_TLS_PRIVATE_KEY || '../config/ssl/xuans.com.key') return { plugins: [ + developmentHTTPRedirect(frontendOrigin), vue(), AutoImport({ resolvers: [ElementPlusResolver()], @@ -41,6 +57,13 @@ export default defineConfig(({ mode }) => { }, }, server: { + host: '127.0.0.1', + port: 443, + strictPort: true, + https: { + cert: readFileSync(certificatePath), + key: readFileSync(privateKeyPath) + }, cors: true, proxy: { "/api": { @@ -55,7 +78,6 @@ export default defineConfig(({ mode }) => { proxyRequest.setHeader('x-forwarded-proto', originURL.protocol.replace(':', '')) proxyRequest.removeHeader('referer') }) - proxy.on('proxyRes', rewriteDevelopmentCookies) } }, "/attachments": { diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index c2edef8..2bc9592 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -52,6 +52,13 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async assert.match(content, /-WindowStyle Hidden/) assert.match(content, /\[switch\]\$SkipBuild/) assert.match(content, /--dev-disable-admin-reset/) + assert.match(content, /SMAIL_HTTP_PORT_OVERRIDE = '5201'/) + assert.match(content, /SMAIL_HTTP_TLS_MODE_OVERRIDE = 'proxy'/) + assert.match(content, /LocalPort 5201/) + assert.match(content, /@\(80, 443\)/) + assert.match(await read('frontend/.env.development'), /VITE_DEV_BACKEND_URL=http:\/\/127\.0\.0\.1:5201/) + assert.match(await read('frontend/vite.config.js'), /port: 443/) + assert.match(await read('frontend/vite.config.js'), /smail-development-http-redirect/) assert.match(content, /默认先编译当前工作区/) assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM') })