fix: surface frps startup failures and avoid nginx port conflicts

This commit is contained in:
2026-10-05 13:07:56 +08:00
parent 7d8a327533
commit 9aaefa3b77
16 changed files with 285 additions and 45 deletions
+25 -2
View File
@@ -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) {
+36
View File
@@ -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)
}
}
+17
View File
@@ -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
+13
View File
@@ -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)
}
}
Binary file not shown.
Binary file not shown.
+3
View File
@@ -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))
}
+14
View File
@@ -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)
}
}
+14 -4
View File
@@ -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)
+9
View File
@@ -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 管理接口地址无效")
}
+34
View File
@@ -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)
}
})
}
}