Files
opsx-vpn/backend/internal/service/frp/frp.go
T

389 lines
12 KiB
Go

// 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
}