Files
-Fonu/internal/service/nginx_config.go
T
mrlee fd1661622e feat: 上游 1.0.3-1.0.5 同步第 1-8 项(v1.2.2)
剪贴板:新增 web/src/utils/clipboard.ts,5 处调用点支持非 HTTPS 环境(对齐 1.0.4)

日志中心:新增「FRP 日志」Tab,解析 frpc 日志为 时间/级别/来源/消息(对齐 1.0.3)

通知:单通道密文解密失败不再拖垮整份配置(对齐 1.0.5);容器补 TZ + tzdata(对齐 1.0.4);Telegram 代理自动补 http:// 并校验 scheme(对齐 1.0.3)

反向代理:复制不再追加「-复制」(对齐 1.0.5);监听占用校验合并表单规则与手动 Nginx 配置两张表(迁移 024 + endpoints 解析器),冲突提示写明占用规则;改端口的并发冲突改为可读提示

FRP:frps 弹窗补「VPS 防火墙放行 TCP 远程端口」提示(对齐 1.0.3);反代配置弹窗补齐内容区内边距与来源说明

文档:新增 docs/upstream-sync-plan.md(逐版本对照 + 第 5 项详细整理);VERSION 与 CHANGELOG 记 v1.2.2
2026-09-23 17:46:21 +08:00

354 lines
10 KiB
Go

