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
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/proxy"
|
||||
)
|
||||
|
||||
// ParseServerEndpoints 从 Nginx 配置文本里抽取各 server 块的 (server_name, listen 端口) 组合,
|
||||
// 返回 proxy.Endpoint(与表单规则的端点同一类型),用于手动(custom)规则的监听占用校验。
|
||||
//
|
||||
// 采取保守策略:解析不出的写法(include 片段、正则 server_name、listen unix:、变量)一律忽略。
|
||||
// 该校验只用于保存时提示,宁可漏报也不能因为解析失败而拦住保存。
|
||||
func ParseServerEndpoints(content string) []proxy.Endpoint {
|
||||
var out []proxy.Endpoint
|
||||
seen := map[string]bool{}
|
||||
|
||||
var (
|
||||
inServer bool
|
||||
depth int
|
||||
ports []int
|
||||
names []string
|
||||
)
|
||||
|
||||
flush := func() {
|
||||
// 未写 listen 的 server 块按 Nginx 默认监听端口 80 处理
|
||||
if len(ports) == 0 && len(names) > 0 {
|
||||
ports = []int{80}
|
||||
}
|
||||
for _, port := range ports {
|
||||
for _, name := range names {
|
||||
key := name + ":" + strconv.Itoa(port)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, proxy.Endpoint{Hostname: name, Port: port})
|
||||
}
|
||||
}
|
||||
inServer, depth, ports, names = false, 0, nil, nil
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(content, "\n") {
|
||||
line := stripInlineComment(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
opens := strings.Count(line, "{")
|
||||
closes := strings.Count(line, "}")
|
||||
if !inServer {
|
||||
if opens > 0 && serverBlockOpens(line) {
|
||||
inServer, depth = true, opens-closes
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "listen"):
|
||||
if port, ok := listenPort(line); ok {
|
||||
ports = append(ports, port)
|
||||
}
|
||||
case strings.HasPrefix(line, "server_name"):
|
||||
names = append(names, serverNames(line)...)
|
||||
}
|
||||
depth += opens - closes
|
||||
if depth <= 0 {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serverBlockOpens(line string) bool {
|
||||
fields := strings.Fields(line)
|
||||
return len(fields) > 0 && fields[0] == "server" && strings.Contains(line, "{")
|
||||
}
|
||||
|
||||
// listenPort 从 listen 指令取出端口号;unix socket 等无法判定的写法返回 false
|
||||
func listenPort(line string) (int, bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
addr := strings.TrimSuffix(fields[1], ";")
|
||||
if strings.Contains(addr, "unix:") {
|
||||
return 0, false
|
||||
}
|
||||
if idx := strings.LastIndex(addr, ":"); idx >= 0 {
|
||||
addr = addr[idx+1:]
|
||||
}
|
||||
port, err := strconv.Atoi(addr)
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return 0, false
|
||||
}
|
||||
return port, true
|
||||
}
|
||||
|
||||
// serverNames 从 server_name 指令取出字面量主机名;正则(~ 开头)与占位符(含 $)跳过
|
||||
func serverNames(line string) []string {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, field := range fields[1:] {
|
||||
name := strings.ToLower(strings.TrimSuffix(field, ";"))
|
||||
if name == "" || name == "_" || strings.HasPrefix(name, "~") || strings.Contains(name, "$") {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package nginx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseServerEndpoints(t *testing.T) {
|
||||
content := `server {
|
||||
listen 8011;
|
||||
listen [::]:8011;
|
||||
server_name a.com b.com;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
}
|
||||
|
||||
# 注释行不参与解析
|
||||
server {
|
||||
listen 127.0.0.1:8017 ssl;
|
||||
server_name *.c.com ~^regex\.com$ _;
|
||||
}
|
||||
`
|
||||
endpoints := ParseServerEndpoints(content)
|
||||
want := []struct {
|
||||
host string
|
||||
port int
|
||||
}{
|
||||
{"a.com", 8011},
|
||||
{"b.com", 8011},
|
||||
{"*.c.com", 8017},
|
||||
}
|
||||
if len(endpoints) != len(want) {
|
||||
t.Fatalf("expected %d endpoints, got %d (%+v)", len(want), len(endpoints), endpoints)
|
||||
}
|
||||
for i, expect := range want {
|
||||
if endpoints[i].Hostname != expect.host || endpoints[i].Port != expect.port {
|
||||
t.Fatalf("endpoint %d = %s:%d, want %s:%d", i, endpoints[i].Hostname, endpoints[i].Port, expect.host, expect.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerEndpointsDefaultsToPort80(t *testing.T) {
|
||||
endpoints := ParseServerEndpoints("server {\n server_name d.com;\n return 444;\n}\n")
|
||||
if len(endpoints) != 1 || endpoints[0].Hostname != "d.com" || endpoints[0].Port != 80 {
|
||||
t.Fatalf("expected d.com:80, got %+v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerEndpointsIgnoresUnparseable(t *testing.T) {
|
||||
content := `include /etc/nginx/snippets/extra.conf;
|
||||
server {
|
||||
listen unix:/run/fonu.sock;
|
||||
server_name $host;
|
||||
}
|
||||
`
|
||||
if endpoints := ParseServerEndpoints(content); len(endpoints) != 0 {
|
||||
t.Fatalf("expected no endpoints, got %+v", endpoints)
|
||||
}
|
||||
}
|
||||
@@ -105,9 +105,12 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(smtpEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
// 单个通道的密文损坏(例如更换过加密密钥)不应让整份配置加载失败:
|
||||
// 仅标记该通道不可用并继续解析其它通道,避免 Telegram/Webhook 一起失效
|
||||
cfg.Email.HasPassword = false
|
||||
} else {
|
||||
secrets.smtpPassword = plain
|
||||
}
|
||||
secrets.smtpPassword = plain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +120,11 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(webhookEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Webhook.HasSecret = false
|
||||
} else {
|
||||
secrets.webhookSecret = plain
|
||||
}
|
||||
secrets.webhookSecret = plain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,9 +134,11 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
if decrypt && s.secret != nil {
|
||||
plain, err := s.secret.Decrypt(telegramEnc)
|
||||
if err != nil {
|
||||
return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
|
||||
// 同上:只让该通道不可用,不影响其它通道
|
||||
cfg.Telegram.HasBotToken = false
|
||||
} else {
|
||||
secrets.telegramToken = plain
|
||||
}
|
||||
secrets.telegramToken = plain
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,9 +41,18 @@ func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, c
|
||||
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
|
||||
// 常见误填:只写 host:port(无 scheme)。补全后再解析,并校验 scheme 是否受支持
|
||||
if !strings.Contains(proxyURL, "://") {
|
||||
proxyURL = "http://" + proxyURL
|
||||
}
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Telegram 代理地址无效")
|
||||
return fmt.Errorf("Telegram 代理地址无效:%s", proxyURL)
|
||||
}
|
||||
switch parsed.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return fmt.Errorf("Telegram 代理仅支持 http / https / socks5,当前为 %q", parsed.Scheme)
|
||||
}
|
||||
httpClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// ReplaceCustomEndpoints 用解析出的占用集合替换该规则在 proxy_custom_endpoints 里的记录。
|
||||
// 手动(custom)Nginx 规则保存或回滚成功后调用,使其占用对其它规则的保存校验可见。
|
||||
func (s *Store) ReplaceCustomEndpoints(ctx context.Context, ruleID int64, endpoints []Endpoint) error {
|
||||
if err := s.ClearCustomEndpoints(ctx, ruleID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
if _, err := s.querier().ExecContext(ctx, `
|
||||
INSERT INTO proxy_custom_endpoints(rule_id, hostname, listen_port)
|
||||
VALUES (?, ?, ?)
|
||||
`, ruleID, endpoint.Hostname, endpoint.Port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCustomEndpoints 清除该规则的手动配置占用(切回自动模式或删除规则时调用)
|
||||
func (s *Store) ClearCustomEndpoints(ctx context.Context, ruleID int64) error {
|
||||
_, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_custom_endpoints WHERE rule_id = ?`, ruleID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureEndpointsAvailable 校验一批 (域名, 端口) 是否已被其它规则占用,
|
||||
// 用于手动 Nginx 配置的保存前检查(与表单规则的校验共用同一条判定)。
|
||||
func (s *Store) EnsureEndpointsAvailable(ctx context.Context, endpoints []Endpoint, excludeRuleID int64) error {
|
||||
for _, endpoint := range endpoints {
|
||||
if err := s.ensureHostAvailable(ctx, endpoint.Hostname, endpoint.Port, excludeRuleID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
name := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", name))
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
conn.SetMaxOpenConns(1)
|
||||
if err := db.Migrate(ctx, conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate test db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return NewStore(conn), conn
|
||||
}
|
||||
|
||||
func createTestRule(t *testing.T, store *Store, name, host string, port int) Rule {
|
||||
t.Helper()
|
||||
rule, err := store.Create(context.Background(), CreateInput{
|
||||
Upstream: "http://192.168.1.10:8080",
|
||||
ListenPort: port,
|
||||
ListenIPv4: true,
|
||||
Hosts: []string{host},
|
||||
Enabled: true,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create rule %s: %v", name, err)
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func TestEnsureEndpointsAvailableNamesOwningRule(t *testing.T) {
|
||||
store, _ := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
owner := createTestRule(t, store, "官网", "a.com", 8011)
|
||||
|
||||
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "规则「官网」") {
|
||||
t.Fatalf("expected owning rule name in error, got %q", err.Error())
|
||||
}
|
||||
|
||||
// 编辑自身不应被判为冲突
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8011}}, owner.ID); err != nil {
|
||||
t.Fatalf("exclude self should pass, got %v", err)
|
||||
}
|
||||
// 同域名不同端口是合法虚拟主机
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "a.com", Port: 8012}}, 0); err != nil {
|
||||
t.Fatalf("different port should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureEndpointsAvailableSeesCustomEndpoints(t *testing.T) {
|
||||
store, conn := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
custom := createTestRule(t, store, "手动规则", "b.com", 8011)
|
||||
if err := store.ReplaceCustomEndpoints(ctx, custom.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
|
||||
t.Fatalf("replace custom endpoints: %v", err)
|
||||
}
|
||||
|
||||
err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict with custom endpoints")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "手动 Nginx 配置") {
|
||||
t.Fatalf("expected custom-rule hint in error, got %q", err.Error())
|
||||
}
|
||||
|
||||
// 删除规则后占用记录随外键级联清除
|
||||
if _, err := conn.ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, custom.ID); err != nil {
|
||||
t.Fatalf("delete rule: %v", err)
|
||||
}
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
|
||||
t.Fatalf("cascade should free the binding, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCustomEndpoints(t *testing.T) {
|
||||
store, _ := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
rule := createTestRule(t, store, "手动规则", "b.com", 8011)
|
||||
if err := store.ReplaceCustomEndpoints(ctx, rule.ID, []Endpoint{{Hostname: "c.com", Port: 8013}}); err != nil {
|
||||
t.Fatalf("replace custom endpoints: %v", err)
|
||||
}
|
||||
if err := store.ClearCustomEndpoints(ctx, rule.ID); err != nil {
|
||||
t.Fatalf("clear custom endpoints: %v", err)
|
||||
}
|
||||
if err := store.EnsureEndpointsAvailable(ctx, []Endpoint{{Hostname: "c.com", Port: 8013}}, 0); err != nil {
|
||||
t.Fatalf("cleared binding should be free, got %v", err)
|
||||
}
|
||||
}
|
||||
+61
-11
@@ -360,6 +360,17 @@ func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, err
|
||||
if _, err := s.querier().ExecContext(ctx, `
|
||||
UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
|
||||
`, listenPort, id, oldListenPort); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// 保存前校验与写入之间存在窗口,被并发规则抢占时按域名复检并给出带规则名的原因
|
||||
for _, host := range current.Hosts {
|
||||
if hostEffectivePort(host, oldListenPort) != oldListenPort {
|
||||
continue
|
||||
}
|
||||
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); ownerErr != nil {
|
||||
return Rule{}, ownerErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return Rule{}, err
|
||||
}
|
||||
}
|
||||
@@ -463,6 +474,11 @@ func (s *Store) cleanupOrphanRules(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// isUniqueViolation 判断是否为 SQLite 唯一约束冲突(hostname + listen_port 撞车)
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToUpper(err.Error()), "UNIQUE")
|
||||
}
|
||||
|
||||
func hostEffectivePort(host Host, ruleListenPort int) int {
|
||||
if host.ListenPort != nil && *host.ListenPort > 0 {
|
||||
return *host.ListenPort
|
||||
@@ -497,22 +513,52 @@ func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
|
||||
}
|
||||
|
||||
func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error {
|
||||
var ownerID int64
|
||||
err := s.querier().QueryRowContext(ctx, `
|
||||
SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ? LIMIT 1
|
||||
`, hostname, port).Scan(&ownerID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
owner, err := s.endpointOwner(ctx, "proxy_hosts", hostname, port, excludeRuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerID != excludeRuleID {
|
||||
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port)
|
||||
if owner != "" {
|
||||
return fmt.Errorf("域名 %s:%d 已被%s使用,请编辑现有规则或更换域名/端口", hostname, port, owner)
|
||||
}
|
||||
// 手动(custom)Nginx 规则的占用单独存表,其域名/端口不在 proxy_hosts 里,需一并检查
|
||||
owner, err = s.endpointOwner(ctx, "proxy_custom_endpoints", hostname, port, excludeRuleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != "" {
|
||||
return fmt.Errorf("域名 %s:%d 已被%s使用(该规则使用手动 Nginx 配置),请编辑现有规则或更换域名/端口", hostname, port, owner)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointOwner 返回占用该 (域名, 端口) 的规则描述(如 规则「名称」);空闲时返回空串。
|
||||
// table 只传入本包内两张固定表名(proxy_hosts / proxy_custom_endpoints),不接受外部输入。
|
||||
// 规则描述优先用备注名,其次用该规则的首个域名,最后回落为规则 ID。
|
||||
func (s *Store) endpointOwner(ctx context.Context, table, hostname string, port int, excludeRuleID int64) (string, error) {
|
||||
var (
|
||||
ownerID int64
|
||||
ownerDesc string
|
||||
)
|
||||
query := `SELECT h.rule_id,
|
||||
COALESCE(NULLIF(TRIM(r.name), ''),
|
||||
(SELECT hostname FROM proxy_hosts WHERE rule_id = h.rule_id ORDER BY id LIMIT 1), '')
|
||||
FROM ` + table + ` h
|
||||
LEFT JOIN proxy_rules r ON r.id = h.rule_id
|
||||
WHERE h.hostname = ? COLLATE NOCASE AND h.listen_port = ? AND h.rule_id != ?
|
||||
LIMIT 1`
|
||||
err := s.querier().QueryRowContext(ctx, query, hostname, port, excludeRuleID).Scan(&ownerID, &ownerDesc)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if desc := strings.TrimSpace(ownerDesc); desc != "" {
|
||||
return fmt.Sprintf("规则「%s」", desc), nil
|
||||
}
|
||||
return fmt.Sprintf("规则 #%d", ownerID), nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
|
||||
for _, host := range hosts {
|
||||
port := hostEffectivePort(host, ruleListenPort)
|
||||
@@ -534,8 +580,12 @@ func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort i
|
||||
VALUES (?, ?, ?)
|
||||
`, ruleID, host.Hostname, port)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", host.Hostname, port)
|
||||
if isUniqueViolation(err) {
|
||||
// 并发下可能绕过保存前校验:复检一次以给出带规则名的原因
|
||||
if ownerErr := s.ensureHostAvailable(ctx, host.Hostname, port, ruleID); ownerErr != nil {
|
||||
return ownerErr
|
||||
}
|
||||
return fmt.Errorf("域名 %s:%d 已被其它规则占用", host.Hostname, port)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleN
|
||||
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
|
||||
}
|
||||
@@ -118,9 +122,18 @@ func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleN
|
||||
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
|
||||
}
|
||||
@@ -158,6 +171,15 @@ func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupNa
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user