Compare commits
3
Commits
d1c498a286
..
v1.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2856040883 | ||
|
|
0f68101503 | ||
|
|
50b8cc1351 |
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
> 说明:1.1.0 起的相近补丁版本已合并整理并重新编排版本号;0.1.0-1.0.2 保持原样。
|
> 说明:1.1.0 起的相近补丁版本已合并整理并重新编排版本号;0.1.0-1.0.2 保持原样。
|
||||||
|
|
||||||
|
## [v1.9.3] - 2026-10-11
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **frps vhost 回源端口支持 80/443(需二次确认)**:服务端表单不再硬性拒绝 `vhostHTTPPort = 80` / `vhostHTTPSPort = 443`,改为保存时弹窗二次确认,提示「80/443 通常已被 VPS 上的 Nginx 占用,需先停止占用该端口的服务,否则 frps 会因端口冲突启动失败」;确认后请求携带 `allow_privileged_vhost_ports` 提交并由后端校验放行,未携带标记的请求(包括直接调 API)仍被拒绝。生成的 `frps.toml` 会写入一行风险注释,各规则的公网反代回源地址(HTTP → `127.0.0.1:80`、HTTPS → `127.0.0.1:443`)随回源端口自动跟随
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- **80/443 回源端口下公网反代不适用,也不再自动部署**:frps 直接占用 80/443 意味着 VPS 上不再有 Nginx 的位置,「公网反代」页检测到该配置后显示说明卡片,隐藏统计、Nginx、规则台账与部署漂移区块,并跳过 agent 的路由 / 漂移 / 健康请求;后端 `DeployAgentRoute` 与 `RedeployAllRoutes`(含漂移一键修复)一律拒绝下发,自动下发路径 `syncAgentRoute` 直接跳过。历史遗留的 Nginx 路由需把回源端口改回 8080/8443 后才能在本页移除
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 新增 `internal/frp/vhost_port_test.go`:80/443 未二次确认被拒、已确认可保存、生成的 `frps.toml` 含风险注释、公网反代回源地址跟随 80/443
|
||||||
|
- 新增回源端口特权判定边界用例(默认 8080/8443、显式 0、自定义高位端口)与「默认端口不受 80/443 规则影响」对照测试
|
||||||
|
- `go test ./...`、`npm run typecheck` 通过
|
||||||
|
|
||||||
## [v1.9.2] - 2026-10-07
|
## [v1.9.2] - 2026-10-07
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.2-22c55e?style=flat-square" alt="发布版本" /></a>
|
<a href="https://github.com/mrlee233/havline/releases"><img src="https://img.shields.io/badge/version-1.9.3-22c55e?style=flat-square" alt="发布版本" /></a>
|
||||||
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
|
<img src="https://img.shields.io/badge/目标平台-飞牛_fnOS-22c55e?style=flat-square" alt="飞牛 fnOS" />
|
||||||
<img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
|
<img src="https://img.shields.io/badge/其他环境-未测试-94a3b8?style=flat-square" alt="其他环境未测试" />
|
||||||
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
|
<img src="https://img.shields.io/badge/Go-1.23-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go" />
|
||||||
@@ -56,7 +56,6 @@ Havline 基于开源项目 [O96u/Fonu](https://github.com/O96u/Fonu) **1.0.2**
|
|||||||
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
|
| **Cloudflare Tunnel** | cloudflared 按需下载、Account/API Token 预检、隧道与路由管理、DNS 同步、健康巡检和漂移修复 |
|
||||||
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
|
| **一键升级** | Compose + updater sidecar,支持构建/重建/健康检查,并在部分失败场景下尝试回滚 |
|
||||||
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
|
| **飞牛交付** | 提供 Docker 离线镜像包与 Native 应用两种形态,支持统一网关 `/app/havline` 接入 |
|
||||||
| **文档体系** | 增加图文操作手册、部署/升级/迁移文档、飞牛打包方案和 Cloudflare/FRP 专题方案 |
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -230,7 +229,7 @@ Havline 提供两种飞牛应用形态,均支持统一网关入口 `/app/havli
|
|||||||
### Docker 应用(离线镜像包)
|
### Docker 应用(离线镜像包)
|
||||||
|
|
||||||
- 独立仓库:<https://github.com/mrlee233/havline>
|
- 独立仓库:<https://github.com/mrlee233/havline>
|
||||||
- 安装包:`havline-1.9.2.fpk`
|
- 安装包:`havline-1.9.3.fpk`
|
||||||
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
|
- 包内包含 amd64 / arm64 离线镜像,安装时执行 `docker load`
|
||||||
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
|
- Compose 使用本地镜像并设置 `pull_policy: never`,不依赖外部 Registry
|
||||||
- 管理界面通过统一网关 `/app/havline` 访问
|
- 管理界面通过统一网关 `/app/havline` 访问
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ type frpServerRequest struct {
|
|||||||
TLSTrustedCA string `json:"tls_trusted_ca"`
|
TLSTrustedCA string `json:"tls_trusted_ca"`
|
||||||
ClearTLSCertificate bool `json:"clear_tls_certificate"`
|
ClearTLSCertificate bool `json:"clear_tls_certificate"`
|
||||||
ClearTLSKey bool `json:"clear_tls_key"`
|
ClearTLSKey bool `json:"clear_tls_key"`
|
||||||
|
// 二次确认标记:vhost 回源端口填 80/443 时前端必须带上
|
||||||
|
AllowPrivilegedVhostPorts bool `json:"allow_privileged_vhost_ports"`
|
||||||
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
|
ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,6 +1063,7 @@ func (request frpServerRequest) serverInput() frp.ServerInput {
|
|||||||
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
|
DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
|
||||||
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
|
TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
|
||||||
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
|
ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
|
||||||
|
AllowPrivilegedVhostPorts: request.AllowPrivilegedVhostPorts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
|||||||
httpsPort = 8443
|
httpsPort = 8443
|
||||||
}
|
}
|
||||||
if httpPort == 80 || httpsPort == 443 {
|
if httpPort == 80 || httpsPort == 443 {
|
||||||
return "", fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置")
|
// 已由前端二次确认;这里只把后果写进生成的文件,便于日后在 VPS 上直接看懂
|
||||||
|
b.WriteString("# ⚠️ 回源端口使用 80/443:请确认 VPS 上没有 Nginx 等其他进程占用,否则 frps 会因端口冲突启动失败\n")
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
|
b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
|
||||||
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
|
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ type ServerInput struct {
|
|||||||
ClearTLSCertificate bool
|
ClearTLSCertificate bool
|
||||||
ClearTLSKey bool
|
ClearTLSKey bool
|
||||||
ClearTLSTrustedCA bool
|
ClearTLSTrustedCA bool
|
||||||
|
|
||||||
|
// AllowPrivilegedVhostPorts 是本次请求的二次确认标记:vhost 回源端口填 80/443 时必须为 true。
|
||||||
|
// 只作用于本次校验,不落库。
|
||||||
|
AllowPrivilegedVhostPorts bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyInput struct {
|
type ProxyInput struct {
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ type RedeployAllResult struct {
|
|||||||
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
|
// RedeployAllRoutes 把该服务端上所有启用中的公网反代规则重新下发一遍。
|
||||||
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
|
// 逐条执行、单条失败不中断(沿用 Reload 的隔离报错风格),返回逐条结果供前端汇总展示。
|
||||||
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
|
func (s *Service) RedeployAllRoutes(ctx context.Context, serverID int64) (RedeployAllResult, error) {
|
||||||
|
server, err := s.store.GetServer(ctx, serverID)
|
||||||
|
if err != nil {
|
||||||
|
return RedeployAllResult{}, err
|
||||||
|
}
|
||||||
|
// 80/443 回源端口的服务端不做任何公网反代下发(含漂移一键修复)
|
||||||
|
if privilegedVhostPorts(server.Options) {
|
||||||
|
return RedeployAllResult{}, errPrivilegedVhostNoPublicProxy()
|
||||||
|
}
|
||||||
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
proxies, err := s.store.ListEnabledProxies(ctx, serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RedeployAllResult{}, err
|
return RedeployAllResult{}, err
|
||||||
|
|||||||
+18
-4
@@ -1502,6 +1502,12 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
|
|||||||
return client.PushFRPSConfig(ctx, content)
|
return client.PushFRPSConfig(ctx, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,
|
||||||
|
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
|
||||||
|
func errPrivilegedVhostNoPublicProxy() error {
|
||||||
|
return fmt.Errorf("该服务端的 frps 回源端口为 80/443:VPS 上由 frps 直接对外,公网反代(Nginx)不适用也无需部署;如需公网反代,请到「公网服务端」页把回源端口改回 8080/8443")
|
||||||
|
}
|
||||||
|
|
||||||
// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
|
// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
|
||||||
// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
|
// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
|
||||||
func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
|
func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
|
||||||
@@ -1840,6 +1846,14 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
|
|||||||
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
|
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
|
||||||
return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
|
return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
|
||||||
}
|
}
|
||||||
|
server, serr := s.store.GetServer(ctx, proxy.ServerID)
|
||||||
|
if serr != nil {
|
||||||
|
return serr
|
||||||
|
}
|
||||||
|
// 80/443 回源端口的服务端由 frps 直接对外,VPS 上没有 Nginx 的位置,直接拒绝部署
|
||||||
|
if privilegedVhostPorts(server.Options) {
|
||||||
|
return errPrivilegedVhostNoPublicProxy()
|
||||||
|
}
|
||||||
client, ok := s.agentClient(ctx, proxy.ServerID)
|
client, ok := s.agentClient(ctx, proxy.ServerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||||
@@ -1855,10 +1869,6 @@ func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
|
|||||||
return uerr
|
return uerr
|
||||||
}
|
}
|
||||||
// vhost 端口供 agent 回源自检用
|
// vhost 端口供 agent 回源自检用
|
||||||
server, serr := s.store.GetServer(ctx, proxy.ServerID)
|
|
||||||
if serr != nil {
|
|
||||||
return serr
|
|
||||||
}
|
|
||||||
vhostPort := server.Options.VhostHTTPPort
|
vhostPort := server.Options.VhostHTTPPort
|
||||||
if vhostPort <= 0 {
|
if vhostPort <= 0 {
|
||||||
vhostPort = 8080
|
vhostPort = 8080
|
||||||
@@ -2116,6 +2126,10 @@ func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
|
|||||||
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
|
if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 80/443 回源端口的服务端由 frps 直接对外:任何自动下发都要跳过
|
||||||
|
if server, err := s.store.GetServer(ctx, proxy.ServerID); err == nil && privilegedVhostPorts(server.Options) {
|
||||||
|
return
|
||||||
|
}
|
||||||
client, ok := s.agentClient(ctx, proxy.ServerID)
|
client, ok := s.agentClient(ctx, proxy.ServerID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ func validateServerInput(in ServerInput) error {
|
|||||||
if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) {
|
if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) {
|
||||||
return fmt.Errorf("frps HTTPS 回源端口无效")
|
return fmt.Errorf("frps HTTPS 回源端口无效")
|
||||||
}
|
}
|
||||||
if in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443 {
|
// 80/443 默认由 VPS 上的 Nginx 占用,frps 再监听会因端口冲突启动失败;
|
||||||
return fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请使用 8080/8443")
|
// 仍允许使用(例如 VPS 上不跑 Nginx、由 frps 直接对外),但必须由前端二次确认后带标记提交。
|
||||||
|
if (in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443) && !in.AllowPrivilegedVhostPorts {
|
||||||
|
return fmt.Errorf("frps vhost 回源端口 80/443 通常由 VPS 上的 Nginx 占用;确认该端口已释放后,请确认风险提示再保存")
|
||||||
}
|
}
|
||||||
if !validDashboardAddr(in.Options.DashboardAddr) {
|
if !validDashboardAddr(in.Options.DashboardAddr) {
|
||||||
return fmt.Errorf("frps 管理接口地址无效")
|
return fmt.Errorf("frps 管理接口地址无效")
|
||||||
@@ -366,6 +368,21 @@ func validPort(port int) bool {
|
|||||||
return port > 0 && port <= 65535
|
return port > 0 && port <= 65535
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// privilegedVhostPorts 判断 frps 回源端口是否落在 80/443(未配置时按默认 8080/8443 计)。
|
||||||
|
// 这种拓扑下 VPS 上由 frps 直接对外,没有 Nginx 监听 80/443 的位置,
|
||||||
|
// 公网反代(Nginx 路由下发)既不适用、也不应该部署。
|
||||||
|
func privilegedVhostPorts(options ServerOptions) bool {
|
||||||
|
httpPort := options.VhostHTTPPort
|
||||||
|
if httpPort <= 0 {
|
||||||
|
httpPort = 8080
|
||||||
|
}
|
||||||
|
httpsPort := options.VhostHTTPSPort
|
||||||
|
if httpsPort <= 0 {
|
||||||
|
httpsPort = 8443
|
||||||
|
}
|
||||||
|
return httpPort == 80 || httpsPort == 443
|
||||||
|
}
|
||||||
|
|
||||||
func validHost(host string) bool {
|
func validHost(host string) bool {
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
|
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
|
||||||
|
|||||||
@@ -57,17 +57,22 @@ func TestValidateServerInputVhostPorts(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
http int
|
http int
|
||||||
https int
|
https int
|
||||||
|
confirm bool
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{name: "默认端口", http: 8080, https: 8443},
|
{name: "默认端口", http: 8080, https: 8443},
|
||||||
{name: "HTTP 80 与 Nginx 冲突", http: 80, https: 8443, wantErr: true},
|
{name: "HTTP 80 未二次确认", http: 80, https: 8443, wantErr: true},
|
||||||
{name: "HTTPS 443 与 Nginx 冲突", http: 8080, https: 443, wantErr: true},
|
{name: "HTTPS 443 未二次确认", http: 8080, https: 443, wantErr: true},
|
||||||
|
{name: "HTTP 80 已二次确认", http: 80, https: 8443, confirm: true},
|
||||||
|
{name: "HTTPS 443 已二次确认", http: 8080, https: 443, confirm: true},
|
||||||
|
{name: "80 与 443 同时使用且已二次确认", http: 80, https: 443, confirm: true},
|
||||||
}
|
}
|
||||||
for _, testCase := range cases {
|
for _, testCase := range cases {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
input := normalizeServerInput(base)
|
input := normalizeServerInput(base)
|
||||||
input.Options.VhostHTTPPort = testCase.http
|
input.Options.VhostHTTPPort = testCase.http
|
||||||
input.Options.VhostHTTPSPort = testCase.https
|
input.Options.VhostHTTPSPort = testCase.https
|
||||||
|
input.AllowPrivilegedVhostPorts = testCase.confirm
|
||||||
err := validateServerInput(input)
|
err := validateServerInput(input)
|
||||||
if (err != nil) != testCase.wantErr {
|
if (err != nil) != testCase.wantErr {
|
||||||
t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr)
|
t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr)
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package frp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/havline/havline/internal/certificate"
|
||||||
|
"github.com/havline/havline/internal/config"
|
||||||
|
"github.com/havline/havline/internal/db"
|
||||||
|
"github.com/havline/havline/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newVhostPortTestService 建一个带真实 SQLite 的 Service:80/443 的放行是「校验 + 生成 frps.toml」
|
||||||
|
// 两段链路共同决定的,纯函数测试覆盖不到。
|
||||||
|
func newVhostPortTestService(t *testing.T) (*Service, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(dir, "havline.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
box, err := secret.NewBox("vhost-port-test-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
svc := NewService(config.Config{DataDir: dir}, NewStore(conn), box, logger, certificate.NewStore(conn))
|
||||||
|
if err := svc.EnsureDirs(); err != nil {
|
||||||
|
t.Fatalf("ensure dirs: %v", err)
|
||||||
|
}
|
||||||
|
return svc, context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseVhostServerInput() ServerInput {
|
||||||
|
return ServerInput{
|
||||||
|
Name: "test-vps",
|
||||||
|
ServerAddr: "10.0.0.1",
|
||||||
|
ServerPort: 7000,
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test-token",
|
||||||
|
Options: ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未二次确认时,80/443 必须被拒绝。
|
||||||
|
func TestCreateServerPrivilegedVhostPortsRequiresConfirmation(t *testing.T) {
|
||||||
|
svc, ctx := newVhostPortTestService(t)
|
||||||
|
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
http int
|
||||||
|
https int
|
||||||
|
}{
|
||||||
|
{name: "HTTP 80", http: 80, https: 8443},
|
||||||
|
{name: "HTTPS 443", http: 8080, https: 443},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
in := baseVhostServerInput()
|
||||||
|
in.Options.VhostHTTPPort = testCase.http
|
||||||
|
in.Options.VhostHTTPSPort = testCase.https
|
||||||
|
if _, err := svc.CreateServer(ctx, in); err == nil {
|
||||||
|
t.Fatal("未二次确认时保存 80/443 应被拒绝")
|
||||||
|
} else if !strings.Contains(err.Error(), "80/443") {
|
||||||
|
t.Fatalf("错误信息应点明 80/443:%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次确认后允许保存,生成的 frps.toml 要带风险注释且不能报错。
|
||||||
|
func TestCreateServerPrivilegedVhostPortsConfirmed(t *testing.T) {
|
||||||
|
svc, ctx := newVhostPortTestService(t)
|
||||||
|
|
||||||
|
in := baseVhostServerInput()
|
||||||
|
in.Options.VhostHTTPPort = 80
|
||||||
|
in.Options.VhostHTTPSPort = 443
|
||||||
|
in.AllowPrivilegedVhostPorts = true
|
||||||
|
server, err := svc.CreateServer(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("二次确认后保存应成功:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vhost 端口只在存在 http/https/tcpmux 规则时才写入 frps.toml
|
||||||
|
if _, err := svc.CreateProxy(ctx, ProxyInput{
|
||||||
|
ServerID: server.ID, Name: "nas-web", Type: "http",
|
||||||
|
LocalIP: "127.0.0.1", LocalPort: 18080,
|
||||||
|
CustomDomains: []string{"nas.example.com"}, Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("创建 http 规则失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := svc.buildFRPSServerConfig(ctx, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("已放行的 80/443 不应导致 frps.toml 生成失败:%v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"vhostHTTPPort = 80", "vhostHTTPSPort = 443", "80/443"} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Fatalf("frps.toml 缺少 %q:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反代回源地址必须跟着 80/443 走,否则 VPS 上 Nginx 会指向错端口。
|
||||||
|
func TestAgentUpstreamFollowsPrivilegedVhostPorts(t *testing.T) {
|
||||||
|
svc, ctx := newVhostPortTestService(t)
|
||||||
|
|
||||||
|
in := baseVhostServerInput()
|
||||||
|
in.Options.VhostHTTPPort = 80
|
||||||
|
in.Options.VhostHTTPSPort = 443
|
||||||
|
in.AllowPrivilegedVhostPorts = true
|
||||||
|
server, err := svc.CreateServer(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("保存失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpProxy, err := svc.CreateProxy(ctx, ProxyInput{
|
||||||
|
ServerID: server.ID, Name: "nas-web", Type: "http",
|
||||||
|
LocalIP: "127.0.0.1", LocalPort: 18080,
|
||||||
|
CustomDomains: []string{"nas.example.com"}, Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 http 规则失败:%v", err)
|
||||||
|
}
|
||||||
|
if got, err := svc.agentUpstreamFor(ctx, httpProxy); err != nil {
|
||||||
|
t.Fatalf("解析回源地址失败:%v", err)
|
||||||
|
} else if got != "127.0.0.1:80" {
|
||||||
|
t.Fatalf("HTTP 回源地址 = %q,期望 127.0.0.1:80", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpsProxy, err := svc.CreateProxy(ctx, ProxyInput{
|
||||||
|
ServerID: server.ID, Name: "nas-web-tls", Type: "https",
|
||||||
|
LocalIP: "127.0.0.1", LocalPort: 18080,
|
||||||
|
CustomDomains: []string{"nas-tls.example.com"}, Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 https 规则失败:%v", err)
|
||||||
|
}
|
||||||
|
if got, err := svc.agentUpstreamFor(ctx, httpsProxy); err != nil {
|
||||||
|
t.Fatalf("解析回源地址失败:%v", err)
|
||||||
|
} else if got != "127.0.0.1:443" {
|
||||||
|
t.Fatalf("HTTPS 回源地址 = %q,期望 127.0.0.1:443", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivilegedVhostPortsDetection(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
options ServerOptions
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "默认 8080/8443", options: ServerOptions{}, want: false},
|
||||||
|
{name: "留空但显式 0", options: ServerOptions{VhostHTTPPort: 0, VhostHTTPSPort: 0}, want: false},
|
||||||
|
{name: "HTTP 80", options: ServerOptions{VhostHTTPPort: 80}, want: true},
|
||||||
|
{name: "HTTPS 443", options: ServerOptions{VhostHTTPSPort: 443}, want: true},
|
||||||
|
{name: "自定义高位端口", options: ServerOptions{VhostHTTPPort: 18080, VhostHTTPSPort: 18443}, want: false},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
if got := privilegedVhostPorts(testCase.options); got != testCase.want {
|
||||||
|
t.Fatalf("privilegedVhostPorts() = %v, 期望 %v", got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 80/443 回源端口下公网反代不适用:单条部署与全量重部署(含漂移一键修复)都必须被拒绝。
|
||||||
|
func TestPublicReverseProxyRejectedForPrivilegedVhostPorts(t *testing.T) {
|
||||||
|
svc, ctx := newVhostPortTestService(t)
|
||||||
|
|
||||||
|
in := baseVhostServerInput()
|
||||||
|
in.Options.VhostHTTPPort = 80
|
||||||
|
in.Options.VhostHTTPSPort = 443
|
||||||
|
in.AllowPrivilegedVhostPorts = true
|
||||||
|
server, err := svc.CreateServer(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("保存失败:%v", err)
|
||||||
|
}
|
||||||
|
proxy, err := svc.CreateProxy(ctx, ProxyInput{
|
||||||
|
ServerID: server.ID, Name: "nas-web", Type: "http",
|
||||||
|
LocalIP: "127.0.0.1", LocalPort: 18080,
|
||||||
|
CustomDomains: []string{"nas.example.com"}, Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 http 规则失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.DeployAgentRoute(ctx, proxy.ID); err == nil {
|
||||||
|
t.Fatal("80/443 回源端口下部署公网反代应被拒绝")
|
||||||
|
} else if !strings.Contains(err.Error(), "80/443") {
|
||||||
|
t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.RedeployAllRoutes(ctx, server.ID); err == nil {
|
||||||
|
t.Fatal("80/443 回源端口下全量重部署应被拒绝")
|
||||||
|
} else if !strings.Contains(err.Error(), "80/443") {
|
||||||
|
t.Fatalf("错误信息应说明 80/443 不适用公网反代:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对照组:默认 8080/8443 时不应被 80/443 规则拦下(此时失败是「没配 agent」,属于另一条链路)。
|
||||||
|
func TestPublicReverseProxyNotBlockedForDefaultVhostPorts(t *testing.T) {
|
||||||
|
svc, ctx := newVhostPortTestService(t)
|
||||||
|
|
||||||
|
server, err := svc.CreateServer(ctx, baseVhostServerInput())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("保存失败:%v", err)
|
||||||
|
}
|
||||||
|
proxy, err := svc.CreateProxy(ctx, ProxyInput{
|
||||||
|
ServerID: server.ID, Name: "nas-web", Type: "http",
|
||||||
|
LocalIP: "127.0.0.1", LocalPort: 18080,
|
||||||
|
CustomDomains: []string{"nas.example.com"}, Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建 http 规则失败:%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.DeployAgentRoute(ctx, proxy.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("未配置 agent 时部署应失败")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "不适用也无需部署") {
|
||||||
|
t.Fatalf("默认端口不应被 80/443 规则拦下,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "havline-web",
|
"name": "havline-web",
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "havline-web",
|
"name": "havline-web",
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.11.1",
|
"@codemirror/commands": "^6.11.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "havline-web",
|
"name": "havline-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"packageManager": "npm@10.9.4",
|
"packageManager": "npm@10.9.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -676,6 +676,8 @@ export interface FrpServerPayload {
|
|||||||
clear_tls_certificate?: boolean
|
clear_tls_certificate?: boolean
|
||||||
clear_tls_key?: boolean
|
clear_tls_key?: boolean
|
||||||
clear_tls_trusted_ca?: boolean
|
clear_tls_trusted_ca?: boolean
|
||||||
|
/** 二次确认标记:vhost 回源端口使用 80/443 时必须为 true(前端弹窗确认后置位)*/
|
||||||
|
allow_privileged_vhost_ports?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FrpAgentRouteCandidate {
|
export interface FrpAgentRouteCandidate {
|
||||||
|
|||||||
@@ -492,7 +492,7 @@
|
|||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
style="margin-bottom: 12px"
|
style="margin-bottom: 12px"
|
||||||
>
|
>
|
||||||
80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。
|
80/443 通常由 VPS 上的 Nginx 占用。继续使用前请先停止占用该端口的服务,保存时会再次弹窗确认;端口仍被占用时 frps 会因冲突启动失败。若 VPS 上还要保留 Nginx,请改用 8080/8443。
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<div class="form-grid">
|
<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>
|
<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 agentActionId = ref<number | null>(null)
|
||||||
const measuringId = ref<number | null>(null)
|
const measuringId = ref<number | null>(null)
|
||||||
const savingServer = ref(false)
|
const savingServer = ref(false)
|
||||||
|
// 80/443 回源端口的二次确认结果,按弹窗会话记录,避免每次保存都重复弹窗
|
||||||
|
const confirmedPrivilegedVhost = ref('')
|
||||||
|
|
||||||
const showServerModal = ref(false)
|
const showServerModal = ref(false)
|
||||||
|
|
||||||
@@ -1356,7 +1358,7 @@ const serverHelpItems = computed(() => {
|
|||||||
{ title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' },
|
{ title: '连接调优', text: '心跳与 TCP 多路复用保持默认即可;网络不稳定时可加大超时,连接池可降低高并发时的建连延迟。' },
|
||||||
]
|
]
|
||||||
if (serverFormTab.value === 'server') return [
|
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: 'TCP/UDP 端口分配', text: 'allowPorts 只限制 tcp/udp 规则使用的 remotePort,不控制 vhost 回源端口;实际端口仍需在云安全组与 VPS 主机防火墙放行。' },
|
||||||
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' },
|
{ title: 'frps 管理接口', text: 'frps 开启 webServer(dashboard)后填写,Havline 借此拉取穿透规则的实时连接与流量;地址与端口需成对填写,与连接认证无关。' },
|
||||||
]
|
]
|
||||||
@@ -1463,6 +1465,7 @@ function resetServerForm() {
|
|||||||
function openServerModal(server?: FrpServer) {
|
function openServerModal(server?: FrpServer) {
|
||||||
editingServer.value = server ?? null
|
editingServer.value = server ?? null
|
||||||
serverFormTab.value = 'basic'
|
serverFormTab.value = 'basic'
|
||||||
|
confirmedPrivilegedVhost.value = ''
|
||||||
resetServerForm()
|
resetServerForm()
|
||||||
if (server) {
|
if (server) {
|
||||||
Object.assign(serverForm, { ...server, token: '', oidc_client_secret: '', agent_token: '', ssh_secret: '' })
|
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 (!editingServer.value && !form.oidc_client_secret.trim()) return 'OIDC 需填写客户端密钥'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.options.vhost_http_port === 80 || form.options.vhost_https_port === 443) {
|
// 80/443 不再硬拦:由 saveServer 弹窗二次确认,后端要求同一次请求带上确认标记
|
||||||
return 'frps 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用),请改为 8080/8443'
|
|
||||||
}
|
|
||||||
|
|
||||||
const ports: Array<[string, number]> = [
|
const ports: Array<[string, number]> = [
|
||||||
['控制端口', form.server_port],
|
['控制端口', form.server_port],
|
||||||
['HTTP 回源端口', form.options.vhost_http_port || 8080],
|
['HTTP 回源端口', form.options.vhost_http_port || 8080],
|
||||||
@@ -1708,12 +1708,41 @@ function validateServerForm(): string {
|
|||||||
return ''
|
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() {
|
async function saveServer() {
|
||||||
const invalid = validateServerForm()
|
const invalid = validateServerForm()
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
message.error(invalid)
|
message.error(invalid)
|
||||||
return
|
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()) {
|
if (serverForm.mgmt_enabled && !serverForm.agent_url.trim()) {
|
||||||
message.warning('未填写 Agent 地址:保存后可在本页通过 SSH 安装 Agent 自动回填')
|
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,
|
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_certificate: serverForm.clear_tls_certificate, clear_tls_key: serverForm.clear_tls_key,
|
||||||
clear_tls_trusted_ca: serverForm.clear_tls_trusted_ca,
|
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)
|
if (editingServer.value) await api.updateFrpServer(editingServer.value.id, payload)
|
||||||
else await api.createFrpServer(payload)
|
else await api.createFrpServer(payload)
|
||||||
|
|||||||
@@ -61,8 +61,18 @@
|
|||||||
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
|
||||||
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
|
<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) -->
|
<!-- 部署漂移:库里的期望与 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">
|
||||||
<div class="alert-body__text">
|
<div class="alert-body__text">
|
||||||
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
|
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
|
||||||
@@ -81,7 +91,7 @@
|
|||||||
</n-alert>
|
</n-alert>
|
||||||
|
|
||||||
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
|
<!-- 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">
|
||||||
<div class="alert-body__text">
|
<div class="alert-body__text">
|
||||||
<strong>{{ nginxBlocking.title }}</strong>
|
<strong>{{ nginxBlocking.title }}</strong>
|
||||||
@@ -100,7 +110,7 @@
|
|||||||
</n-alert>
|
</n-alert>
|
||||||
|
|
||||||
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
<!-- 入口行:agent / frps 生命周期在另一页 -->
|
||||||
<div class="entrance-row">
|
<div v-if="!publicProxyInapplicable" class="entrance-row">
|
||||||
<span class="entrance-row__text">
|
<span class="entrance-row__text">
|
||||||
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
|
Agent 与 frps 的安装、升级、启停、配置下发在「公网服务端」页;本页只负责把规则发布到 Nginx
|
||||||
</span>
|
</span>
|
||||||
@@ -108,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ① 统计区 -->
|
<!-- ① 统计区 -->
|
||||||
<section class="stats-row" aria-label="反代概览">
|
<section v-if="!publicProxyInapplicable" class="stats-row" aria-label="反代概览">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
<n-icon :component="LayersOutline" class="stat-card__icon stat-card__icon--blue" aria-hidden="true" />
|
||||||
<div class="stat-card__value">{{ candidates.length }}</div>
|
<div class="stat-card__value">{{ candidates.length }}</div>
|
||||||
@@ -136,7 +146,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ② 环境与 Nginx -->
|
<!-- ② 环境与 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="section-body">
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
|
<div><label>可用性</label><span class="field-tags"><StatusBadge :kind="nginxKind" :text="nginxText" /></span></div>
|
||||||
@@ -170,7 +180,7 @@
|
|||||||
</HavlineCard>
|
</HavlineCard>
|
||||||
|
|
||||||
<!-- ③ 规则台账 -->
|
<!-- ③ 规则台账 -->
|
||||||
<HavlineCard class="section-card" title="规则台账">
|
<HavlineCard v-if="!publicProxyInapplicable" class="section-card" title="规则台账">
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
<p class="form-hint">穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)</p>
|
<p class="form-hint">穿透规则保存后不会自动改 VPS:点「部署反代」才会发布到 Nginx(自动推送证书并做回源自检)</p>
|
||||||
<div v-if="candidates.length" class="route-table-wrap">
|
<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 server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
|
||||||
const agentStatusOk = computed(() => statusLoaded.value && agentStatus.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 deployedCount = computed(() => candidates.value.filter((item) => stateOf(item).state === 'loaded').length)
|
||||||
const pendingCount = computed(() => candidates.value.length - deployedCount.value)
|
const pendingCount = computed(() => candidates.value.length - deployedCount.value)
|
||||||
@@ -541,6 +563,16 @@ async function load() {
|
|||||||
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
if (!current) return
|
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
|
if (options.silent === false) manualRefreshing.value = true
|
||||||
const failures: string[] = []
|
const failures: string[] = []
|
||||||
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {
|
const track = async <T>(label: string, fn: () => Promise<T>, assign: (value: T) => void) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user