package service
import (
"context"
"fmt"
"strings"
"github.com/fonu/fonu/internal/nginx"
"github.com/fonu/fonu/internal/proxy"
"github.com/fonu/fonu/internal/settings"
)
type NginxBackupInfo struct {
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
type RuleNginxView struct {
Mode string `json:"mode"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
Generated string `json:"generated"`
Content string `json:"content"`
Backups []NginxBackupInfo `json:"backups"`
}
type GlobalNginxView struct {
Mode string `json:"mode"`
GeneratedFramework string `json:"generated_framework"`
GeneratedSnippet string `json:"generated_snippet"`
Content string `json:"content"`
Backups []NginxBackupInfo `json:"backups"`
}
type SaveRuleNginxInput struct {
Mode string
Content *string
}
type SaveGlobalNginxInput struct {
Mode string
Content *string
}
func (s *ProxyService) GetRuleNginx(ctx context.Context, id int64) (RuleNginxView, error) {
rule, err := s.Get(ctx, id)
if err != nil {
return RuleNginxView{}, err
}
certs, err := s.loadCertSources(ctx)
if err != nil {
return RuleNginxView{}, err
}
opts := s.loadGenerateOptions(ctx)
generated, err := nginx.GenerateRuleBlocks(s.cfg, rule, certs, opts)
if err != nil {
return RuleNginxView{}, err
}
mode := rule.NginxMode
if mode == "" {
mode = "auto"
}
content := generated
if mode == "custom" {
custom, err := nginx.ReadRuleCustom(s.cfg, id)
if err != nil {
return RuleNginxView{}, err
}
if strings.TrimSpace(custom) != "" {
content = custom
}
}
backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
if err != nil {
return RuleNginxView{}, err
}
return RuleNginxView{
Mode: mode,
Enabled: rule.Enabled,
Active: rule.Enabled,
Generated: generated,
Content: content,
Backups: mapBackups(backups),
}, nil
}
func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleNginxInput) (RuleNginxView, error) {
rule, err := s.Get(ctx, id)
if err != nil {
return RuleNginxView{}, err
}
mode := strings.TrimSpace(in.Mode)
if mode == "" {
mode = rule.NginxMode
}
if mode != "auto" && mode != "custom" {
return RuleNginxView{}, fmt.Errorf("无效的 nginx 模式")
}
if mode == "auto" {
if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil {
return RuleNginxView{}, err
}
// 切回自动模式后,手动配置的占用记录不再适用
if err := s.store.ClearCustomEndpoints(ctx, id); err != nil {
return RuleNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return RuleNginxView{}, err
}
return s.GetRuleNginx(ctx, id)
}
if in.Content == nil {
return RuleNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
}
content := *in.Content
if strings.TrimSpace(content) == "" {
return RuleNginxView{}, fmt.Errorf("Nginx 配置不能为空")
}
if err := s.validateRuleNginxPending(ctx, rule, content); err != nil {
return RuleNginxView{}, err
}
// 手动配置里的 listen/server_name 不进 proxy_hosts,需单独解析记录并校验占用,
// 否则它与其它规则的同域名同端口冲突只会在 Nginx reload 时静默失效
endpoints := nginx.ParseServerEndpoints(content)
if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
return RuleNginxView{}, err
}
if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil {
return RuleNginxView{}, err
}
if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
return RuleNginxView{}, err
}
if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
return RuleNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return RuleNginxView{}, err
}
return s.GetRuleNginx(ctx, id)
}
func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupName string) (RuleNginxView, error) {
if _, err := s.Get(ctx, id); err != nil {
return RuleNginxView{}, err
}
backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
if err != nil {
return RuleNginxView{}, err
}
if len(backups) == 0 {
return RuleNginxView{}, fmt.Errorf("没有可回滚的备份")
}
if backupName == "" {
backupName = backups[0].Name
}
if err := nginx.RestoreBackup(s.cfg.NginxRuleBackupsDir(id), backupName, s.cfg.NginxRuleCustomPath(id)); err != nil {
return RuleNginxView{}, err
}
custom, err := nginx.ReadRuleCustom(s.cfg, id)
if err != nil {
return RuleNginxView{}, err
}
rule, err := s.Get(ctx, id)
if err != nil {
return RuleNginxView{}, err
}
if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil {
return RuleNginxView{}, err
}
endpoints := nginx.ParseServerEndpoints(custom)
if err := s.store.EnsureEndpointsAvailable(ctx, endpoints, id); err != nil {
// 备份文件已写回(与原有行为一致),占用记录需跟磁盘内容保持一致,避免留下过期占用
_ = s.store.ReplaceCustomEndpoints(ctx, id, endpoints)
return RuleNginxView{}, err
}
if err := s.store.ReplaceCustomEndpoints(ctx, id, endpoints); err != nil {
return RuleNginxView{}, err
}
if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
return RuleNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return RuleNginxView{}, err
}
return s.GetRuleNginx(ctx, id)
}
func (s *ProxyService) GetGlobalNginx(ctx context.Context) (GlobalNginxView, error) {
mode, err := s.settings.Get(ctx, settings.KeyNginxGlobalMode)
if err != nil {
return GlobalNginxView{}, err
}
if mode == "" {
mode = "auto"
}
content, err := nginx.ReadGlobalCustom(s.cfg)
if err != nil {
return GlobalNginxView{}, err
}
backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
if err != nil {
return GlobalNginxView{}, err
}
return GlobalNginxView{
Mode: mode,
GeneratedFramework: nginx.GenerateGlobalFramework(s.cfg),
GeneratedSnippet: nginx.DefaultGlobalHTTPSnippet(),
Content: content,
Backups: mapBackups(backups),
}, nil
}
func (s *ProxyService) SaveGlobalNginx(ctx context.Context, in SaveGlobalNginxInput) (GlobalNginxView, error) {
mode := strings.TrimSpace(in.Mode)
if mode == "" {
var err error
mode, err = s.settings.Get(ctx, settings.KeyNginxGlobalMode)
if err != nil {
return GlobalNginxView{}, err
}
}
if mode != "auto" && mode != "custom" {
return GlobalNginxView{}, fmt.Errorf("无效的 nginx 模式")
}
if mode == "auto" {
if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "auto"); err != nil {
return GlobalNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return GlobalNginxView{}, err
}
return s.GetGlobalNginx(ctx)
}
if in.Content == nil {
return GlobalNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
}
content := *in.Content
if err := s.validateGlobalNginxPending(ctx, content); err != nil {
return GlobalNginxView{}, err
}
if _, err := nginx.SaveGlobalCustomWithBackup(s.cfg, content); err != nil {
return GlobalNginxView{}, err
}
if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
return GlobalNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return GlobalNginxView{}, err
}
return s.GetGlobalNginx(ctx)
}
func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName string) (GlobalNginxView, error) {
backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
if err != nil {
return GlobalNginxView{}, err
}
if len(backups) == 0 {
return GlobalNginxView{}, fmt.Errorf("没有可回滚的备份")
}
if backupName == "" {
backupName = backups[0].Name
}
if err := nginx.RestoreBackup(s.cfg.NginxGlobalBackupsDir(), backupName, s.cfg.NginxGlobalCustomPath()); err != nil {
return GlobalNginxView{}, err
}
content, err := nginx.ReadGlobalCustom(s.cfg)
if err != nil {
return GlobalNginxView{}, err
}
if err := s.validateGlobalNginxPending(ctx, content); err != nil {
return GlobalNginxView{}, err
}
if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
return GlobalNginxView{}, err
}
if err := s.applyNginx(ctx); err != nil {
return GlobalNginxView{}, err
}
return s.GetGlobalNginx(ctx)
}
func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
rules, err := s.store.ListEnabled(ctx)
if err != nil {
return err
}
merged := make([]proxy.Rule, 0, len(rules))
found := false
for _, r := range rules {
if r.ID == rule.ID {
ruleCopy := rule
ruleCopy.NginxMode = "custom"
ruleCopy.Enabled = true
merged = append(merged, ruleCopy)
found = true
continue
}
merged = append(merged, r)
}
if !found && rule.Enabled {
ruleCopy := rule
ruleCopy.NginxMode = "custom"
merged = append(merged, ruleCopy)
}
return s.validateWithOverrides(ctx, merged, nginx.GenerateOptions{
RuleCustomOverrides: map[int64]string{rule.ID: content},
})
}
func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
rules, err := s.store.ListEnabled(ctx)
if err != nil {
return err
}
return s.validateWithOverrides(ctx, rules, nginx.GenerateOptions{
GlobalCustomOverride: content,
})
}
func (s *ProxyService) validateWithOverrides(ctx context.Context, rules []proxy.Rule, extra nginx.GenerateOptions) error {
certs, err := s.loadCertSources(ctx)
if err != nil {
return err
}
opts := s.loadGenerateOptions(ctx)
opts.GlobalCustomOverride = extra.GlobalCustomOverride
opts.GlobalCustomPathOverride = extra.GlobalCustomPathOverride
opts.RuleCustomOverrides = extra.RuleCustomOverrides
opts.ChinaCIDRAvailable = nginx.ChinaCIDRExists(s.cfg)
content, err := nginx.Generate(s.cfg, rules, certs, opts)
if err != nil {
return err
}
return s.nginx.ValidateContent(ctx, content)
}
func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
out := make([]NginxBackupInfo, 0, len(entries))
for _, entry := range entries {
out = append(out, NginxBackupInfo{
Name: entry.Name,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
return out
}