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:
2026-09-23 17:46:21 +08:00
parent 1e915fe7c1
commit fd1661622e
23 changed files with 1008 additions and 53 deletions
+39
View File
@@ -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
}
+109
View File
@@ -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
View File
@@ -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
}