Files
mrlee e4edbdcef0
CI / verify (push) Canceled after 0s
tcp新增端口校验逻辑
2026-10-19 15:53:59 +08:00

459 lines
16 KiB
Go

package frp
import (
"encoding/json"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"
)
var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func validateServerInput(in ServerInput) error {
if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
return fmt.Errorf("服务端名称长度应为 1-64 个字符")
}
if !validHost(in.ServerAddr) {
return fmt.Errorf("FRP 服务端地址无效")
}
if !validPort(in.ServerPort) {
return fmt.Errorf("FRP 服务端端口无效")
}
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 回源端口无效")
}
// 80/443 默认由 VPS 上的 Nginx 占用,frps 再监听会因端口冲突启动失败;
// 仍允许使用(例如 VPS 上不跑 Nginx、由 frps 直接对外),但必须由前端二次确认后带标记提交。
if (in.Options.VhostHTTPPort == 80 || in.Options.VhostHTTPSPort == 443) && !in.AllowPrivilegedVhostPorts {
return fmt.Errorf("frps vhost 回源端口 80/443 通常由 VPS 上的 Nginx 占用;确认该端口已释放后,请确认风险提示再保存")
}
if !validDashboardAddr(in.Options.DashboardAddr) {
return fmt.Errorf("frps 管理接口地址无效")
}
if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
return fmt.Errorf("TLS 服务名无效")
}
if !validAuthMethod(in.Options.AuthMethod) {
return fmt.Errorf("不支持的认证方式")
}
if !validLogLevel(in.Options.LogLevel) {
return fmt.Errorf("日志级别无效")
}
if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
return fmt.Errorf("日志保留天数应为 0-3650")
}
if !validProtocol(in.Options.Protocol) {
return fmt.Errorf("传输协议无效")
}
if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
return fmt.Errorf("代理或 OIDC 地址无效")
}
if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
}
if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
return fmt.Errorf("传输参数不能小于 0")
}
if len([]rune(in.Options.Location)) > 120 {
return fmt.Errorf("服务端位置不能超过 120 个字符")
}
return nil
}
func validateProxyInput(in ProxyInput) error {
if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
}
if in.Options.Plugin == nil {
if !validHost(in.LocalIP) {
return fmt.Errorf("内网地址无效")
}
if !validPort(in.LocalPort) {
return fmt.Errorf("内网端口无效")
}
}
switch in.Type {
case "tcp", "udp":
if in.RemotePort == nil || !validPort(*in.RemotePort) {
return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
}
if len(in.CustomDomains) > 0 {
return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
}
case "http", "https":
if in.RemotePort != nil {
return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
case "tcpmux":
if in.Options.Multiplexer != "httpconnect" {
return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
}
if len(in.CustomDomains) == 0 {
return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
}
for _, domain := range in.CustomDomains {
if !validDomain(domain) {
return fmt.Errorf("自定义域名无效:%s", domain)
}
}
if in.RemotePort != nil {
return fmt.Errorf("TCPMUX 规则不能设置远程端口")
}
case "stcp", "sudp", "xtcp":
if strings.TrimSpace(in.Options.SecretKey) == "" {
return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
}
if in.RemotePort != nil {
return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
}
default:
return fmt.Errorf("不支持的 FRP 规则类型")
}
if err := validateProxyOptions(in); err != nil {
return err
}
return nil
}
func validateProxyOptions(in ProxyInput) error {
opts := in.Options
if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
return fmt.Errorf("限速位置只能是 client 或 server")
}
if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
return fmt.Errorf("代理协议版本只能是 v1 或 v2")
}
if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
return fmt.Errorf("健康检查类型只能是 tcp 或 http")
}
if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
return fmt.Errorf("健康检查参数不能小于 0")
}
if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
return fmt.Errorf("负载均衡组设置后必须填写组密钥")
}
return validateProxyPlugin(opts.Plugin)
}
// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
func validateProxyPlugin(plugin *ProxyPluginOptions) error {
if plugin == nil {
return nil
}
pluginType := strings.TrimSpace(plugin.Type)
if pluginType == "" {
return fmt.Errorf("插件类型不能为空")
}
validAddr := func(addr string, label string) error {
addr = strings.TrimSpace(addr)
host, portText, ok := strings.Cut(addr, ":")
if !ok || !validHost(host) || !validPort(parseInt(portText)) {
return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
}
return nil
}
validSecretFile := func(path, label string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
}
return nil
}
switch pluginType {
case "unix_domain_socket":
if strings.TrimSpace(plugin.UnixPath) == "" {
return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
}
case "static_file":
if strings.TrimSpace(plugin.LocalPath) == "" {
return fmt.Errorf("插件 static_file 需要提供静态文件目录")
}
case "http_proxy", "socks5":
// 用户名/密码可选
case "http2https", "http2http":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
case "https2http", "https2https", "tls2raw":
if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
return err
}
if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
return err
}
if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
return err
}
default:
return fmt.Errorf("不支持的插件类型:%s", pluginType)
}
return nil
}
// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
func parseInt(value string) int {
value = strings.TrimSpace(value)
number := 0
if value == "" {
return 0
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return 0
}
number = number*10 + int(ch-'0')
}
return number
}
// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
func ValidateProxy(in ProxyInput) error {
return validateProxyInput(normalizeProxyInput(in))
}
func normalizeServerInput(in ServerInput) ServerInput {
in.Name = strings.TrimSpace(in.Name)
in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
in.Token = strings.TrimSpace(in.Token)
in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
in.Options = normalizeServerOptions(in.Options)
return in
}
func normalizeServerOptions(in ServerOptions) ServerOptions {
in.User = strings.TrimSpace(in.User)
in.Location = strings.TrimSpace(in.Location)
in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
in.OIDCScope = strings.TrimSpace(in.OIDCScope)
in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
in.ProxyURL = strings.TrimSpace(in.ProxyURL)
in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
in.DNSServer = strings.TrimSpace(in.DNSServer)
in.Remark = strings.TrimSpace(in.Remark)
if in.AuthMethod == "" {
in.AuthMethod = "token"
}
if in.LogLevel == "" {
in.LogLevel = "info"
}
if in.Protocol == "" {
in.Protocol = "tcp"
}
if in.LogMaxDays == 0 {
in.LogMaxDays = 3
}
if in.DialServerTimeout == 0 {
in.DialServerTimeout = 10
}
if in.DialServerKeepalive == 0 {
in.DialServerKeepalive = 7200
}
if in.TCPMuxKeepaliveInterval == 0 {
in.TCPMuxKeepaliveInterval = 60
}
if in.HeartbeatInterval == 0 {
in.HeartbeatInterval = 30
}
if in.HeartbeatTimeout == 0 {
in.HeartbeatTimeout = 90
}
return in
}
// ServerOptions 的布尔字段在表单默认值里是 true,但 JSON 缺省时零值是 false。
// 016 迁移给已有服务端补的 config_json 默认值为 "{}",非 UI 路径创建的服务端也可能不带这些键;
// 缺省时若不兜底,生成的 frpc 配置会变成 transport.tcpMux = false(每条新连接都要重新建
// TCP+TLS,首连明显变慢)与 loginFailExit = false(登录失败不再退出,排障信息被吞掉)。
// 因此按「键缺省即取默认值」反序列化;显式写入 false 的配置仍然保留 false。
func (in *ServerOptions) UnmarshalJSON(data []byte) error {
type plain ServerOptions
var decoded plain
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(data, &keys); err != nil {
return err
}
*in = ServerOptions(decoded)
if _, ok := keys["tcp_mux"]; !ok {
in.TCPMux = true
}
if _, ok := keys["login_fail_exit"]; !ok {
in.LoginFailExit = true
}
return nil
}
func validAuthMethod(value string) bool {
return value == "none" || value == "token" || value == "oidc"
}
func validLogLevel(value string) bool {
return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
}
func validProtocol(value string) bool {
return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
}
func validOptionalURL(value string) bool {
value = strings.TrimSpace(value)
if value == "" {
return true
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.Host != ""
}
func normalizeProxyInput(in ProxyInput) ProxyInput {
in.Name = strings.TrimSpace(in.Name)
in.Type = strings.ToLower(strings.TrimSpace(in.Type))
in.LocalIP = strings.TrimSpace(in.LocalIP)
in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
in.Remark = strings.TrimSpace(in.Remark)
in.Options = normalizeProxyOptions(in.Options)
seen := map[string]bool{}
domains := make([]string, 0, len(in.CustomDomains))
for _, domain := range in.CustomDomains {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain != "" && !seen[domain] {
seen[domain] = true
domains = append(domains, domain)
}
}
in.CustomDomains = domains
return in
}
func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
in.Subdomain = strings.TrimSpace(in.Subdomain)
in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
in.HTTPUser = strings.TrimSpace(in.HTTPUser)
in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
in.SecretKey = strings.TrimSpace(in.SecretKey)
for index, user := range in.AllowUsers {
in.AllowUsers[index] = strings.TrimSpace(user)
}
in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
return in
}
func validPort(port int) bool {
return port > 0 && port <= 65535
}
// privilegedVhostPorts 判断 frps 回源端口是否落在 80/443(未配置时按默认 8080/8443 计)。
// 这种拓扑下 VPS 上由 frps 直接对外,没有 Nginx 监听 80/443 的位置,
// 公网反代(Nginx 路由下发)既不适用、也不应该部署。
func privilegedVhostPorts(options ServerOptions) bool {
httpPort := options.VhostHTTPPort
if httpPort <= 0 {
httpPort = 8080
}
httpsPort := options.VhostHTTPSPort
if httpsPort <= 0 {
httpsPort = 8443
}
return httpPort == 80 || httpsPort == 443
}
func validHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
return false
}
if net.ParseIP(host) != nil || host == "localhost" {
return true
}
return validDomain(host)
}
// validateRemotePortAllowed 检查 tcp/udp 规则的远程端口是否落在服务端的 allowPorts 白名单里。
//
// frps 只认 allowPorts:端口不在白名单时规则会被服务端拒绝,frpc 会一直重试并刷
// `[规则名] start error: port not allowed`。与其等用户去翻 frpc 日志,不如保存时就说清楚。
func validateRemotePortAllowed(server Server, in ProxyInput) error {
allow := strings.TrimSpace(server.Options.AllowPorts)
if allow == "" || in.RemotePort == nil {
return nil
}
if in.Type != "tcp" && in.Type != "udp" {
return nil
}
if portAllowed(allow, *in.RemotePort) {
return nil
}
return fmt.Errorf(
"远程端口 %d 不在服务端「%s」的 allowPorts 白名单(%s)内:frps 会拒绝这条规则,frpc 会反复报 \"port not allowed\"。请到「公网服务端 → 服务端参数」把 %d 加进白名单(或清空 allowPorts 表示不限),保存后重新下发 frps 配置",
*in.RemotePort, server.Name, allow, *in.RemotePort)
}
// portAllowed 按 allowPorts 的写法(单端口 / 起-止 区间,逗号分隔)判断端口是否放行。
// 区间写反(6010-6000)、越界(0 或 >65535)与 renderAllowPorts 一样视为无效,不放行;
// 否则会出现「校验说放行、实际生成的 frps.toml 里这条白名单被丢弃」的偏差。
func portAllowed(allowPorts string, port int) bool {
for _, part := range strings.Split(allowPorts, ",") {
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 && port >= startN && port <= endN {
return true
}
continue
}
if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 && n == port {
return true
}
}
return false
}
func validDomain(domain string) bool {
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
return false
}
for _, label := range strings.Split(domain, ".") {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, ch := range label {
if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
return false
}
}
}
return true
}