fix: surface frps startup failures and avoid nginx port conflicts
This commit is contained in:
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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_`
|
- **品牌全量切换为 Havline**:项目显示名、Go module、命令目录、二进制、环境变量、数据库、Cookie、Docker/Compose、Agent、Updater、脚本和当前文档统一切换到 `Havline / havline / HAVLINE_`
|
||||||
|
|||||||
+95
-36
@@ -31,37 +31,7 @@ import (
|
|||||||
"github.com/havline/havline/internal/sysinfo"
|
"github.com/havline/havline/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
|
const AgentVersion = "0.15.1"
|
||||||
// 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 版本备份目录改为隐藏目录(.<name>.versions)并自动迁移旧目录,
|
|
||||||
// 修掉旧 <name>.versions 目录被 include * 当配置文件读取、nginx -t 报 pread() "Is a directory" 的问题。
|
|
||||||
// 0.13.2:nginx -T 探测失败时先从报错文本解析旧 <name>.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"
|
|
||||||
|
|
||||||
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 havline-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
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_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
|
||||||
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
"frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
|
||||||
"frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
|
"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)),
|
"dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
|
||||||
"routes": s.routeNames(),
|
"routes": s.routeNames(),
|
||||||
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -460,7 +430,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if writeUnit {
|
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 {
|
if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
|
||||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
|
||||||
return
|
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"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
|
||||||
return
|
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 {
|
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)))})
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
|
time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
|
||||||
active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
|
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 {
|
if !active {
|
||||||
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
|
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
|
||||||
journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
|
|
||||||
result["ok"] = false
|
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)
|
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 {
|
func downloadToFile(url, path string) error {
|
||||||
client := &http.Client{Timeout: 10 * time.Minute}
|
client := &http.Client{Timeout: 10 * time.Minute}
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
|
|||||||
@@ -809,11 +809,12 @@ func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request
|
|||||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||||
return
|
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())
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
return
|
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) {
|
func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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)
|
return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
|
func (c *AgentClient) FRPSAction(ctx context.Context, action string) (map[string]any, error) {
|
||||||
return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
|
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) {
|
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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
agentruntime "github.com/havline/havline/internal/agent"
|
||||||
"github.com/havline/havline/internal/frp/agentbin"
|
"github.com/havline/havline/internal/frp/agentbin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,13 @@ func (i AgentInstaller) Install(ctx context.Context) (string, error) {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
|
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
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +113,15 @@ func extractToken(output string) string {
|
|||||||
return ""
|
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) {
|
func agentInstallScript() ([]byte, error) {
|
||||||
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
|
// 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
|
||||||
return []byte(`#!/usr/bin/env bash
|
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 {
|
if httpsPort <= 0 {
|
||||||
httpsPort = 8443
|
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("vhostHTTPPort = %d\n", httpPort))
|
||||||
b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
|
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)
|
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
|
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)
|
client, ok := s.agentClient(ctx, serverID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||||
}
|
}
|
||||||
return client.FRPSAction(ctx, action)
|
return client.FRPSAction(ctx, action)
|
||||||
}
|
}
|
||||||
@@ -1645,12 +1645,13 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
|||||||
} else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
|
} else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
|
||||||
names := make([]string, 0, len(tunnelNames))
|
names := make([]string, 0, len(tunnelNames))
|
||||||
for name := range tunnelNames {
|
for name := range tunnelNames {
|
||||||
if !strings.HasPrefix(name, "domain:") {
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" && !strings.HasPrefix(name, "domain:") {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
|
item.TunnelDetail = tunnelMismatchDetail(names, domain)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
item.TunnelDetail = dashboardDiag
|
item.TunnelDetail = dashboardDiag
|
||||||
@@ -1667,6 +1668,15 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
|||||||
return out, nil
|
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 校验规则归属与域名)。
|
// GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。
|
||||||
func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
|
func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
|
||||||
return s.store.GetProxy(ctx, id)
|
return s.store.GetProxy(ctx, id)
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ func validateServerInput(in ServerInput) error {
|
|||||||
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
|
if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
|
||||||
return fmt.Errorf("frps 管理接口端口无效")
|
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) {
|
if !validDashboardAddr(in.Options.DashboardAddr) {
|
||||||
return fmt.Errorf("frps 管理接口地址无效")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -465,6 +465,14 @@
|
|||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 反代端口</h3>
|
<h3><n-icon :component="SwapHorizontalOutline" /> frps vhost 反代端口</h3>
|
||||||
<p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p>
|
<p class="form-hint">frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。</p>
|
||||||
|
<n-alert
|
||||||
|
v-if="serverForm.options.vhost_http_port === 80 || serverForm.options.vhost_https_port === 443"
|
||||||
|
type="warning"
|
||||||
|
:show-icon="false"
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
>
|
||||||
|
80/443 通常由 VPS 上的 Nginx 占用,frps 不能再次监听;请改为 8080/8443,否则 frps 会因端口冲突启动失败。
|
||||||
|
</n-alert>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<n-form-item label="HTTP 回源端口"><n-input-number v-model:value="serverForm.options.vhost_http_port" :min="1" :max="65535" placeholder="默认 8080" /></n-form-item>
|
<n-form-item label="HTTP 回源端口"><n-input-number v-model:value="serverForm.options.vhost_http_port" :min="1" :max="65535" placeholder="默认 8080" /></n-form-item>
|
||||||
<n-form-item label="HTTPS 回源端口"><n-input-number v-model:value="serverForm.options.vhost_https_port" :min="1" :max="65535" placeholder="默认 8443" /></n-form-item>
|
<n-form-item label="HTTPS 回源端口"><n-input-number v-model:value="serverForm.options.vhost_https_port" :min="1" :max="65535" placeholder="默认 8443" /></n-form-item>
|
||||||
|
|||||||
@@ -469,7 +469,7 @@
|
|||||||
→ frps(vhostHTTPPort,按 Host 分流)
|
→ frps(vhostHTTPPort,按 Host 分流)
|
||||||
→ FRP HTTP 隧道 → NAS 内网应用</pre>
|
→ FRP HTTP 隧道 → NAS 内网应用</pre>
|
||||||
<ol class="guide-steps">
|
<ol class="guide-steps">
|
||||||
<li>存在 http/https 规则时,本页生成的 frps.toml 已自动带 vhostHTTPPort / vhostHTTPSPort(80/443);用非标准端口请自行调整并放行防火墙;</li>
|
<li>存在 http/https 规则时,frps.toml 会自动带 vhostHTTPPort / vhostHTTPSPort;VPS 上运行 Nginx 时请使用 8080/8443,80/443 留给 Nginx 对外监听;</li>
|
||||||
<li>DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;</li>
|
<li>DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;</li>
|
||||||
<li>FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;</li>
|
<li>FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;</li>
|
||||||
<li>应用配置后验证。</li>
|
<li>应用配置后验证。</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user