389 lines
12 KiB
Go
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
|
|
}
|