diff --git a/CHANGELOG.md b/CHANGELOG.md index deb873a..95bc15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 修复 + +- **frps 启动失败不再误报成功**:主程序现在保留 Agent 返回的 `ok=false` 与错误内容,`systemctl start` 后 frps 未进入运行状态时会如实返回失败,不再固定提示「启动成功」 +- **frps 启动诊断增强**:Agent 版本提升到 `0.15.1`,启动失败时汇总 systemd 状态、`systemctl status`、journal、`/var/log/frps-systemd.log`,并在 journald 不可用时直接运行 `frps -c /etc/frp/frps.toml` 2 秒捕获真实错误;两个内嵌 Agent 二进制已重编 +- **frps 与 Nginx 端口冲突防呆**:frps `vhostHTTPPort` / `vhostHTTPSPort` 禁止使用 `80/443`,服务端保存和 `frps.toml` 生成阶段都会提示改用 `8080/8443`,避免 VPS 上 Nginx 已占用 80/443 时 frps 启动即退出 +- **frps 启动前清理旧进程**:systemd 服务未运行但检测到 systemd 之外的手工 `frps` 进程时,启动/重启前先停止旧进程,避免端口占用导致 systemd 反复 auto-restart +- **公网反代隧道提示修正**:frps 管理接口可访问但没有任何注册代理时,不再显示 `frps 已注册代理: ;`,改为提示 frpc 未登录或尚未注册该域名 + +### 测试 + +- 新增 Agent 操作结果解析、Agent 版本解析、frps vhost 端口冲突校验和隧道提示文案测试 +- `go test ./...`、`npm run typecheck` 通过 + ### 变更 - **品牌全量切换为 Havline**:项目显示名、Go module、命令目录、二进制、环境变量、数据库、Cookie、Docker/Compose、Agent、Updater、脚本和当前文档统一切换到 `Havline / havline / HAVLINE_` diff --git a/internal/agent/server.go b/internal/agent/server.go index c542ae4..c0c00a1 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -31,37 +31,7 @@ import ( "github.com/havline/havline/internal/sysinfo" ) -// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。 -// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记 -// 找出真正被加载的文件所在目录,零猜测);include 链递归与 HAVLINE_AGENT_NGINX_CONF_DIR 保留为回退。 -// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。 -// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。 -// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。 -// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。 -// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。 -// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进 -// nginx 配置注释(改为只在 PUT 响应里回一次)。 -// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。 -// 0.12.2:新增域名 vhost 的版本列表 / 对比 / 回滚端点;写配置前留版本,校验失败时恢复上一个可用版本。 -// 0.12.3:frps 日志尾巴改为最近 20 行、最新在前,并用 short-iso 时间戳(默认格式不带年份、分不清哪一天)。 -// 0.12.4:日志行重写成「2026-09-22 18:43:34 进程: 消息」,去掉 T 分隔、时区偏移与每行重复的主机名。 -// 0.12.5:修正 frps 官方校验文件名(frp 一直叫 frp_sha256_checksums.txt,原代码拼的是 frp_<版本>_sha256sums.txt,导致首次安装报 HTTP 404)。 -// 0.12.6:nginxPath 探测不到时返回空串(原来回落到配置里的裸名字 "nginx"), -// 修掉「VPS 未安装 Nginx」告警与一键安装按钮永远不出现、以及把它误报成「配置校验未通过」的问题。 -// 0.12.7:vhost 目录探测不再要求被加载文件带 .conf 后缀(Debian 的 sites-enabled/default 不带), -// 并在失败信息里带上最后一次的具体原因与常见的显式指定目录。 -// 0.12.8:Agent Bearer Token 改为常量时间比较,避免认证路径上的长度/内容时序泄漏。 -// 0.12.9:限流状态文件损坏时拒绝覆盖,避免保存单条规则时清空其他域名的限流配置。 -// 0.13.0:默认只监听回环地址,非回环监听必须显式允许;状态接口暴露监听范围与传输安全。 -// 0.13.1:nginx 版本备份目录改为隐藏目录(..versions)并自动迁移旧目录, -// 修掉旧 .versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。 -// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 .versions 目录并迁移后重试, -// 修掉「配置目录还没探测出来、迁移逻辑没机会执行」导致 HTTPS 直连持续失败的问题。 -// 0.13.3:HTTPS 反代改用 default_server 并在 reload 后做本机证书指纹自检, -// 证书不匹配时回滚并回报实际指纹与端口占用进程,区分「nginx 未生效」与「公网链路被接管」。 -// 0.14.0:HTTPS 端口自动选择(7443/8443/9443)、当前证书信息查询、主机防火墙检测与 ufw/firewalld 显式放行。 -// 0.14.1:vhost 模板迁出到 internal/nginxtmpl,统一 TLS、安全头、allow/deny、WebSocket 与限流输出。 -const AgentVersion = "0.15.0" +const AgentVersion = "0.15.1" // Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。 type Config struct { @@ -336,7 +306,7 @@ func (s *Server) status(w http.ResponseWriter, _ *http.Request) { "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")), "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil, "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)), - "frps_log_tail": strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)), + "frps_log_tail": frpsLogTailDesc(), "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)), "routes": s.routeNames(), "checked_at": time.Now().UTC().Format(time.RFC3339), @@ -460,7 +430,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) { } } if writeUnit { - unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath) + unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by havline-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\nStandardOutput=append:%s\nStandardError=append:%s\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath, frpsSystemdLogPath, frpsSystemdLogPath) if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()}) return @@ -515,22 +485,111 @@ func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"}) return } + notes := make([]string, 0, 2) + if req.Action == "start" || req.Action == "restart" { + if note := cleanupOrphanFRPS(); note != "" { + notes = append(notes, note) + } + } if output, err := command.CombinedOutput(); err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))}) return } time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到 active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil - result := map[string]any{"ok": true, "action": req.Action, "frps_active": active} + result := map[string]any{"ok": true, "action": req.Action, "frps_active": active, "notes": notes} if !active { // 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功” - journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput() result["ok"] = false - result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal))) + result["error"] = s.frpsFailureDetail(req.Action) } writeJSON(w, http.StatusOK, result) } +const frpsSystemdLogPath = "/var/log/frps-systemd.log" + +// cleanupOrphanFRPS 在 systemd 服务未运行时清理手工启动的 frps, +// 避免旧进程占用 7000/vhost 端口后导致 systemd 启动即退出。 +func cleanupOrphanFRPS() string { + if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil { + return "" + } + if exec.Command("pgrep", "-x", "frps").Run() != nil { + return "" + } + if err := exec.Command("pkill", "-x", "frps").Run(); err != nil { + return "检测到 systemd 之外运行的 frps 进程,但停止失败" + } + time.Sleep(300 * time.Millisecond) + return "检测到 systemd 之外运行的 frps 进程,已先停止旧进程" +} + +// frpsFailureDetail 汇总 systemd 状态、输出日志与直接启动结果, +// 避免 journald 不可用时只剩 “No journal files were found”。 +func (s *Server) frpsFailureDetail(action string) string { + lines := []string{fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态", action)} + if out := strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", + "-p", "ActiveState", "-p", "SubState", "-p", "Result", "-p", "ExecMainCode", "-p", "ExecMainStatus")); out != "" { + lines = append(lines, "systemd 状态:\n"+out) + } + if out := strings.TrimSpace(execCommandOutput("systemctl", "status", "frps", "--no-pager", "-l")); out != "" { + lines = append(lines, "systemctl status:\n"+tailText(out, 2000)) + } + if out := strings.TrimSpace(journalTail("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") { + lines = append(lines, "journal 日志:\n"+out) + } + if out := strings.TrimSpace(readFRPSSystemdLog()); out != "" { + lines = append(lines, "frps 输出:\n"+out) + } + if out := s.probeFRPSStartup(); out != "" { + lines = append(lines, "直接启动诊断:\n"+out) + } + return strings.Join(lines, "\n\n") +} + +// probeFRPSStartup 在无 systemd 日志时直接运行 frps 2 秒: +// 配置错误会立即返回;配置正常时会打印启动成功后被 timeout 结束。 +func (s *Server) probeFRPSStartup() string { + if exec.Command("pgrep", "-x", "frps").Run() == nil { + return "已有 frps 进程在运行,跳过直接启动诊断" + } + out, err := exec.Command("timeout", "2s", s.cfg.FrpsBinary, "-c", s.cfg.FrpsConfigPath).CombinedOutput() + text := strings.TrimSpace(string(out)) + hint := frpsStartupHint(text) + if err != nil { + if text == "" { + text = err.Error() + } + return tailText(text+hint, 2400) + } + if text == "" { + return "frps 直接运行 2 秒未报错,可能已正常启动后被超时结束;请检查 systemd 单元和端口占用" + } + return tailText(text+hint, 2400) +} + +func frpsStartupHint(output string) string { + if !strings.Contains(output, "address already in use") { + return "" + } + return "\n\n建议:80/443 通常由 VPS 上的 Nginx 占用;请把 frps 的 vhostHTTPPort/vhostHTTPSPort 改为 8080/8443,保存后重新下发 frps 配置并重启 frps。" +} + +func frpsLogTailDesc() string { + if out := strings.TrimSpace(journalTailDesc("frps", frpsLogTailLines)); out != "" && !strings.Contains(out, "No journal files were found") { + return out + } + return readFRPSSystemdLog() +} + +func readFRPSSystemdLog() string { + data, err := os.ReadFile(frpsSystemdLogPath) + if err != nil { + return "" + } + return tailText(string(data), 8000) +} + func downloadToFile(url, path string) error { client := &http.Client{Timeout: 10 * time.Minute} resp, err := client.Get(url) diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index bea9887..e41304a 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -809,11 +809,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request writeError(r, w, http.StatusBadRequest, "请求格式无效") return } - if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil { + result, err := h.svc.AgentFRPSAction(r.Context(), id, body.Action) + if err != nil { writeError(r, w, http.StatusBadGateway, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action}) + writeJSON(w, http.StatusOK, result) } func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) { diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go index 73f4be3..e254c54 100644 --- a/internal/frp/agent_client.go +++ b/internal/frp/agent_client.go @@ -378,8 +378,31 @@ func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error { return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil) } -func (c *AgentClient) FRPSAction(ctx context.Context, action string) error { - return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil) +func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) { + var result map[string]any + if err := c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, &result); err != nil { + return nil, err + } + return normalizeFRPSActionResult(result) +} + +// normalizeFRPSActionResult 保留 agent 的 ok=false 与日志,避免后端把它误判为成功。 +func normalizeFRPSActionResult(result map[string]any) (map[string]any, error) { + if result == nil { + return nil, errors.New("agent 未返回 frps 操作结果") + } + ok, exists := result["ok"].(bool) + if !exists { + return nil, errors.New("agent 未返回 frps 操作结果") + } + if ok { + return result, nil + } + if msg, _ := result["error"].(string); strings.TrimSpace(msg) != "" { + return result, nil + } + result["error"] = "frps 未进入运行状态,请查看 frps 日志" + return result, nil } func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) { diff --git a/internal/frp/agent_client_test.go b/internal/frp/agent_client_test.go new file mode 100644 index 0000000..9d3eb50 --- /dev/null +++ b/internal/frp/agent_client_test.go @@ -0,0 +1,36 @@ +package frp + +import "testing" + +func TestNormalizeFRPSActionResult(t *testing.T) { + if _, err := normalizeFRPSActionResult(nil); err == nil { + t.Fatal("空响应应返回错误") + } + if _, err := normalizeFRPSActionResult(map[string]any{}); err == nil { + t.Fatal("缺少 ok 字段应返回错误") + } + + failed, err := normalizeFRPSActionResult(map[string]any{ + "ok": false, + "error": "systemctl start 已执行但 frps 未进入运行状态", + }) + if err != nil { + t.Fatalf("ok=false 应保留给前端展示,got %v", err) + } + if failed["ok"] != false || failed["error"] == "" { + t.Fatalf("失败结果未保留:%+v", failed) + } + + failedWithoutMessage, err := normalizeFRPSActionResult(map[string]any{"ok": false}) + if err != nil { + t.Fatalf("ok=false 应补齐默认错误,got %v", err) + } + if failedWithoutMessage["error"] != "frps 未进入运行状态,请查看 frps 日志" { + t.Fatalf("默认错误不正确:%+v", failedWithoutMessage) + } + + success, err := normalizeFRPSActionResult(map[string]any{"ok": true, "action": "start"}) + if err != nil || success["ok"] != true { + t.Fatalf("成功结果解析失败:result=%+v err=%v", success, err) + } +} diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go index 6831453..67384f5 100644 --- a/internal/frp/agent_installer.go +++ b/internal/frp/agent_installer.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + agentruntime "github.com/havline/havline/internal/agent" "github.com/havline/havline/internal/frp/agentbin" ) @@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) { if token == "" { return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output)) } + version := extractAgentVersion(output) + if version == "" { + return "", fmt.Errorf("安装完成但未识别 Agent 版本,无法确认升级结果: %s", strings.TrimSpace(output)) + } + if version != agentruntime.AgentVersion { + return "", fmt.Errorf("VPS 上安装的 Agent 版本 %s 与主程序内嵌版本 %s 不一致;请重新构建主程序镜像后再执行「升级 Agent」", version, agentruntime.AgentVersion) + } return token, nil } @@ -105,6 +113,15 @@ func extractToken(output string) string { return "" } +func extractAgentVersion(output string) string { + for _, line := range strings.Split(output, "\n") { + if version, found := strings.CutPrefix(strings.TrimSpace(line), "agent version: "); found { + return strings.TrimSpace(version) + } + } + return "" +} + func agentInstallScript() ([]byte, error) { // 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。 return []byte(`#!/usr/bin/env bash diff --git a/internal/frp/agent_installer_test.go b/internal/frp/agent_installer_test.go new file mode 100644 index 0000000..1cff19b --- /dev/null +++ b/internal/frp/agent_installer_test.go @@ -0,0 +1,13 @@ +package frp + +import "testing" + +func TestExtractAgentVersion(t *testing.T) { + output := "agent version: 0.15.1\nhavline-agent installed\nToken: abc123" + if got := extractAgentVersion(output); got != "0.15.1" { + t.Fatalf("版本解析失败,got %q", got) + } + if got := extractAgentVersion("havline-agent installed\nToken: abc123"); got != "" { + t.Fatalf("无版本行时应返回空串,got %q", got) + } +} diff --git a/internal/frp/agentbin/havline-agent-linux-amd64 b/internal/frp/agentbin/havline-agent-linux-amd64 index b350c73..4f835b0 100644 Binary files a/internal/frp/agentbin/havline-agent-linux-amd64 and b/internal/frp/agentbin/havline-agent-linux-amd64 differ diff --git a/internal/frp/agentbin/havline-agent-linux-arm64 b/internal/frp/agentbin/havline-agent-linux-arm64 index 3baedea..5b566d3 100644 Binary files a/internal/frp/agentbin/havline-agent-linux-arm64 and b/internal/frp/agentbin/havline-agent-linux-arm64 differ diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go index df97eaa..250c68c 100644 --- a/internal/frp/frps_template.go +++ b/internal/frp/frps_template.go @@ -65,6 +65,9 @@ func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (str if httpsPort <= 0 { httpsPort = 8443 } + if httpPort == 80 || httpsPort == 443 { + return "", fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请在服务端表单改为 8080/8443 后重新生成配置") + } b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort)) b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort)) } diff --git a/internal/frp/route_health_test.go b/internal/frp/route_health_test.go index d59cc6d..43aeb01 100644 --- a/internal/frp/route_health_test.go +++ b/internal/frp/route_health_test.go @@ -33,3 +33,17 @@ func TestSummarizeRouteHealthEmpty(t *testing.T) { t.Fatalf("expected no failed servers, got %+v", summary.FailedServers) } } + +func TestTunnelMismatchDetail(t *testing.T) { + empty := tunnelMismatchDetail(nil, "5211.36800.cc") + wantEmpty := "frps 当前没有注册任何代理;请确认 frpc 已启动并成功登录,域名 5211.36800.cc 尚未绑定" + if empty != wantEmpty { + t.Fatalf("空代理列表提示不正确,got %q", empty) + } + + bound := tunnelMismatchDetail([]string{"nas-web"}, "5211.36800.cc") + wantBound := "frps 已注册代理: nas-web;但均未绑定域名 5211.36800.cc" + if bound != wantBound { + t.Fatalf("代理未绑定域名提示不正确,got %q", bound) + } +} diff --git a/internal/frp/service.go b/internal/frp/service.go index 4bc26de..2a17ceb 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -1354,10 +1354,10 @@ func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, versio return result, nil } -func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) error { +func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) (map[string]any, error) { client, ok := s.agentClient(ctx, serverID) if !ok { - return fmt.Errorf("公网 agent 未配置或 Token 未保存") + return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存") } return client.FRPSAction(ctx, action) } @@ -1645,12 +1645,13 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt } else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查) names := make([]string, 0, len(tunnelNames)) for name := range tunnelNames { - if !strings.HasPrefix(name, "domain:") { + name = strings.TrimSpace(name) + if name != "" && !strings.HasPrefix(name, "domain:") { names = append(names, name) } } sort.Strings(names) - item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain) + item.TunnelDetail = tunnelMismatchDetail(names, domain) } } else { item.TunnelDetail = dashboardDiag @@ -1667,6 +1668,15 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt return out, nil } +// tunnelMismatchDetail 区分「frps 一个代理都没有」和「有代理但没绑定当前域名」, +// 避免空列表被拼成 “frps 已注册代理: ;” 这种误导性提示。 +func tunnelMismatchDetail(names []string, domain string) string { + if len(names) == 0 { + return fmt.Sprintf("frps 当前没有注册任何代理;请确认 frpc 已启动并成功登录,域名 %s 尚未绑定", domain) + } + return fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain) +} + // GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。 func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) { return s.store.GetProxy(ctx, id) diff --git a/internal/frp/validate.go b/internal/frp/validate.go index 6559a1e..2bed028 100644 --- a/internal/frp/validate.go +++ b/internal/frp/validate.go @@ -23,6 +23,15 @@ func validateServerInput(in ServerInput) error { if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) { return fmt.Errorf("frps 管理接口端口无效") } + if in.Options.VhostHTTPPort != 0 && !validPort(in.Options.VhostHTTPPort) { + return fmt.Errorf("frps HTTP 回源端口无效") + } + if in.Options.VhostHTTPSPort != 0 && !validPort(in.Options.VhostHTTPSPort) { + return fmt.Errorf("frps HTTPS 回源端口无效") + } + if in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443 { + return fmt.Errorf("frps vhost 回源端口不能使用 80/443(通常由 VPS 上的 Nginx 占用);请使用 8080/8443") + } if !validDashboardAddr(in.Options.DashboardAddr) { return fmt.Errorf("frps 管理接口地址无效") } diff --git a/internal/frp/validate_test.go b/internal/frp/validate_test.go index ebfc192..26a91c8 100644 --- a/internal/frp/validate_test.go +++ b/internal/frp/validate_test.go @@ -41,3 +41,37 @@ func TestValidateProxyInput(t *testing.T) { }) } } + +func TestValidateServerInputVhostPorts(t *testing.T) { + base := ServerInput{ + Name: "web", + ServerAddr: "203.0.113.10", + ServerPort: 7000, + Options: ServerOptions{ + AuthMethod: "token", + LogLevel: "info", + Protocol: "tcp", + }, + } + cases := []struct { + name string + http int + https int + wantErr bool + }{ + {name: "默认端口", http: 8080, https: 8443}, + {name: "HTTP 80 与 Nginx 冲突", http: 80, https: 8443, wantErr: true}, + {name: "HTTPS 443 与 Nginx 冲突", http: 8080, https: 443, wantErr: true}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + input := normalizeServerInput(base) + input.Options.VhostHTTPPort = testCase.http + input.Options.VhostHTTPSPort = testCase.https + err := validateServerInput(input) + if (err != nil) != testCase.wantErr { + t.Fatalf("validateServerInput() error = %v, wantErr %v", err, testCase.wantErr) + } + }) + } +} diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue index 9b300f2..fe52646 100644 --- a/web/src/views/AgentView.vue +++ b/web/src/views/AgentView.vue @@ -465,6 +465,14 @@

frps vhost 反代端口

frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。

+ + 80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。 +
diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue index 71ffdd7..1a40c1c 100644 --- a/web/src/views/FrpView.vue +++ b/web/src/views/FrpView.vue @@ -469,7 +469,7 @@ → frps(vhostHTTPPort,按 Host 分流) → FRP HTTP 隧道 → NAS 内网应用
    -
  1. 存在 http/https 规则时,本页生成的 frps.toml 已自动带 vhostHTTPPort / vhostHTTPSPort(80/443);用非标准端口请自行调整并放行防火墙;
  2. +
  3. 存在 http/https 规则时,frps.toml 会自动带 vhostHTTPPort / vhostHTTPSPort;VPS 上运行 Nginx 时请使用 8080/8443,80/443 留给 Nginx 对外监听;
  4. DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;
  5. FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;
  6. 应用配置后验证。