fix: surface frps startup failures and avoid nginx port conflicts
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 管理接口地址无效")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user