package frp import ( "context" "fmt" "regexp" "strconv" "strings" ) // buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml, // bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。 // 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。 // FixedAgent404PagePath 是「自定义 404 页面内容」在 VPS 上的固定落盘位置。 // 界面里填的内容由 Havline 写到这个路径,frps.toml 的 custom404Page 也指向它, // 这样用户不需要自己去 VPS 上放文件。 const FixedAgent404PagePath = "/etc/frp/havline-404.html" func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) { // token 认证才需要取同一份 token;none / oidc 不涉及 token := "" if server.Options.AuthMethod == "token" { encoded, err := s.store.Token(ctx, server.ID) if err != nil { return "", err } if encoded != "" { token, err = s.secretBox.Decrypt(encoded) if err != nil { return "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置") } } } dashboardOn := server.Options.DashboardPort > 0 && strings.TrimSpace(server.Options.DashboardAddr) != "" hasVhost, vhostErr := s.hasVhostProxies(ctx, server.ID) // subDomainHost 缺口检测:frpc 侧规则填了 subdomain 时,frps 必须配置 subDomainHost 才生效 proxies, proxyErr := s.store.ListProxiesByServer(ctx, server.ID) hasSubdomain := false if proxyErr == nil { for _, p := range proxies { if strings.TrimSpace(p.Options.Subdomain) != "" { hasSubdomain = true break } } } var b strings.Builder b.WriteString("# 由 Havline 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n") b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(INI 自 v0.52.0 起已被官方弃用)\n") b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n") // TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表 b.WriteString("bindAddr = \"0.0.0.0\"\n") b.WriteString(fmt.Sprintf("bindPort = %d\n", server.ServerPort)) switch server.Options.Protocol { case "kcp": b.WriteString(fmt.Sprintf("kcpBindPort = %d # frpc 使用 kcp 协议接入;官方允许与 bindPort 相同(UDP)\n", server.ServerPort)) case "quic": b.WriteString(fmt.Sprintf("quicBindPort = %d # frpc 使用 quic 协议接入;UDP 端口官方允许与 bindPort 相同,如冲突请改为独立端口\n", server.ServerPort)) } if vhostErr == nil && hasVhost { // 官方默认 vhost 端口为 0(禁用)。默认 8080/8443:80/443 由 Nginx 占用(Nginx 80/443 → // proxy_pass 127.0.0.1:8080 → frps vhost → 隧道),可在服务端表单自定义,与 Nginx upstream 同源 httpPort := server.Options.VhostHTTPPort if httpPort <= 0 { httpPort = 8080 } httpsPort := server.Options.VhostHTTPSPort if httpsPort <= 0 { httpsPort = 8443 } if httpPort == 80 || httpsPort == 443 { // 已由前端二次确认;这里只把后果写进生成的文件,便于日后在 VPS 上直接看懂 b.WriteString("# ⚠️ 回源端口使用 80/443:请确认 VPS 上没有 Nginx 等其他进程占用,否则 frps 会因端口冲突启动失败\n") } b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort)) b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort)) } // 子域名根:规则填了 subdomain 时必须配置,否则 subdomain 不生效 if host := strings.TrimSpace(server.Options.SubDomainHost); host != "" { b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host)) } // 自定义 404 页面(HTTP vhost 命中不到规则时的响应) // 界面里填了「页面内容」就用 Havline 下发的那份,其次才用用户自己填的 VPS 路径。 switch { case strings.TrimSpace(server.Options.Custom404HTML) != "": b.WriteString(fmt.Sprintf("custom404Page = %q\n", FixedAgent404PagePath)) case strings.TrimSpace(server.Options.Custom404Page) != "": b.WriteString(fmt.Sprintf("custom404Page = %q\n", strings.TrimSpace(server.Options.Custom404Page))) } // tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口 if port := server.Options.TCPMuxHTTPConnectPort; port > 0 { b.WriteString(fmt.Sprintf("tcpmuxHTTPConnectPort = %d\n", port)) } // 强制 TLS:公网 frps 标准安全实践;开启后 frpc 不开 TLS 将无法连接 if server.Options.TLSForce { b.WriteString("transport.tls.force = true # 只接受 TLS 连接(frpc 侧必须启用 transport.tls.enable)\n") } // 传输参数与 frpc 侧同源:两端不一致会导致连不上或池被截断 if !server.Options.TCPMux { b.WriteString("transport.tcpMux = false # 与 frpc 侧 tcp_mux 同源(官方默认 true,仅在关闭时输出)\n") } if server.Options.PoolCount > 0 { b.WriteString(fmt.Sprintf("transport.maxPoolCount = %d # 不低于 frpc 侧 pool_count,避免连接池被静默截断\n", server.Options.PoolCount)) } // 端口白名单:逗号分隔的单端口或「起-止」范围,如 6000,6005-6010,7000-8000;留空不限制 if ports := strings.TrimSpace(server.Options.AllowPorts); ports != "" { b.WriteString(fmt.Sprintf("allowPorts = [\n%s\n]\n", renderAllowPorts(ports))) } // 每客户端可绑定端口上限;0 表示不限制 if maxPorts := server.Options.MaxPortsPerClient; maxPorts > 0 { b.WriteString(fmt.Sprintf("maxPortsPerClient = %d\n", maxPorts)) } // frps 文件日志:journalctl 只保留尾部,排障需完整历史时开启;写入 /var/log/frps.log if server.Options.FrpsLogToFile { maxDays := server.Options.FrpsLogMaxDays if maxDays <= 0 { maxDays = 3 } b.WriteString("\nlog.to = \"/var/log/frps.log\"\n") b.WriteString(fmt.Sprintf("log.maxDays = %d\n", maxDays)) } if !server.TLS { b.WriteString("# 安全提示:frpc 侧未启用 TLS,frps 可加 transport.tls.force = true 强制 TLS(开启后 frpc 不开 TLS 将无法连接)\n") } switch server.Options.AuthMethod { case "token": if token != "" { // 点式键与官方 frps_full_example.toml 同款(TOML 语义上与 [auth] 表等价) b.WriteString("\nauth.method = \"token\"\n") b.WriteString(fmt.Sprintf("auth.token = %q\n", token)) } else { b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Havline 重新生成本文件以保持两端一致\n") } case "none": b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n") b.WriteString("# 公网暴露的 frps 建议配置认证或用 transport.tls.force = true 强制 TLS,防止任意客户端接入\n") case "oidc": b.WriteString("\n# frpc 侧使用 OIDC 认证;frps 侧的 oidc.* 校验参数(issuer、client_id 等)依赖你的身份提供商,\n") b.WriteString("# 请参考官方 frps_full_example.toml 的 [auth] oidc 段手动配置\n") } if server.TLS { b.WriteString("\n# frpc 侧已启用 transport.tls.enable;frps 的 transport.tls.force 默认为 false,无需额外 TLS 配置即可接受 TLS 连接\n") } // webServer(frps 管理接口):与客户端 options.dashboard_* 同源 if dashboardOn { b.WriteString("\nwebServer.addr = \"0.0.0.0\"\n") b.WriteString(fmt.Sprintf("webServer.port = %d\n", server.Options.DashboardPort)) if user := strings.TrimSpace(server.Options.DashboardUser); user != "" { b.WriteString(fmt.Sprintf("webServer.user = %q\n", user)) } if encoded, err := s.store.DashboardPassword(ctx, server.ID); err == nil && encoded != "" { if password, err := s.secretBox.Decrypt(encoded); err == nil && password != "" { b.WriteString(fmt.Sprintf("webServer.password = %q\n", password)) } } b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Havline 所在机器访问该端口\n") } if hasSubdomain { b.WriteString("\n# 注意:该服务端下存在填写 subdomain 的规则;frps 需配置 subDomainHost(如 frps.example.com)后,\n") b.WriteString("# 规则的 subdomain 字段才会生效为 sub.frps.example.com;域名 DNS 也需指向本 VPS\n") } b.WriteString("\n# 防火墙放行清单(按本服务端当前配置生成)\n") b.WriteString(fmt.Sprintf("# %d/tcp # frps 控制端口\n", server.ServerPort)) if dashboardOn { b.WriteString(fmt.Sprintf("# %d/tcp # frps 管理接口(webServer)\n", server.Options.DashboardPort)) } if vhostErr == nil && hasVhost { httpPort := server.Options.VhostHTTPPort if httpPort <= 0 { httpPort = 8080 } httpsPort := server.Options.VhostHTTPSPort if httpsPort <= 0 { httpsPort = 8443 } b.WriteString(fmt.Sprintf("# %d, %d/tcp # 上方 vhostHTTPPort / vhostHTTPSPort 对应端口(仅本机 Nginx 回源需要,可不对公网开放)\n", httpPort, httpsPort)) } return b.String(), nil } // hasVhostProxies 判断该服务端下是否存在依赖 vhost 端口的规则(http/https/tcpmux)。 func (s *Service) hasVhostProxies(ctx context.Context, serverID int64) (bool, error) { proxies, err := s.store.ListProxiesByServer(ctx, serverID) if err != nil { return false, err } for _, p := range proxies { if p.Type == "http" || p.Type == "https" || p.Type == "tcpmux" { return true, nil } } return false, nil } // renderAllowPorts 把表单端口白名单文本(如 "6000,6005-6010,7000-8000")渲染为 TOML 数组行; // 非法片段直接忽略,不中断生成。 func renderAllowPorts(ports string) string { var lines []string for _, part := range strings.Split(ports, ",") { part = strings.TrimSpace(part) if part == "" { continue } if start, end, ok := strings.Cut(part, "-"); ok { startN, err1 := strconv.Atoi(strings.TrimSpace(start)) endN, err2 := strconv.Atoi(strings.TrimSpace(end)) if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 { lines = append(lines, fmt.Sprintf("{ start = %d, end = %d },", startN, endN)) } continue } if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 { lines = append(lines, fmt.Sprintf("{ single = %d },", n)) } } return strings.Join(lines, "\n") } // frpsSecretLinePattern 匹配带着敏感值的 frps.toml 行 var frpsSecretLinePattern = regexp.MustCompile(`(?i)^\s*(auth\.token|auth\.oidc\.clientsecret)\s*=`) // maskFRPSSecrets 把 frps.toml 里的敏感值换成掩码:保留键名与行结构, // 让用户在预览里仍能看清「配了哪些认证项」,但不下发明文密钥。 // 只用于预览;下发路径(PushFRPSConfigToAgent)必须用未脱敏的内容。 func maskFRPSSecrets(content string) string { lines := strings.Split(content, "\n") for i, line := range lines { if !frpsSecretLinePattern.MatchString(line) { continue } key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0]) lines[i] = fmt.Sprintf("%s = %q", key, maskedToken) } return strings.Join(lines, "\n") } // FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。 // 这是**预览**入口:密钥一律脱敏(明文只在 PushFRPSConfigToAgent 的下发路径里出现)。 func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) { server, err := s.store.GetServer(ctx, id) if err != nil { return "", err } content, err := s.buildFRPSServerConfig(ctx, server) if err != nil { return "", err } return maskFRPSSecrets(content), nil }