Files
Havline/internal/frp/vhost_port_test.go
mrlee 0f68101503 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 生成、反代回源跟随与守卫对照
2026-10-11 15:18:32 +08:00

233 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}