chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听) - 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP) - 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点) - 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
maskedToken = "********"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerAddr string `json:"server_addr"`
|
||||
ServerPort int `json:"server_port"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
HasAuthToken bool `json:"has_auth_token"`
|
||||
TLSEnabled bool `json:"tls_enabled"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
}
|
||||
|
||||
type SaveInput struct {
|
||||
Enabled bool
|
||||
ServerAddr string
|
||||
ServerPort int
|
||||
AuthToken string
|
||||
TLSEnabled bool
|
||||
CustomDomains []string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
settings *settings.Store
|
||||
secretBox *secret.Box
|
||||
}
|
||||
|
||||
func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
|
||||
return &Store{settings: settingsStore, secretBox: secretBox}
|
||||
}
|
||||
|
||||
func (s *Store) Load(ctx context.Context) (Config, error) {
|
||||
enabled, _ := s.settings.GetBool(ctx, settings.KeyFRPEnabled)
|
||||
serverAddr, _ := s.settings.Get(ctx, settings.KeyFRPServerAddr)
|
||||
serverPort, _ := s.settings.GetInt(ctx, settings.KeyFRPServerPort)
|
||||
if serverPort <= 0 {
|
||||
serverPort = 7000
|
||||
}
|
||||
tlsEnabled, _ := s.settings.GetBool(ctx, settings.KeyFRPTLSEnabled)
|
||||
domains, err := s.loadDomains(ctx)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
encToken, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
hasToken := strings.TrimSpace(encToken) != ""
|
||||
token := ""
|
||||
if hasToken {
|
||||
token = maskedToken
|
||||
}
|
||||
return Config{
|
||||
Enabled: enabled,
|
||||
ServerAddr: serverAddr,
|
||||
ServerPort: serverPort,
|
||||
AuthToken: token,
|
||||
HasAuthToken: hasToken,
|
||||
TLSEnabled: tlsEnabled,
|
||||
CustomDomains: domains,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadRuntime(ctx context.Context) (Config, string, error) {
|
||||
cfg, err := s.Load(ctx)
|
||||
if err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
if !cfg.HasAuthToken {
|
||||
return cfg, "", nil
|
||||
}
|
||||
enc, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
|
||||
plain, err := s.secretBox.Decrypt(enc)
|
||||
if err != nil {
|
||||
return cfg, "", secret.DecryptHint(err)
|
||||
}
|
||||
return cfg, plain, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(ctx context.Context, in SaveInput, keepToken bool) error {
|
||||
if err := validateSaveInput(in, keepToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPEnabled, in.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPServerAddr, normalizeServerAddr(in.ServerAddr)); err != nil {
|
||||
return err
|
||||
}
|
||||
port := in.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyFRPServerPort, port); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyFRPTLSEnabled, in.TLSEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
domains := normalizeDomains(in.CustomDomains)
|
||||
raw, err := json.Marshal(domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPCustomDomains, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token != "" && token != maskedToken {
|
||||
enc, err := s.secretBox.Encrypt(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.Set(ctx, settings.KeyFRPAuthToken, enc); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if in.Enabled && !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetLastError(ctx context.Context, msg string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPLastError, strings.TrimSpace(msg))
|
||||
}
|
||||
|
||||
func (s *Store) LastError(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPLastError)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) StartedAt(ctx context.Context) string {
|
||||
v, _ := s.settings.Get(ctx, settings.KeyFRPStartedAt)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func (s *Store) SetStartedAt(ctx context.Context, value string) error {
|
||||
return s.settings.Set(ctx, settings.KeyFRPStartedAt, strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func (s *Store) loadDomains(ctx context.Context) ([]string, error) {
|
||||
raw, _ := s.settings.Get(ctx, settings.KeyFRPCustomDomains)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(raw), &domains); err != nil {
|
||||
return nil, fmt.Errorf("FRP 域名配置无效")
|
||||
}
|
||||
return normalizeDomains(domains), nil
|
||||
}
|
||||
|
||||
func normalizeServerAddr(addr string) string {
|
||||
addr = strings.TrimSpace(addr)
|
||||
addr = strings.TrimPrefix(addr, "https://")
|
||||
addr = strings.TrimPrefix(addr, "http://")
|
||||
if idx := strings.IndexAny(addr, "/?#"); idx >= 0 {
|
||||
addr = addr[:idx]
|
||||
}
|
||||
return strings.TrimSpace(addr)
|
||||
}
|
||||
|
||||
func normalizeDomains(domains []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, d := range domains {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" || seen[strings.ToLower(d)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(d)] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSaveInput(in SaveInput, keepToken bool) error {
|
||||
if !in.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(in.ServerAddr) == "" {
|
||||
return fmt.Errorf("请填写 FRP 服务器地址")
|
||||
}
|
||||
if in.ServerPort < 0 || in.ServerPort > 65535 {
|
||||
return fmt.Errorf("FRP 服务器端口无效")
|
||||
}
|
||||
if len(normalizeDomains(in.CustomDomains)) == 0 {
|
||||
return fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
token := strings.TrimSpace(in.AuthToken)
|
||||
if token == "" || token == maskedToken {
|
||||
if !keepToken {
|
||||
return fmt.Errorf("请填写 FRP 认证 Token")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeServerAddr(t *testing.T) {
|
||||
if got := normalizeServerAddr("http://127.0.0.1"); got != "127.0.0.1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := normalizeServerAddr("https://frp.example.com/path"); got != "frp.example.com" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func Generate(cfg config.Config, frpCfg Config, authToken string, httpPort, httpsPort int) (string, error) {
|
||||
if strings.TrimSpace(frpCfg.ServerAddr) == "" {
|
||||
return "", fmt.Errorf("FRP 服务器地址未配置")
|
||||
}
|
||||
if strings.TrimSpace(authToken) == "" {
|
||||
return "", fmt.Errorf("FRP 认证 Token 未配置")
|
||||
}
|
||||
domains := normalizeDomains(frpCfg.CustomDomains)
|
||||
if len(domains) == 0 {
|
||||
return "", fmt.Errorf("请至少填写一个穿透域名")
|
||||
}
|
||||
if httpPort <= 0 || httpsPort <= 0 {
|
||||
return "", fmt.Errorf("Nginx 端口未配置")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("serverAddr = %q\n", frpCfg.ServerAddr))
|
||||
b.WriteString(fmt.Sprintf("serverPort = %d\n\n", frpCfg.ServerPort))
|
||||
|
||||
b.WriteString(fmt.Sprintf("log.to = %q\n", tomlPath(cfg.FrpLogPath())))
|
||||
b.WriteString("log.level = \"info\"\n\n")
|
||||
|
||||
b.WriteString("auth.method = \"token\"\n")
|
||||
b.WriteString(fmt.Sprintf("auth.token = %q\n\n", authToken))
|
||||
|
||||
if frpCfg.TLSEnabled {
|
||||
b.WriteString("transport.tls.enable = true\n\n")
|
||||
} else {
|
||||
b.WriteString("transport.tls.enable = false\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-http\"\n")
|
||||
b.WriteString("type = \"http\"\n")
|
||||
b.WriteString("localIP = \"127.0.0.1\"\n")
|
||||
b.WriteString(fmt.Sprintf("localPort = %d\n", httpPort))
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n\n", formatTOMLStringArray(domains)))
|
||||
|
||||
b.WriteString("[[proxies]]\n")
|
||||
b.WriteString("name = \"fonu-nginx-https\"\n")
|
||||
b.WriteString("type = \"https\"\n")
|
||||
b.WriteString(fmt.Sprintf("customDomains = %s\n", formatTOMLStringArray(domains)))
|
||||
b.WriteString("[proxies.plugin]\n")
|
||||
b.WriteString("type = \"https2https\"\n")
|
||||
b.WriteString(fmt.Sprintf("localAddr = \"127.0.0.1:%d\"\n", httpsPort))
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func tomlPath(path string) string {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
||||
func formatTOMLStringArray(items []string) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf("%q", item))
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateNginxGateway(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
DataDir: "/data",
|
||||
NginxDefaultHTTPPort: 18080,
|
||||
NginxDefaultHTTPSPort: 9443,
|
||||
FrpPIDFile: "/data/frp/frpc.pid",
|
||||
}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
TLSEnabled: true,
|
||||
CustomDomains: []string{"nas.example.com", "*.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `serverAddr = "vps.example.com"`) {
|
||||
t.Fatalf("missing server addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localPort = 18080`) {
|
||||
t.Fatalf("missing http local port: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `type = "https2https"`) {
|
||||
t.Fatalf("missing https plugin: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `localAddr = "127.0.0.1:9443"`) {
|
||||
t.Fatalf("missing https local addr: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = true`) {
|
||||
t.Fatalf("missing tls enable: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDisablesTLSByDefault(t *testing.T) {
|
||||
cfg := config.Config{DataDir: "/data", FrpPIDFile: "/data/frp/frpc.pid"}
|
||||
frpCfg := Config{
|
||||
ServerAddr: "vps.example.com",
|
||||
ServerPort: 7000,
|
||||
CustomDomains: []string{"nas.example.com"},
|
||||
}
|
||||
out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `transport.tls.enable = false`) {
|
||||
t.Fatalf("expected tls disabled: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ReadLogTail(path string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
|
||||
return []string{}, nil
|
||||
}
|
||||
if len(lines) > limit {
|
||||
lines = lines[len(lines)-limit:]
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
const (
|
||||
frpcCmdTimeout = 8 * time.Second
|
||||
frpcStartWait = 5 * time.Second
|
||||
frpcTerminateWait = 3 * time.Second
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cfg config.Config
|
||||
store *Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
|
||||
return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
|
||||
}
|
||||
|
||||
func (m *Manager) EnsureDirs() error {
|
||||
return os.MkdirAll(m.cfg.FrpDir(), 0o755)
|
||||
}
|
||||
|
||||
func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
|
||||
existing, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
|
||||
if err := m.store.Save(ctx, in, keepToken); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
if err := m.Stop(ctx); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.clearStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已停用"}, nil
|
||||
}
|
||||
|
||||
runtimeCfg, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
|
||||
if err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
tmpPath := m.cfg.FrpConfigPath() + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if err := m.verifyConfig(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
|
||||
if !m.available() {
|
||||
msg := "frpc 未安装,配置已保存"
|
||||
_ = m.store.SetLastError(ctx, msg)
|
||||
return ApplyResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
running, _ := m.isRunning()
|
||||
if !running {
|
||||
if err := m.start(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已启动"}, nil
|
||||
}
|
||||
|
||||
if err := m.reload(ctx); err != nil {
|
||||
m.logger.Warn("frpc reload failed, trying restart", "error", err)
|
||||
if err := m.forceRestart(ctx); err != nil {
|
||||
_ = m.store.SetLastError(ctx, err.Error())
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
m.markStarted(ctx)
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重新启动"}, nil
|
||||
}
|
||||
_ = m.store.SetLastError(ctx, "")
|
||||
return ApplyResult{Message: "FRP 已重载"}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Bootstrap(ctx context.Context) error {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil || !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !m.available() {
|
||||
m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
|
||||
return nil
|
||||
}
|
||||
_, err = m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: cfg.CustomDomains,
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Warn("initial frpc apply skipped", "error", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Load(ctx context.Context) (Config, error) {
|
||||
return m.store.Load(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) Status(ctx context.Context) Status {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{Message: "读取配置失败"}
|
||||
}
|
||||
return BuildStatus(
|
||||
m.cfg,
|
||||
cfg,
|
||||
cfg.Enabled,
|
||||
m.store.LastError(ctx),
|
||||
m.store.StartedAt(ctx),
|
||||
ResolveClientVersion(m.cfg.FrpcBin),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
}
|
||||
merged := MergeDomains(cfg.CustomDomains, domains)
|
||||
return m.Apply(ctx, SaveInput{
|
||||
Enabled: cfg.Enabled,
|
||||
ServerAddr: cfg.ServerAddr,
|
||||
ServerPort: cfg.ServerPort,
|
||||
AuthToken: maskedToken,
|
||||
TLSEnabled: cfg.TLSEnabled,
|
||||
CustomDomains: merged,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
|
||||
return ReadLogTail(m.cfg.FrpLogPath(), limit)
|
||||
}
|
||||
|
||||
func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
|
||||
cfg, err := m.store.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, token, err := m.store.LoadRuntime(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := cfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPPort() int {
|
||||
return m.cfg.NginxDefaultHTTPPort
|
||||
}
|
||||
|
||||
func (m *Manager) NginxHTTPSPort() int {
|
||||
return m.cfg.NginxDefaultHTTPSPort
|
||||
}
|
||||
|
||||
func (m *Manager) verifyConfig(ctx context.Context) error {
|
||||
if !m.available() {
|
||||
return nil
|
||||
}
|
||||
return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
|
||||
}
|
||||
|
||||
func (m *Manager) markStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (m *Manager) clearStarted(ctx context.Context) {
|
||||
_ = m.store.SetStartedAt(ctx, "")
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context) error {
|
||||
if !m.isRunningQuick() {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return nil
|
||||
}
|
||||
_ = terminateProcess(pid, syscall.SIGTERM)
|
||||
waitProcessExit(pid, frpcTerminateWait)
|
||||
if nginx.IsPIDAlive(pid) {
|
||||
_ = terminateProcess(pid, syscall.SIGKILL)
|
||||
waitProcessExit(pid, time.Second)
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
m.clearStarted(ctx)
|
||||
m.logger.Info("frpc stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) available() bool {
|
||||
_, err := exec.LookPath(m.cfg.FrpcBin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(ctx context.Context) error {
|
||||
if m.isRunningQuick() {
|
||||
return m.reload(ctx)
|
||||
}
|
||||
args := []string{"-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reload(ctx context.Context) error {
|
||||
args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
|
||||
if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
|
||||
return err
|
||||
}
|
||||
m.logger.Info("frpc reloaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) forceRestart(ctx context.Context) error {
|
||||
_ = m.Stop(ctx)
|
||||
return m.start(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
msg = "操作超时"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
|
||||
cmd := exec.Command(m.cfg.FrpcBin, args...)
|
||||
var stderr, stdout bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
if cmd.Process != nil {
|
||||
if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return fmt.Errorf("%s:%s", prefix, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
|
||||
deadline := time.Now().Add(frpcStartWait)
|
||||
for time.Now().Before(deadline) {
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case err := <-waitDone:
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), err)
|
||||
m.logger.Warn("frpc exited during start", "error", msg)
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s:%s", prefix, "操作超时")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if running, _ := m.isRunning(); running {
|
||||
return nil
|
||||
}
|
||||
msg := frpcCommandError(stdout.String(), stderr.String(), nil)
|
||||
if msg == "" {
|
||||
msg = "未检测到运行中的进程"
|
||||
}
|
||||
return fmt.Errorf("%s:%s", prefix, msg)
|
||||
}
|
||||
|
||||
func frpcCommandError(stdout, stderr string, err error) string {
|
||||
msg := strings.TrimSpace(stderr)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stdout)
|
||||
}
|
||||
if msg == "" && err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "进程已退出"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func writePIDFile(path string, pid int) error {
|
||||
if pid <= 0 {
|
||||
return fmt.Errorf("无效的 frpc 进程 ID")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
|
||||
}
|
||||
|
||||
func (m *Manager) isRunning() (bool, error) {
|
||||
pid, err := readPIDFile(m.cfg.FrpPIDFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
removePIDFile(m.cfg.FrpPIDFile)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) isRunningQuick() bool {
|
||||
ok, _ := m.isRunning()
|
||||
return ok
|
||||
}
|
||||
|
||||
func readPIDFile(path string) (int, error) {
|
||||
return nginx.ReadPID(path)
|
||||
}
|
||||
|
||||
func removePIDFile(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func waitProcessExit(pid int, timeout time.Duration) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !nginx.IsPIDAlive(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func terminateProcess(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
|
||||
func FrpConfigDir(cfg config.Config) string {
|
||||
return filepath.Clean(cfg.FrpDir())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/nginx"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Connected bool `json:"connected"`
|
||||
Message string `json:"message"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ServerEndpoint string `json:"server_endpoint,omitempty"`
|
||||
ConnectedAt string `json:"connected_at,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
HTTPGatewayEnabled bool `json:"http_gateway_enabled"`
|
||||
HTTPSGatewayEnabled bool `json:"https_gateway_enabled"`
|
||||
SyncedDomainCount int `json:"synced_domain_count"`
|
||||
}
|
||||
|
||||
func BuildStatus(cfg config.Config, frpCfg Config, enabled bool, lastError, startedAt, clientVersion string) Status {
|
||||
st := Status{
|
||||
Enabled: enabled,
|
||||
Connected: false,
|
||||
Message: "未启用",
|
||||
LastError: strings.TrimSpace(lastError),
|
||||
ClientVersion: clientVersion,
|
||||
SyncedDomainCount: len(frpCfg.CustomDomains),
|
||||
}
|
||||
if enabled {
|
||||
addr := strings.TrimSpace(frpCfg.ServerAddr)
|
||||
if addr != "" {
|
||||
port := frpCfg.ServerPort
|
||||
if port <= 0 {
|
||||
port = 7000
|
||||
}
|
||||
st.ServerEndpoint = addr + ":" + strconv.Itoa(port)
|
||||
}
|
||||
st.HTTPGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
st.HTTPSGatewayEnabled = len(frpCfg.CustomDomains) > 0
|
||||
}
|
||||
if !enabled {
|
||||
return st
|
||||
}
|
||||
running, err := isProcessRunning(cfg.FrpPIDFile)
|
||||
if err != nil || !running {
|
||||
st.Message = "未连接"
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
} else if hint := readLogHint(cfg.FrpLogPath()); hint != "" {
|
||||
st.Message = hint
|
||||
}
|
||||
return st
|
||||
}
|
||||
ok, logHint := logIndicatesConnected(cfg.FrpLogPath())
|
||||
if ok {
|
||||
st.Connected = true
|
||||
st.Message = "已连接"
|
||||
st.LastError = ""
|
||||
applyConnectedTiming(&st, startedAt)
|
||||
return st
|
||||
}
|
||||
st.Message = "连接中"
|
||||
if logHint != "" {
|
||||
st.Message = logHint
|
||||
}
|
||||
if st.LastError != "" {
|
||||
st.Message = st.LastError
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func applyConnectedTiming(st *Status, startedAt string) {
|
||||
st.ConnectedAt = strings.TrimSpace(startedAt)
|
||||
if st.ConnectedAt == "" {
|
||||
return
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, st.ConnectedAt)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
st.UptimeSeconds = int64(time.Since(t).Seconds())
|
||||
if st.UptimeSeconds < 0 {
|
||||
st.UptimeSeconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
func isProcessRunning(pidFile string) (bool, error) {
|
||||
pid, err := nginx.ReadPID(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return nginx.IsPIDAlive(pid), nil
|
||||
}
|
||||
|
||||
func readLogHint(path string) string {
|
||||
lines := tailLines(path, 40)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") ||
|
||||
strings.Contains(line, "token in login doesn't match") {
|
||||
return sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logIndicatesConnected(path string) (bool, string) {
|
||||
lines := tailLines(path, 80)
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.ToLower(lines[i])
|
||||
if strings.Contains(line, "login to server success") {
|
||||
return true, ""
|
||||
}
|
||||
if strings.Contains(line, "connect to server error") ||
|
||||
strings.Contains(line, "authorization failed") {
|
||||
return false, sanitizeLogLine(lines[i])
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func tailLines(path string, max int) []string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > max {
|
||||
lines = lines[len(lines)-max:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func sanitizeLogLine(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
|
||||
line = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
if len(line) > 200 {
|
||||
line = line[:200] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func DomainsFromProxyRules(rules []proxy.Rule) []string {
|
||||
seen := make(map[string]bool)
|
||||
var domains []string
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, host := range rule.Hostnames() {
|
||||
if host == "" || seen[host] {
|
||||
continue
|
||||
}
|
||||
seen[host] = true
|
||||
domains = append(domains, host)
|
||||
}
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
func MergeDomains(existing, incoming []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, list := range [][]string{existing, incoming} {
|
||||
for _, d := range list {
|
||||
d = strings.TrimSpace(d)
|
||||
key := strings.ToLower(d)
|
||||
if d == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
func TestDomainsFromProxyRules(t *testing.T) {
|
||||
rules := []proxy.Rule{
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}}, Domain: "b.example.com"},
|
||||
{Enabled: false, Hosts: []proxy.Host{{Hostname: "skip.example.com"}}},
|
||||
{Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}, {Hostname: "c.example.com"}}},
|
||||
}
|
||||
domains := DomainsFromProxyRules(rules)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %v", domains)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package frp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildFRPSConfig generates frps.toml for the user's VPS, aligned with Fonu Nginx ports and auth.
|
||||
func BuildFRPSConfig(bindPort, httpPort, httpsPort int, token string) string {
|
||||
if bindPort <= 0 {
|
||||
bindPort = 7000
|
||||
}
|
||||
if httpPort <= 0 {
|
||||
httpPort = 80
|
||||
}
|
||||
if httpsPort <= 0 {
|
||||
httpsPort = 443
|
||||
}
|
||||
if token == "" {
|
||||
token = "your-secret-token"
|
||||
}
|
||||
return fmt.Sprintf(`bindAddr = "0.0.0.0"
|
||||
bindPort = %d
|
||||
|
||||
vhostHTTPPort = %d
|
||||
vhostHTTPSPort = %d
|
||||
|
||||
[auth]
|
||||
method = "token"
|
||||
token = "%s"
|
||||
`, bindPort, httpPort, httpsPort, token)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildFRPSConfig(t *testing.T) {
|
||||
out := BuildFRPSConfig(7000, 18080, 9443, "fonu-dev-token")
|
||||
if out == "" {
|
||||
t.Fatal("empty config")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"bindPort = 7000",
|
||||
"vhostHTTPPort = 18080",
|
||||
"vhostHTTPSPort = 9443",
|
||||
`token = "fonu-dev-token"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("missing %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultClientVersion = "frpc v0.71.0"
|
||||
|
||||
func ResolveClientVersion(bin string) string {
|
||||
bin = strings.TrimSpace(bin)
|
||||
if bin == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
out, err := exec.Command(bin, "--version").Output()
|
||||
if err != nil {
|
||||
return defaultClientVersion
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return defaultClientVersion
|
||||
}
|
||||
if strings.HasPrefix(line, "frpc version ") {
|
||||
return "frpc v" + strings.TrimPrefix(line, "frpc version ")
|
||||
}
|
||||
return line
|
||||
}
|
||||
Reference in New Issue
Block a user