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
+13
View File
@@ -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_`
+95 -36
View File
@@ -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 版本备份目录改为隐藏目录(.<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"
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)
+3 -2
View File
@@ -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) {
+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)
}
})
}
}
+8
View File
@@ -465,6 +465,14 @@
<section class="form-section">
<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>
<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">
<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>
+1 -1
View File
@@ -469,7 +469,7 @@
→ frps(vhostHTTPPort,按 Host 分流)
→ FRP HTTP 隧道 → NAS 内网应用</pre>
<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>FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;</li>
<li>应用配置后验证。</li>