feat(frp): frps vhost 回源端口支持 80/443,公网反代在该配置下不再适用

- 服务端表单不再硬性拒绝 vhostHTTPPort=80 / vhostHTTPSPort=443,改为保存时弹窗二次确认;
  确认后请求携带 allow_privileged_vhost_ports 提交并由后端校验放行,未携带标记的请求(含直接调 API)仍被拒绝
- frps.toml 生成改为写入风险注释、不再报错;各规则的公网反代回源地址随 80/443 自动跟随
- 「公网反代」页检测到 80/443 后显示说明卡片并隐藏统计/Nginx/规则台账/漂移区块,
  同时跳过 agent 的路由、漂移与健康请求
- 后端 DeployAgentRoute 与 RedeployAllRoutes(含漂移一键修复)拒绝下发,自动下发路径 syncAgentRoute 跳过
- 新增 internal/frp/vhost_port_test.go:校验、二次确认、frps.toml 生成、反代回源跟随与守卫对照
This commit is contained in:
2026-10-11 15:07:07 +08:00
parent d1c498a286
commit aafff522d8
11 changed files with 370 additions and 22 deletions
+2
View File
@@ -676,6 +676,8 @@ export interface FrpServerPayload {
clear_tls_certificate?: boolean
clear_tls_key?: boolean
clear_tls_trusted_ca?: boolean
/** 二次确认标记:vhost 回源端口使用 80/443 时必须为 true(前端弹窗确认后置位)*/
allow_privileged_vhost_ports?: boolean
}
export interface FrpAgentRouteCandidate {
+36 -6
View File
@@ -492,7 +492,7 @@
:show-icon="false"
style="margin-bottom: 12px"
>
80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。
80/443 通常由 VPS 上的 Nginx 占用。继续使用前请先停止占用该端口的服务,保存时会再次弹窗确认;端口仍被占用时 frps 会因冲突启动失败。若 VPS 上还要保留 Nginx,请改用 8080/8443。
</n-alert>
<div class="form-grid">
<n-form-item label="HTTP 回源端口"><n-input-number v-model:value="serverForm.options.vhost_http_port" :min="1" :max="65535" placeholder="默认 8080" /></n-form-item>
@@ -1264,6 +1264,8 @@ const serverStartingId = ref<number | null>(null)
const agentActionId = ref<number | null>(null)
const measuringId = ref<number | null>(null)
const savingServer = ref(false)
// 80/443 回源端口的二次确认结果,按弹窗会话记录,避免每次保存都重复弹窗
const confirmedPrivilegedVhost = ref('')
const showServerModal = ref(false)
@@ -1356,7 +1358,7 @@ const serverHelpItems = computed(() => {
{ title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' },
]
if (serverFormTab.value === 'server') return [
{ title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443,80/443 会与 VPS 上的 Nginx 冲突。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' },
{ title: 'frps vhost 与域名反代', text: 'vhostHTTPPort/vhostHTTPSPort 是 Nginx 反代的回源端口,同时决定 subDomainHost、tcpmux 等域名入口;留空用默认 8080/8443。80/443 通常与 VPS 上的 Nginx 冲突,确需使用时保存前会二次确认,请先释放该端口。修改后需「下发 frps 配置」并重启 frps,再重新部署各规则。' },
{ title: 'TCP/UDP 端口分配', text: 'allowPorts 只限制 tcp/udp 规则使用的 remotePort,不控制 vhost 回源端口;实际端口仍需在云安全组与 VPS 主机防火墙放行。' },
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' },
]
@@ -1463,6 +1465,7 @@ function resetServerForm() {
function openServerModal(server?: FrpServer) {
editingServer.value = server ?? null
serverFormTab.value = 'basic'
confirmedPrivilegedVhost.value = ''
resetServerForm()
if (server) {
Object.assign(serverForm, { ...server, token: '', oidc_client_secret: '', agent_token: '', ssh_secret: '' })
@@ -1671,10 +1674,7 @@ function validateServerForm(): string {
if (!editingServer.value && !form.oidc_client_secret.trim()) return 'OIDC 需填写客户端密钥'
}
if (form.options.vhost_http_port === 80 || form.options.vhost_https_port === 443) {
return 'frps 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用),请改为 8080/8443'
}
// 80/443 不再硬拦:由 saveServer 弹窗二次确认,后端要求同一次请求带上确认标记
const ports: Array<[string, number]> = [
['控制端口', form.server_port],
['HTTP 回源端口', form.options.vhost_http_port || 8080],
@@ -1708,12 +1708,41 @@ function validateServerForm(): string {
return ''
}
// 当前表单里用到的 80/443 回源端口;空数组表示无需确认。
function privilegedVhostPorts(): number[] {
const ports: number[] = []
if (serverForm.options.vhost_http_port === 80) ports.push(80)
if (serverForm.options.vhost_https_port === 443) ports.push(443)
return ports
}
// 80/443 通常已被 VPS 上的 Nginx 占用,放行前必须让用户确认自己已经释放了端口。
function confirmPrivilegedVhostPorts(ports: number[]): Promise<boolean> {
return new Promise((resolve) => {
dialog.warning({
title: `确认使用 ${ports.join(' / ')} 作为 frps 回源端口`,
content: `${ports.join(' / ')} 通常已被 VPS 上的 Nginx 占用。继续前请先停止占用该端口的服务,否则 frps 会因端口冲突启动失败,隧道和公网反代都会不可用。保存后需在服务端详情「下发 frps 配置」并重启 frps 才生效。`,
positiveText: '端口已释放,继续保存',
negativeText: '取消',
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
})
})
}
async function saveServer() {
const invalid = validateServerForm()
if (invalid) {
message.error(invalid)
return
}
const privileged = privilegedVhostPorts()
if (privileged.length > 0 && confirmedPrivilegedVhost.value !== privileged.join(',')) {
const confirmed = await confirmPrivilegedVhostPorts(privileged)
if (!confirmed) return
confirmedPrivilegedVhost.value = privileged.join(',')
}
if (serverForm.mgmt_enabled && !serverForm.agent_url.trim()) {
message.warning('未填写 Agent 地址:保存后可在本页通过 SSH 安装 Agent 自动回填')
}
@@ -1730,6 +1759,7 @@ async function saveServer() {
tls_certificate: serverForm.tls_certificate, tls_key: serverForm.tls_key, tls_trusted_ca: serverForm.tls_trusted_ca,
clear_tls_certificate: serverForm.clear_tls_certificate, clear_tls_key: serverForm.clear_tls_key,
clear_tls_trusted_ca: serverForm.clear_tls_trusted_ca,
allow_privileged_vhost_ports: privileged.length > 0,
}
if (editingServer.value) await api.updateFrpServer(editingServer.value.id, payload)
else await api.createFrpServer(payload)
+38 -6
View File
@@ -61,8 +61,18 @@
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
<!-- frps 回源端口 80/443:VPS 上由 frps 直接对外,公网反代没有位置(也不需要自动部署) -->
<n-alert v-if="publicProxyInapplicable" type="info" :bordered="false" class="page-alert">
<div class="alert-body__text">
<strong>该服务端的 frps 回源端口是 {{ privilegedVhostPorts.join(' / ') }},公网反代不适用</strong>
<span>80/443 已由 frps 直接对外使用,VPS 上没有 Nginx 的位置:域名访问走 FRP 隧道直达 NAS,不需要再部署 Nginx 反代,本页也不会自动下发任何路由。</span>
<span>如需使用公网反代,请到「公网服务端」页把回源端口改回 8080/8443,再回本页部署。</span>
<span>若这台 VPS 上还留着以前部署的 Nginx 路由,需先把回源端口改回 8080/8443,才能在本页移除。</span>
</div>
</n-alert>
<!-- 部署漂移:库里的期望与 VPS 上的实际不一致时才出现(P0-2) -->
<n-alert v-if="driftItems.length > 0" type="warning" :bordered="false" class="page-alert">
<n-alert v-if="driftItems.length > 0 && !publicProxyInapplicable" type="warning" :bordered="false" class="page-alert">
<div class="alert-body">
<div class="alert-body__text">
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
@@ -81,7 +91,7 @@
</n-alert>
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
<n-alert v-if="nginxBlocking" :type="nginxBlocking.type" :bordered="false" class="page-alert">
<n-alert v-if="nginxBlocking && !publicProxyInapplicable" :type="nginxBlocking.type" :bordered="false" class="page-alert">
<div class="alert-body">
<div class="alert-body__text">
<strong>{{ nginxBlocking.title }}</strong>
@@ -100,7 +110,7 @@
</n-alert>
<!-- 入口行:agent / frps 生命周期在另一页 -->
<div class="entrance-row">
<div v-if="!publicProxyInapplicable" class="entrance-row">
<span class="entrance-row__text">
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
</span>
@@ -108,7 +118,7 @@
</div>
<!-- ① 统计区 -->
<section class="stats-row" aria-label="反代概览">
<section v-if="!publicProxyInapplicable" class="stats-row" aria-label="反代概览">
<div class="stat-card">
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
<div class="stat-card__value">{{ candidates.length }}</div>
@@ -136,7 +146,7 @@
</section>
<!-- ② 环境与 Nginx -->
<HavlineCard class="section-card nginx-card" title="Nginx(公网反代依赖)">
<HavlineCard v-if="!publicProxyInapplicable" class="section-card nginx-card" title="Nginx(公网反代依赖)">
<div class="section-body">
<div class="field-grid">
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
@@ -170,7 +180,7 @@
</HavlineCard>
<!-- ③ 规则台账 -->
<HavlineCard class="section-card" title="规则台账">
<HavlineCard v-if="!publicProxyInapplicable" class="section-card" title="规则台账">
<div class="section-body">
<p class="form-hint">穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)</p>
<div v-if="candidates.length" class="route-table-wrap">
@@ -485,6 +495,18 @@ const rollingBack = ref(false)
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.value !== null)
// frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,没有 Nginx 的位置:
// 公网反代整体不适用,也不需要(更不应该)自动部署。
const privilegedVhostPorts = computed<string[]>(() => {
const options = server.value?.options
if (!options) return []
const ports: string[] = []
if ((options.vhost_http_port || 8080) === 80) ports.push('80')
if ((options.vhost_https_port || 8443) === 443) ports.push('443')
return ports
})
const publicProxyInapplicable = computed(() => privilegedVhostPorts.value.length > 0)
// 统计:已生效 / 待处理 / 健康通过 / 当前连接
const deployedCount = computed(() => candidates.value.filter((item) => stateOf(item).state === 'loaded').length)
const pendingCount = computed(() => candidates.value.length - deployedCount.value)
@@ -541,6 +563,16 @@ async function load() {
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
const current = server.value
if (!current) return
// 80/443 回源端口下公网反代不适用:清空台账,也不必再去问 agent 的路由/漂移/健康
if (publicProxyInapplicable.value) {
candidates.value = []
routeDetails.value = []
routeHealth.value = []
routeDrift.value = null
agentStatus.value = null
statusLoaded.value = false
return
}
if (options.silent === false) manualRefreshing.value = true
const failures: string[] = []
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {