diff --git a/backend/internal/listener/p2.go b/backend/internal/listener/p2.go index 6fe2370..cb68d8b 100644 --- a/backend/internal/listener/p2.go +++ b/backend/internal/listener/p2.go @@ -126,19 +126,25 @@ func (s *Service) BatchCreate(list []models.Listener) ([]models.Listener, error) } created := make([]models.Listener, 0, len(list)) for i := range list { + // Batch-created listeners must go through the same autofill path as + // single Create so UUIDs / passwords / REALITY keys are generated and + // the config passes mihomo validation. Without this, mihomo rejects the + // entire config and the whole batch fails. + if err := AutofillListenerDefaults(&list[i]); err != nil { + tx.Rollback() + return nil, fmt.Errorf("autofill listener defaults: %w", err) + } if err := ValidateModel(&list[i]); err != nil { tx.Rollback() return nil, err } - var n int64 - if err := tx.Model(&models.Listener{}).Where("name = ?", list[i].Name).Count(&n).Error; err != nil { + // GORM's default scope excludes soft-deleted rows, but the UNIQUE(name) + // index covers them. Use ensureUniqueName so soft-deleted rows with the + // same name are renamed out of the way before Create hits the index. + if err := s.ensureUniqueName(&list[i]); err != nil { tx.Rollback() return nil, err } - if n > 0 { - tx.Rollback() - return nil, fmt.Errorf("listener name %q already exists", list[i].Name) - } if err := tx.Create(&list[i]).Error; err != nil { tx.Rollback() return nil, err diff --git a/backend/internal/mihomo/config/generator_listener_auth_test.go b/backend/internal/mihomo/config/generator_listener_auth_test.go index 974e6ba..205ae40 100644 --- a/backend/internal/mihomo/config/generator_listener_auth_test.go +++ b/backend/internal/mihomo/config/generator_listener_auth_test.go @@ -17,13 +17,31 @@ func TestGenerateListenersUsesModelTLS(t *testing.T) { } } -func TestGenerateListenersDoesNotFallbackWhenCredentialStateIsExplicitlyEmpty(t *testing.T) { +func TestGenerateListenersFallsBackToConfigUsersWhenCredentialStateIsExplicitlyEmpty(t *testing.T) { + // When the credential provider reports hasCredState=true but the panel has + // no active credentials for a listener (e.g. all bound users blocked / + // expired), the compiled listener must fall back to the users already + // embedded in the listener Config. Returning nil users here would make + // mihomo reject the entire config ("unset fields: users"), taking down + // every other listener. Falling back to config users keeps the listener + // valid; the enforcer is responsible for disabling the listener when all + // panel users are inactive. listeners := []models.Listener{{BaseModel: models.BaseModel{ID: 1}, Name: "vless", Protocol: "vless", Port: "443", Enabled: true, Config: `{"users":[{"uuid":"legacy"}]}`}} got, err := generateListeners(listeners, map[uint][]Credential{1: {}}) if err != nil { t.Fatal(err) } - if _, ok := got[0]["users"]; ok { - t.Fatalf("legacy users leaked into explicitly empty credential state: %#v", got[0]["users"]) + if len(got) != 1 { + t.Fatalf("expected one compiled listener, got %d", len(got)) + } + users, ok := got[0]["users"].([]map[string]interface{}) + if !ok { + t.Fatalf("expected config users to be present as fallback, got %#v", got[0]["users"]) + } + if len(users) != 1 { + t.Fatalf("expected one fallback user, got %d (%#v)", len(users), users) + } + if uid, _ := users[0]["uuid"].(string); uid != "legacy" { + t.Fatalf("expected legacy config user uuid %q, got %#v", "legacy", users[0]) } } diff --git a/backend/internal/user/service_batch.go b/backend/internal/user/service_batch.go index 69f231f..39b1c3a 100644 --- a/backend/internal/user/service_batch.go +++ b/backend/internal/user/service_batch.go @@ -106,6 +106,12 @@ func (s *Service) Batch(action BatchAction, ids []uint) (int, error) { if res.Error != nil { return 0, res.Error } + // Resetting traffic may unblock previously over-quota users. Trigger a + // config regeneration so blocked users regain access immediately + // instead of waiting for the next enforcer tick (up to 10s latency). + if err := s.notifyCredentialsChanged(); err != nil { + log.Printf("warning: credentials changed notification failed: %v", err) + } return int(res.RowsAffected), nil case BatchDelete: if err := s.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/user/service_crud.go b/backend/internal/user/service_crud.go index 0f7b203..c759a8d 100644 --- a/backend/internal/user/service_crud.go +++ b/backend/internal/user/service_crud.go @@ -43,7 +43,10 @@ func (s *Service) Create(in CreateInput) (*models.ProxyUser, error) { if err != nil { return nil, err } - subTok, _ := randomHex(16) + subTok, err := randomHex(16) + if err != nil { + return nil, fmt.Errorf("generate proxy user sub token: %w", err) + } u := &models.ProxyUser{ Username: username, PasswordEncrypted: encrypted,