@@ -439,6 +439,20 @@ func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error
|
||||
return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
|
||||
}
|
||||
|
||||
// PushFRPS404Page 把自定义 404 页面内容写到 VPS 上的指定路径。
|
||||
// frps 的 custom404Page 只认文件路径,所以内容必须先落到 VPS 上。
|
||||
func (c *AgentClient) PushFRPS404Page(ctx context.Context, path, content string) error {
|
||||
var result map[string]any
|
||||
return c.request(ctx, http.MethodPut, "/api/v1/frps/404-page",
|
||||
map[string]string{"path": path, "content": content}, &result)
|
||||
}
|
||||
|
||||
// DeleteFRPS404Page 移除 VPS 上的自定义 404 页面文件(文件不存在也算成功)。
|
||||
func (c *AgentClient) DeleteFRPS404Page(ctx context.Context, path string) error {
|
||||
var result map[string]any
|
||||
return c.request(ctx, http.MethodDelete, "/api/v1/frps/404-page?path="+url.QueryEscape(path), nil, &result)
|
||||
}
|
||||
|
||||
func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
|
||||
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
|
||||
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 自定义 404 页面:填了「页面内容」就用 Havline 托管的那份(用户不用自己去 VPS 放文件),
|
||||
// 只有手填路径时才用路径,两者都没填就不输出 custom404Page。
|
||||
func TestBuildFRPSServerConfigCustom404Page(t *testing.T) {
|
||||
svc, ctx := newVhostPortTestService(t)
|
||||
|
||||
base := func() ServerOptions {
|
||||
return ServerOptions{AuthMethod: "token", LogLevel: "info", Protocol: "tcp"}
|
||||
}
|
||||
manualPath := base()
|
||||
manualPath.Custom404Page = "/var/www/404.html"
|
||||
managed := base()
|
||||
managed.Custom404HTML = "<html><body>404</body></html>"
|
||||
both := base()
|
||||
both.Custom404Page = "/var/www/404.html"
|
||||
both.Custom404HTML = "<html><body>404</body></html>"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
options ServerOptions
|
||||
want string
|
||||
notWant string
|
||||
}{
|
||||
{name: "都没填", key: "none", options: base(), notWant: "custom404Page"},
|
||||
{name: "只填路径", key: "path", options: manualPath, want: `custom404Page = "/var/www/404.html"`},
|
||||
{
|
||||
name: "填了页面内容", key: "html", options: managed,
|
||||
want: `custom404Page = "/etc/frp/havline-404.html"`,
|
||||
notWant: "/var/www/404.html",
|
||||
},
|
||||
{
|
||||
name: "两者都填时以内容为准", key: "both", options: both,
|
||||
want: `custom404Page = "/etc/frp/havline-404.html"`,
|
||||
notWant: `custom404Page = "/var/www/404.html"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := baseVhostServerInput()
|
||||
// 每个子用例用独立名字,避免同一份测试库里重名
|
||||
in.Name = "vps-" + tc.key
|
||||
in.Options = tc.options
|
||||
server, err := svc.CreateServer(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatalf("创建服务端失败:%v", err)
|
||||
}
|
||||
content, err := svc.buildFRPSServerConfig(ctx, server)
|
||||
if err != nil {
|
||||
t.Fatalf("生成 frps.toml 失败:%v", err)
|
||||
}
|
||||
if tc.want != "" && !strings.Contains(content, tc.want) {
|
||||
t.Fatalf("期望包含 %q:\n%s", tc.want, content)
|
||||
}
|
||||
if tc.notWant != "" && strings.Contains(content, tc.notWant) {
|
||||
t.Fatalf("不应包含 %q:\n%s", tc.notWant, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
|
||||
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
|
||||
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
|
||||
// FixedAgent404PagePath 是「自定义 404 页面内容」在 VPS 上的固定落盘位置。
|
||||
// 界面里填的内容由 Havline 写到这个路径,frps.toml 的 custom404Page 也指向它,
|
||||
// 这样用户不需要自己去 VPS 上放文件。
|
||||
const FixedAgent404PagePath = "/etc/frp/havline-404.html"
|
||||
|
||||
func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
|
||||
// token 认证才需要取同一份 token;none / oidc 不涉及
|
||||
token := ""
|
||||
@@ -77,8 +82,12 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
||||
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
|
||||
}
|
||||
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
|
||||
if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
|
||||
b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
|
||||
// 界面里填了「页面内容」就用 Havline 下发的那份,其次才用用户自己填的 VPS 路径。
|
||||
switch {
|
||||
case strings.TrimSpace(server.Options.Custom404HTML) != "":
|
||||
b.WriteString(fmt.Sprintf("custom404Page = %q\n", FixedAgent404PagePath))
|
||||
case strings.TrimSpace(server.Options.Custom404Page) != "":
|
||||
b.WriteString(fmt.Sprintf("custom404Page = %q\n", strings.TrimSpace(server.Options.Custom404Page)))
|
||||
}
|
||||
// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
|
||||
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
|
||||
|
||||
@@ -158,8 +158,11 @@ type ServerOptions struct {
|
||||
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
|
||||
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
|
||||
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
|
||||
SubDomainHost string `json:"sub_domain_host,omitempty"`
|
||||
Custom404Page string `json:"custom_404_page,omitempty"`
|
||||
SubDomainHost string `json:"sub_domain_host,omitempty"`
|
||||
Custom404Page string `json:"custom_404_page,omitempty"`
|
||||
// Custom404HTML 是「自定义 404 页面内容」:填了就不用自己去 VPS 放文件,
|
||||
// Havline 会把它写到 fixedAgent404PagePath 并让 frps.toml 指向那里。
|
||||
Custom404HTML string `json:"custom_404_html,omitempty"`
|
||||
TLSForce bool `json:"tls_force"`
|
||||
AllowPorts string `json:"allow_ports,omitempty"`
|
||||
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
|
||||
|
||||
@@ -1463,6 +1463,8 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
|
||||
warning := ""
|
||||
if server, err := s.store.GetServer(ctx, serverID); err != nil {
|
||||
warning = "frps 已安装,但读取服务端配置失败:" + err.Error()
|
||||
} else if err := s.syncAgent404Page(ctx, client, server); err != nil {
|
||||
warning = "frps 已安装,但自定义 404 页面下发失败:" + err.Error()
|
||||
} else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil {
|
||||
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
|
||||
} else if err := client.PushFRPSConfig(ctx, content); err != nil {
|
||||
@@ -1494,6 +1496,10 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 先把「自定义 404 页面内容」落到 VPS 上:frps.toml 里写的是文件路径,文件必须先存在
|
||||
if err := s.syncAgent404Page(ctx, client, server); err != nil {
|
||||
return err
|
||||
}
|
||||
// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
|
||||
content, err := s.buildFRPSServerConfig(ctx, server)
|
||||
if err != nil {
|
||||
@@ -1502,6 +1508,19 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
|
||||
return client.PushFRPSConfig(ctx, content)
|
||||
}
|
||||
|
||||
// syncAgent404Page 把界面里填写的 404 页面内容写到 VPS 的固定路径;内容清空时删掉旧文件。
|
||||
func (s *Service) syncAgent404Page(ctx context.Context, client *AgentClient, server Server) error {
|
||||
html := strings.TrimSpace(server.Options.Custom404HTML)
|
||||
if html == "" {
|
||||
// 没填内容就清掉 Havline 托管的那份,避免残留旧页面
|
||||
return client.DeleteFRPS404Page(ctx, FixedAgent404PagePath)
|
||||
}
|
||||
if err := client.PushFRPS404Page(ctx, FixedAgent404PagePath, html); err != nil {
|
||||
return fmt.Errorf("自定义 404 页面下发失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,
|
||||
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
|
||||
func errPrivilegedVhostNoPublicProxy() error {
|
||||
@@ -2316,6 +2335,31 @@ func (s *Service) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart 重启全部 frpc:先停后起。
|
||||
//
|
||||
// 比手动「停止 → 启动」多一层保障:Stop 会把 Agent 隧道一起停掉,而单独点「启动」
|
||||
// 不会重建它们;重启在这里顺带把隧道重新拉起来,避免停一次之后就再也连不上。
|
||||
func (s *Service) Restart(ctx context.Context) error {
|
||||
stopErr := s.Stop(ctx)
|
||||
startErr := s.Start(ctx)
|
||||
if startErr != nil {
|
||||
if stopErr != nil {
|
||||
return fmt.Errorf("停止阶段:%v;启动阶段:%w", stopErr, startErr)
|
||||
}
|
||||
return startErr
|
||||
}
|
||||
// 隧道重建可能要等远端,放到后台跑,不占着这次请求
|
||||
tunnelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
go func() {
|
||||
defer cancel()
|
||||
s.StartAgentTunnels(tunnelCtx)
|
||||
}()
|
||||
if stopErr != nil {
|
||||
s.logger.Warn("frpc 重启时停止阶段报错,但已重新启动", "module", "FRP", "error", stopErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Reload(ctx context.Context) error {
|
||||
s.applyMu.Lock()
|
||||
defer s.applyMu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user