@@ -0,0 +1,80 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// frps 的 custom404Page 只认 VPS 上的文件路径,所以「自定义 404 页面内容」必须先把内容
|
||||||
|
// 落到 VPS 上。这里给 Havline 一个受控的写文件入口:只写绝对路径、只写 .html,避免变成
|
||||||
|
// 任意文件写入。
|
||||||
|
|
||||||
|
type frpsPageRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putFRPS404Page(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req frpsPageRequest
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := validatePagePath(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Content) == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "页面内容为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := atomicWrite(path, []byte(req.Content), 0o644); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path, "bytes": len(req.Content)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteFRPS404Page(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := validatePagePath(r.URL.Query().Get("path"))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePagePath 只接受绝对路径 + .html/.htm 结尾,防止这个接口被当成任意文件写入。
|
||||||
|
func validatePagePath(raw string) (string, error) {
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
// agent 只跑在 Linux 上,这里按 POSIX 绝对路径判断(用 filepath.IsAbs 会在别的平台判错)
|
||||||
|
if path == "" || !strings.HasPrefix(path, "/") {
|
||||||
|
return "", errors.New("页面路径必须是绝对路径")
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "..") {
|
||||||
|
return "", errors.New("页面路径不允许包含 ..")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(path)) {
|
||||||
|
case ".html", ".htm":
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("页面文件必须以 .html 或 .htm 结尾")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 404 页面写入接口只接受绝对路径 + .html/.htm,避免被当成任意文件写入。
|
||||||
|
func TestValidatePagePath(t *testing.T) {
|
||||||
|
ok := []string{"/etc/frp/havline-404.html", "/var/www/404.htm"}
|
||||||
|
for _, path := range ok {
|
||||||
|
if _, err := validatePagePath(path); err != nil {
|
||||||
|
t.Errorf("%s 应当通过:%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []string{"", "etc/frp/404.html", "/etc/frp/404.txt", "/etc/../etc/passwd.html"}
|
||||||
|
for _, path := range bad {
|
||||||
|
if _, err := validatePagePath(path); err == nil {
|
||||||
|
t.Errorf("%s 应当被拒绝", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ import (
|
|||||||
"github.com/havline/havline/internal/sysinfo"
|
"github.com/havline/havline/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
const AgentVersion = "0.15.4"
|
const AgentVersion = "0.15.5"
|
||||||
|
|
||||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -218,6 +218,8 @@ func agentRoutes(s *Server) []agentRoute {
|
|||||||
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
|
{http.MethodPut, "/api/v1/frps/config", s.putFRPSConfig},
|
||||||
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
|
{http.MethodPost, "/api/v1/frps/install", s.installFRPS},
|
||||||
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
|
{http.MethodPost, "/api/v1/frps/action", s.frpsAction},
|
||||||
|
{http.MethodPut, "/api/v1/frps/404-page", s.putFRPS404Page},
|
||||||
|
{http.MethodDelete, "/api/v1/frps/404-page", s.deleteFRPS404Page},
|
||||||
{http.MethodPost, "/api/v1/certs", s.putCertificate},
|
{http.MethodPost, "/api/v1/certs", s.putCertificate},
|
||||||
{http.MethodGet, "/api/v1/metrics", s.metrics},
|
{http.MethodGet, "/api/v1/metrics", s.metrics},
|
||||||
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},
|
{http.MethodGet, "/api/v1/logs/nginx", s.nginxLogs},
|
||||||
|
|||||||
@@ -985,6 +985,15 @@ func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart 重启全部 frpc:比「停止 → 启动」更稳,重启后会重建 Agent 隧道。
|
||||||
|
func (h *FRPMultiHandler) Restart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.svc.Restart(r.Context()); err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
|
func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
|
||||||
lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
|
lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
|||||||
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
|
protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
|
||||||
protect("POST /api/frpmulti/start", frpHandler.Start)
|
protect("POST /api/frpmulti/start", frpHandler.Start)
|
||||||
protect("POST /api/frpmulti/stop", frpHandler.Stop)
|
protect("POST /api/frpmulti/stop", frpHandler.Stop)
|
||||||
|
protect("POST /api/frpmulti/restart", frpHandler.Restart)
|
||||||
protect("POST /api/frpmulti/reload", frpHandler.Reload)
|
protect("POST /api/frpmulti/reload", frpHandler.Reload)
|
||||||
protect("GET /api/frpmulti/logs", frpHandler.Logs)
|
protect("GET /api/frpmulti/logs", frpHandler.Logs)
|
||||||
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
|
protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
|
||||||
|
|||||||
@@ -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)
|
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 {
|
func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
|
||||||
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
|
return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
|
||||||
"domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
|
"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,
|
// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
|
||||||
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
|
// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
|
||||||
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
|
// 密钥只在生成前一刻解密进模板,永不写入日志或其他 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) {
|
func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
|
||||||
// token 认证才需要取同一份 token;none / oidc 不涉及
|
// token 认证才需要取同一份 token;none / oidc 不涉及
|
||||||
token := ""
|
token := ""
|
||||||
@@ -77,8 +82,12 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str
|
|||||||
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
|
b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
|
||||||
}
|
}
|
||||||
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
|
// 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
|
||||||
if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
|
// 界面里填了「页面内容」就用 Havline 下发的那份,其次才用用户自己填的 VPS 路径。
|
||||||
b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
|
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 规则时必须配置监听端口
|
// tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
|
||||||
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
|
if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
|
||||||
|
|||||||
@@ -158,8 +158,11 @@ type ServerOptions struct {
|
|||||||
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
|
VhostHTTPPort int `json:"vhost_http_port,omitempty"`
|
||||||
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
|
VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
|
||||||
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
|
// frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
|
||||||
SubDomainHost string `json:"sub_domain_host,omitempty"`
|
SubDomainHost string `json:"sub_domain_host,omitempty"`
|
||||||
Custom404Page string `json:"custom_404_page,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"`
|
TLSForce bool `json:"tls_force"`
|
||||||
AllowPorts string `json:"allow_ports,omitempty"`
|
AllowPorts string `json:"allow_ports,omitempty"`
|
||||||
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
|
MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
|
||||||
|
|||||||
@@ -1463,6 +1463,8 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio
|
|||||||
warning := ""
|
warning := ""
|
||||||
if server, err := s.store.GetServer(ctx, serverID); err != nil {
|
if server, err := s.store.GetServer(ctx, serverID); err != nil {
|
||||||
warning = "frps 已安装,但读取服务端配置失败:" + err.Error()
|
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 {
|
} else if content, err := s.buildFRPSServerConfig(ctx, server); err != nil {
|
||||||
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
|
warning = "frps 已安装,但生成 frps.toml 失败:" + err.Error()
|
||||||
} else if err := client.PushFRPSConfig(ctx, content); err != nil {
|
} 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 先把「自定义 404 页面内容」落到 VPS 上:frps.toml 里写的是文件路径,文件必须先存在
|
||||||
|
if err := s.syncAgent404Page(ctx, client, server); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
|
// 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
|
||||||
content, err := s.buildFRPSServerConfig(ctx, server)
|
content, err := s.buildFRPSServerConfig(ctx, server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1502,6 +1508,19 @@ func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) err
|
|||||||
return client.PushFRPSConfig(ctx, content)
|
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 直接对外,
|
// errPrivilegedVhostNoPublicProxy 说明:frps 回源端口落在 80/443 时,VPS 上由 frps 直接对外,
|
||||||
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
|
// 没有 Nginx 的位置,公网反代(路由下发)整体不适用。
|
||||||
func errPrivilegedVhostNoPublicProxy() error {
|
func errPrivilegedVhostNoPublicProxy() error {
|
||||||
@@ -2316,6 +2335,31 @@ func (s *Service) Stop(ctx context.Context) error {
|
|||||||
return nil
|
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 {
|
func (s *Service) Reload(ctx context.Context) error {
|
||||||
s.applyMu.Lock()
|
s.applyMu.Lock()
|
||||||
defer s.applyMu.Unlock()
|
defer s.applyMu.Unlock()
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ export const api = {
|
|||||||
deleteFrpProxy: (id: number) => request<void>(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }),
|
deleteFrpProxy: (id: number) => request<void>(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }),
|
||||||
startFrp: () => request<FrpStatus>('/api/frpmulti/start', { method: 'POST' }),
|
startFrp: () => request<FrpStatus>('/api/frpmulti/start', { method: 'POST' }),
|
||||||
stopFrp: () => request<FrpStatus>('/api/frpmulti/stop', { method: 'POST' }),
|
stopFrp: () => request<FrpStatus>('/api/frpmulti/stop', { method: 'POST' }),
|
||||||
|
restartFrp: () => request<FrpStatus>('/api/frpmulti/restart', { method: 'POST' }),
|
||||||
reloadFrp: () => request<FrpStatus>('/api/frpmulti/reload', { method: 'POST' }),
|
reloadFrp: () => request<FrpStatus>('/api/frpmulti/reload', { method: 'POST' }),
|
||||||
getFrpLogs: (limit = 200, serverID?: number) => {
|
getFrpLogs: (limit = 200, serverID?: number) => {
|
||||||
const query = new URLSearchParams({ limit: String(limit) })
|
const query = new URLSearchParams({ limit: String(limit) })
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ export interface FrpServerOptions {
|
|||||||
sub_domain_host?: string
|
sub_domain_host?: string
|
||||||
/** HTTP vhost 404 页面路径(VPS 本地路径)*/
|
/** HTTP vhost 404 页面路径(VPS 本地路径)*/
|
||||||
custom_404_page?: string
|
custom_404_page?: string
|
||||||
|
/** 自定义 404 页面内容:填了就不用自己往 VPS 放文件,Havline 会写到 /etc/frp/havline-404.html */
|
||||||
|
custom_404_html?: string
|
||||||
/** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/
|
/** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/
|
||||||
tls_force?: boolean
|
tls_force?: boolean
|
||||||
/** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */
|
/** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */
|
||||||
|
|||||||
@@ -724,6 +724,44 @@
|
|||||||
<n-form-item label="自定义 404 页面(VPS 路径)"><n-input v-model:value="serverForm.options.custom_404_page" placeholder="例如 /var/www/404.html;留空用默认" /></n-form-item>
|
<n-form-item label="自定义 404 页面(VPS 路径)"><n-input v-model:value="serverForm.options.custom_404_page" placeholder="例如 /var/www/404.html;留空用默认" /></n-form-item>
|
||||||
<div class="switch-row"><span>强制 TLS(frps 只接受 TLS 连接)</span><n-switch v-model:value="serverForm.options.tls_force" /></div>
|
<div class="switch-row"><span>强制 TLS(frps 只接受 TLS 连接)</span><n-switch v-model:value="serverForm.options.tls_force" /></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 直接写/导入 404 页面内容:Havline 负责写到 VPS 上,不用自己去放文件 -->
|
||||||
|
<div class="frp-404-block">
|
||||||
|
<div class="frp-404-block__head">
|
||||||
|
<div>
|
||||||
|
<div class="frp-404-block__title">自定义 404 页面内容</div>
|
||||||
|
<div class="frp-404-block__hint">
|
||||||
|
填了这里就不用管上面的 VPS 路径:保存后点「下发配置」,Havline 会把内容写到 VPS 的
|
||||||
|
<code>/etc/frp/havline-404.html</code> 并让 frps 指向它;清空即恢复默认页面。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="frp-404-block__actions">
|
||||||
|
<n-button size="small" @click="pick404File">导入 HTML 文件</n-button>
|
||||||
|
<n-button
|
||||||
|
v-if="serverForm.options.custom_404_html"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
type="warning"
|
||||||
|
@click="serverForm.options.custom_404_html = ''"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="serverForm.options.custom_404_html"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="直接粘贴 HTML,例如:<!doctype html><html>…</html>"
|
||||||
|
class="frp-404-block__editor"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="file404Input"
|
||||||
|
type="file"
|
||||||
|
accept=".html,.htm,text/html"
|
||||||
|
class="frp-404-block__file"
|
||||||
|
@change="on404FilePicked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="switch-row"><span>frps 写文件日志(/var/log/frps.log)</span><n-switch v-model:value="serverForm.options.frps_log_to_file" /></div>
|
<div class="switch-row"><span>frps 写文件日志(/var/log/frps.log)</span><n-switch v-model:value="serverForm.options.frps_log_to_file" /></div>
|
||||||
<n-form-item v-if="serverForm.options.frps_log_to_file" label="日志保留天数"><n-input-number v-model:value="serverForm.options.frps_log_max_days" :min="1" :max="365" placeholder="默认 3" class="narrow-input" /></n-form-item>
|
<n-form-item v-if="serverForm.options.frps_log_to_file" label="日志保留天数"><n-input-number v-model:value="serverForm.options.frps_log_max_days" :min="1" :max="365" placeholder="默认 3" class="narrow-input" /></n-form-item>
|
||||||
</section>
|
</section>
|
||||||
@@ -1656,7 +1694,7 @@ function defaultServerOptions(): FrpServerOptions {
|
|||||||
dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60,
|
dial_server_keepalive: 7200, pool_count: 0, tcp_mux: true, tcp_mux_keepalive_interval: 60,
|
||||||
heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false,
|
heartbeat_interval: 30, heartbeat_timeout: 90, login_fail_exit: true, tls_disable_custom_first_byte: false, auto_start: false,
|
||||||
proxy_url: '', connect_server_local_ip: '', dns_server: '',
|
proxy_url: '', connect_server_local_ip: '', dns_server: '',
|
||||||
vhost_http_port: 8080, vhost_https_port: 8443, sub_domain_host: '', custom_404_page: '',
|
vhost_http_port: 8080, vhost_https_port: 8443, sub_domain_host: '', custom_404_page: '', custom_404_html: '',
|
||||||
tls_force: false, allow_ports: '', max_ports_per_client: 0, tcpmux_http_connect_port: 0,
|
tls_force: false, allow_ports: '', max_ports_per_client: 0, tcpmux_http_connect_port: 0,
|
||||||
frps_log_to_file: false, frps_log_max_days: 3,
|
frps_log_to_file: false, frps_log_max_days: 3,
|
||||||
dashboard_addr: '', dashboard_port: 0, dashboard_user: '',
|
dashboard_addr: '', dashboard_port: 0, dashboard_user: '',
|
||||||
@@ -1665,6 +1703,9 @@ function defaultServerOptions(): FrpServerOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 隐藏的文件选择框:导入 404 页面内容用
|
||||||
|
const file404Input = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const serverForm = reactive({
|
const serverForm = reactive({
|
||||||
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
|
name: '', server_addr: '', server_port: 7000, tls_enabled: true, tls_server_name: '', enabled: true, token: '',
|
||||||
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
|
agent_url: '', agent_token: '', ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_auth: 'key', ssh_secret: '', mgmt_enabled: true,
|
||||||
@@ -1916,6 +1957,28 @@ function openServerModal(server?: FrpServer) {
|
|||||||
showServerModal.value = true
|
showServerModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 404 页面文件导入:只需要把内容读进表单,真正的落盘由 Havline 在下发配置时完成
|
||||||
|
function pick404File() {
|
||||||
|
file404Input.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function on404FilePicked(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > 512 * 1024) {
|
||||||
|
message.warning('404 页面文件请控制在 512 KB 以内')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
serverForm.options.custom_404_html = await file.text()
|
||||||
|
message.success(`已载入 ${file.name}`)
|
||||||
|
} catch {
|
||||||
|
message.error('读取文件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openServerDetail(server: FrpServer) {
|
function openServerDetail(server: FrpServer) {
|
||||||
viewingServer.value = server
|
viewingServer.value = server
|
||||||
detailStatus.value = { available: false, availability: 0, average_latency_ms: 0, peak_latency_ms: 0, samples: [] }
|
detailStatus.value = { available: false, availability: 0, average_latency_ms: 0, peak_latency_ms: 0, samples: [] }
|
||||||
@@ -3093,4 +3156,28 @@ useVisibilityPolling(() => {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 自定义 404 页面内容:直接写 / 导入,Havline 负责写到 VPS */
|
||||||
|
.frp-404-block {
|
||||||
|
margin: 4px 0 var(--havline-space-4);
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--havline-border);
|
||||||
|
border-radius: var(--havline-radius-sm);
|
||||||
|
background: var(--havline-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frp-404-block__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--havline-space-3);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frp-404-block__title { font-size: 13px; font-weight: 600; color: var(--havline-text); }
|
||||||
|
.frp-404-block__hint { margin-top: 2px; font-size: 12px; line-height: 1.6; color: var(--havline-text-muted); }
|
||||||
|
.frp-404-block__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.frp-404-block__editor :deep(textarea) { font-family: var(--havline-mono); font-size: 12px; line-height: 1.6; }
|
||||||
|
.frp-404-block__file { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -54,6 +54,15 @@
|
|||||||
<template #icon><n-icon :component="PlayOutline" /></template>
|
<template #icon><n-icon :component="PlayOutline" /></template>
|
||||||
启动 frpc
|
启动 frpc
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<!-- 重启:改完配置或进程卡住时一次到位;停掉之后隧道会断,用这个比「停止 → 启动」更稳 -->
|
||||||
|
<n-button
|
||||||
|
:loading="actionLoading === 'restart'"
|
||||||
|
:disabled="servers.length === 0"
|
||||||
|
@click="restartClient"
|
||||||
|
>
|
||||||
|
<template #icon><n-icon :component="RefreshOutline" /></template>
|
||||||
|
重启 frpc
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -800,7 +809,7 @@ const proxies = ref<FrpProxy[]>([])
|
|||||||
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
|
const proxyTraffic = ref<Record<number, FrpProxyTraffic>>({})
|
||||||
const proxyTrafficErrors = ref<Record<number, string>>({})
|
const proxyTrafficErrors = ref<Record<number, string>>({})
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const actionLoading = ref<'start' | 'stop' | 'reload' | null>(null)
|
const actionLoading = ref<'start' | 'stop' | 'restart' | 'reload' | null>(null)
|
||||||
const savingProxy = ref(false)
|
const savingProxy = ref(false)
|
||||||
const showProxyModal = ref(false)
|
const showProxyModal = ref(false)
|
||||||
const showExport = ref(false)
|
const showExport = ref(false)
|
||||||
@@ -1227,18 +1236,20 @@ function splitText(value: string) { return value.split(/[\n,\s]+/).map((item) =>
|
|||||||
function textToMap(value: string) { return Object.fromEntries(value.split('\n').map((line) => line.split('=').map((item) => item.trim())).filter(([key, item]) => key && item)) }
|
function textToMap(value: string) { return Object.fromEntries(value.split('\n').map((line) => line.split('=').map((item) => item.trim())).filter(([key, item]) => key && item)) }
|
||||||
function mapToText(value?: Record<string, string>) { return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n') }
|
function mapToText(value?: Record<string, string>) { return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n') }
|
||||||
|
|
||||||
async function runAction(action: 'start' | 'stop' | 'reload') {
|
async function runAction(action: 'start' | 'stop' | 'restart' | 'reload') {
|
||||||
actionLoading.value = action
|
actionLoading.value = action
|
||||||
try {
|
try {
|
||||||
const methods = { start: api.startFrp, stop: api.stopFrp, reload: api.reloadFrp }
|
const methods = { start: api.startFrp, stop: api.stopFrp, restart: api.restartFrp, reload: api.reloadFrp }
|
||||||
status.value = await methods[action]()
|
status.value = await methods[action]()
|
||||||
message.success(action === 'start' ? 'frpc 已启动' : action === 'stop' ? 'frpc 已停止' : '配置已应用')
|
const tips = { start: 'frpc 已启动', stop: 'frpc 已停止', restart: 'frpc 已重启', reload: '配置已应用' }
|
||||||
|
message.success(tips[action])
|
||||||
} catch (error) { message.error(error instanceof Error ? error.message : '操作失败') }
|
} catch (error) { message.error(error instanceof Error ? error.message : '操作失败') }
|
||||||
finally { actionLoading.value = null; await load() }
|
finally { actionLoading.value = null; await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
function startClient() { return runAction('start') }
|
function startClient() { return runAction('start') }
|
||||||
function stopClient() { return runAction('stop') }
|
function stopClient() { return runAction('stop') }
|
||||||
|
function restartClient() { return runAction('restart') }
|
||||||
function reloadClient() { return runAction('reload') }
|
function reloadClient() { return runAction('reload') }
|
||||||
|
|
||||||
async function openRuntimeModal() {
|
async function openRuntimeModal() {
|
||||||
|
|||||||
Reference in New Issue
Block a user