// Package frp manages real frps/frpc processes and FRP's TOML configuration. package frp import ( "fmt" "regexp" "sort" "strconv" "strings" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/store" ) type Service struct{} // Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept // as a JSON-friendly struct so callers may put a `proxies` array in Config. // The old single proxy_name fields remain supported for existing installations. type Proxy struct { Name string `json:"name"` Type string `json:"type"` LocalIP string `json:"local_ip"` LocalPort int `json:"local_port"` RemotePort int `json:"remote_port"` CustomDomains []string `json:"custom_domains"` Subdomain string `json:"subdomain"` Locations []string `json:"locations"` HostHeaderRewrite string `json:"host_header_rewrite"` } var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) func New() *Service { return &Service{} } func (s *Service) Name() string { return "frp" } func (s *Service) DisplayName() string { return "FRP 内网穿透" } func (s *Service) Available() (bool, string) { if p, ok := service.LookBin("frps", "frpc"); ok { return true, p } return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动" } func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) { out := clone(cfg) mode := service.StrOr(out, "mode", "server") if mode != "server" && mode != "client" { return nil, fmt.Errorf("mode 必须为 server 或 client") } out["mode"] = mode if mode == "server" { setDefaultPort(out, "bind_port", 7000) setDefaultPort(out, "dashboard_port", 7500) for _, key := range []string{"bind_port", "dashboard_port"} { if err := validPort(key, service.Num(out, key, 0)); err != nil { return nil, err } } for _, key := range []string{"vhost_http_port", "vhost_https_port"} { if service.Num(out, key, 0) != 0 { if err := validPort(key, service.Num(out, key, 0)); err != nil { return nil, err } } } } else { if err := service.MustValidate(out, "server_addr"); err != nil { return nil, err } setDefaultPort(out, "server_port", 7000) if err := validPort("server_port", service.Num(out, "server_port", 0)); err != nil { return nil, err } } rules, err := proxyRules(out) if err != nil { return nil, err } for _, rule := range rules { if err := validateProxy(rule); err != nil { return nil, err } } if len(rules) > 0 { out["proxies"] = proxiesJSON(rules) } return out, nil } func setDefaultPort(cfg map[string]interface{}, key string, value int) { if service.Num(cfg, key, 0) == 0 { cfg[key] = value } } func validPort(name string, port float64) error { if port < 1 || port > 65535 || port != float64(int(port)) { return fmt.Errorf("%s 必须是 1-65535 的整数", name) } return nil } func validateProxy(p Proxy) error { if !safeName.MatchString(p.Name) { return fmt.Errorf("代理名称 %q 无效", p.Name) } if p.Type != "tcp" && p.Type != "udp" && p.Type != "http" && p.Type != "https" { return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http 或 https", p.Name) } if p.LocalPort < 1 || p.LocalPort > 65535 { return fmt.Errorf("代理 %s 的 local_port 无效", p.Name) } if p.LocalIP == "" { p.LocalIP = "127.0.0.1" } if strings.ContainsAny(p.LocalIP, "\r\n\"'") { return fmt.Errorf("代理 %s 的 local_ip 非法", p.Name) } if p.Type == "tcp" || p.Type == "udp" { if p.RemotePort < 1 || p.RemotePort > 65535 { return fmt.Errorf("代理 %s 的 remote_port 无效", p.Name) } return nil } if len(p.CustomDomains) == 0 && p.Subdomain == "" { return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name) } domains := append([]string{}, p.CustomDomains...) if p.Subdomain != "" { domains = append(domains, p.Subdomain) } for _, d := range domains { if !validDomain(d) { return fmt.Errorf("代理 %s 的域名 %q 无效", p.Name, d) } } return nil } func validDomain(v string) bool { v = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(v)), ".") if v == "" || len(v) > 253 || strings.ContainsAny(v, "\r\n\"' /\\") { return false } for _, label := range strings.Split(v, ".") { if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return false } } return true } func (s *Service) Create(t *model.Tunnel) error { cfg, err := store.TunnelConfig(t) if err != nil { return err } secrets, _ := store.TunnelSecrets(t) token := service.Str(cfg, "token") if token == "" { token = service.Str(secrets, "token") } if token == "" { token, err = service.RandomSecret(32) if err != nil { return err } } secrets["token"] = token dashboardPassword := service.Str(cfg, "dashboard_password") if dashboardPassword == "" { dashboardPassword = service.Str(secrets, "dashboard_password") } if dashboardPassword == "" { dashboardPassword, err = service.RandomSecret(24) if err != nil { return err } } secrets["dashboard_password"] = dashboardPassword if service.StrOr(cfg, "mode", "server") == "server" { if _, err = service.WriteConfig(t, "frps.toml", frpsConfig(cfg, token, dashboardPassword)); err != nil { return err } } else if _, err = service.WriteConfig(t, "frpc.toml", s.frpcConfig(cfg, token)); err != nil { return err } public, sec := service.MergeSecrets(cfg, "token", "dashboard_password") for k, v := range secrets { sec[k] = v } return service.SaveTunnelConfig(t, public, sec) } func frpsConfig(cfg map[string]interface{}, token, password string) string { var b strings.Builder fmt.Fprintf(&b, "bindPort = %d\nauth.method = \"token\"\nauth.token = %q\n\n", int(service.Num(cfg, "bind_port", 7000)), token) fmt.Fprintf(&b, "webServer.addr = %q\nwebServer.port = %d\nwebServer.user = %q\nwebServer.password = %q\n", service.StrOr(cfg, "dashboard_addr", "127.0.0.1"), int(service.Num(cfg, "dashboard_port", 7500)), service.StrOr(cfg, "dashboard_user", "admin"), password) if p := int(service.Num(cfg, "vhost_http_port", 0)); p > 0 { fmt.Fprintf(&b, "vhostHTTPPort = %d\n", p) } if p := int(service.Num(cfg, "vhost_https_port", 0)); p > 0 { fmt.Fprintf(&b, "vhostHTTPSPort = %d\n", p) } if ranges := allowPorts(service.Str(cfg, "allow_ports")); len(ranges) > 0 { fmt.Fprintf(&b, "allowPorts = [%s]\n", strings.Join(ranges, ", ")) } b.WriteString("log.to = \"frps.log\"\nlog.level = \"info\"\n") return b.String() } func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string { var b strings.Builder fmt.Fprintf(&b, "serverAddr = %q\nserverPort = %d\nauth.method = \"token\"\nauth.token = %q\nlog.to = \"frpc.log\"\nlog.level = \"info\"\n", service.Str(cfg, "server_addr"), int(service.Num(cfg, "server_port", 7000)), token) rules, _ := proxyRules(cfg) for _, p := range rules { fmt.Fprintf(&b, "\n[[proxies]]\nname = %q\ntype = %q\nlocalIP = %q\nlocalPort = %d\n", p.Name, p.Type, defaultString(p.LocalIP, "127.0.0.1"), p.LocalPort) if p.Type == "tcp" || p.Type == "udp" { fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort) } else { if len(p.CustomDomains) > 0 { fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", ")) } if p.Subdomain != "" { fmt.Fprintf(&b, "subdomain = %q\n", p.Subdomain) } if len(p.Locations) > 0 { fmt.Fprintf(&b, "locations = [%s]\n", strings.Join(quoteStrings(p.Locations), ", ")) } if p.HostHeaderRewrite != "" { fmt.Fprintf(&b, "hostHeaderRewrite = %q\n", p.HostHeaderRewrite) } } } return b.String() } func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { cfg, err := store.TunnelConfig(t) if err != nil { return "", err } secrets, _ := store.TunnelSecrets(t) token := service.Str(secrets, "token") if service.StrOr(cfg, "mode", "server") == "server" { cfg = clone(cfg) cfg["server_addr"] = service.StrOr(cfg, "public_endpoint", "your-server-ip") cfg["server_port"] = service.Num(cfg, "bind_port", 7000) } return s.frpcConfig(cfg, token), nil } func (s *Service) Start(t *model.Tunnel) error { cfg, err := store.TunnelConfig(t) if err != nil { return err } bin, conf := "frpc", "frpc.toml" if service.StrOr(cfg, "mode", "server") == "server" { bin, conf = "frps", "frps.toml" } if p, ok := service.LookBin(bin); ok { return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf)) } return fmt.Errorf("无法启动 FRP:未检测到 %s", bin) } func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) } func (s *Service) Status(t *model.Tunnel) (core.Status, error) { if _, alive := service.ReadPid(t); alive { return service.NowStatus(true, "running", "FRP 进程运行中"), nil } cfg, _ := store.TunnelConfig(t) bin := "frpc" if service.StrOr(cfg, "mode", "server") == "server" { bin = "frps" } if _, ok := service.LookBin(bin); !ok { return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil } return service.NowStatus(false, "stopped", "未启动"), nil } func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) } func proxyRules(cfg map[string]interface{}) ([]Proxy, error) { if raw, ok := cfg["proxies"]; ok { items, ok := raw.([]interface{}) if !ok { return nil, fmt.Errorf("proxies 必须是数组") } rules := make([]Proxy, 0, len(items)) for _, item := range items { m, ok := item.(map[string]interface{}) if !ok { return nil, fmt.Errorf("proxies 中每项必须是对象") } p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite")} p.CustomDomains = stringList(m["custom_domains"]) p.Locations = stringList(m["locations"]) rules = append(rules, p) } return rules, nil } if name := service.Str(cfg, "proxy_name"); name != "" { return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains"))}}, nil } return nil, nil } func proxiesJSON(in []Proxy) []interface{} { out := make([]interface{}, 0, len(in)) for _, p := range in { out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite}) } return out } func stringList(v interface{}) []string { switch x := v.(type) { case []interface{}: out := []string{} for _, item := range x { if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { out = append(out, strings.TrimSpace(s)) } } return out case []string: return x case string: return splitDomains(x) } return nil } func splitDomains(v string) []string { return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) } func quoteStrings(v []string) []string { out := make([]string, 0, len(v)) for _, s := range v { out = append(out, strconv.Quote(s)) } return out } func defaultString(v, d string) string { if v != "" { return v } return d } func clone(in map[string]interface{}) map[string]interface{} { out := map[string]interface{}{} for k, v := range in { out[k] = v } return out } func allowPorts(value string) []string { seen := map[string]bool{} out := []string{} for _, part := range strings.Split(value, ",") { part = strings.TrimSpace(part) if part == "" { continue } pieces := strings.Split(part, "-") a, err := strconv.Atoi(strings.TrimSpace(pieces[0])) if err != nil || a < 1 || a > 65535 || len(pieces) > 2 { continue } entry := "" if len(pieces) == 1 { entry = fmt.Sprintf("{ single = %d }", a) } else { b, err := strconv.Atoi(strings.TrimSpace(pieces[1])) if err != nil || b < a || b > 65535 { continue } entry = fmt.Sprintf("{ start = %d, end = %d }", a, b) } if !seen[entry] { seen[entry] = true out = append(out, entry) } } sort.Strings(out) return out }