diff --git a/agent/app/dto/firewall.go b/agent/app/dto/firewall.go index 5c46a9ceb..65d645fec 100644 --- a/agent/app/dto/firewall.go +++ b/agent/app/dto/firewall.go @@ -229,8 +229,9 @@ type DockerPortGuardOperation struct { } type FirewallRuleCheckItem struct { - UUID string `json:"uuid" validate:"omitempty,max=64"` - Rule filter.FirewallRule `json:"rule" validate:"required"` + AdoptLocator *filter.Locator `json:"adoptLocator,omitempty" validate:"excluded_with=UUID"` + UUID string `json:"uuid" validate:"omitempty,max=64"` + Rule filter.FirewallRule `json:"rule" validate:"required"` } type FirewallRuleCheck struct { @@ -342,8 +343,11 @@ type FirewallRuleDeleteFailure struct { } type FirewallRuleUpdate struct { - UUID string `json:"uuid" validate:"required,max=64"` - Rule filter.FirewallRule `json:"rule" validate:"required"` + UUID string `json:"uuid" validate:"required,max=64"` + Rule *filter.FirewallRule `json:"rule,omitempty" validate:"required_without_all=Description OrderIndex Priority,excluded_with=Description OrderIndex Priority"` + Description *string `json:"description,omitempty" validate:"excluded_with=Rule"` + OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"` + Priority *int `json:"priority,omitempty" validate:"excluded_with=Rule OrderIndex"` } type FirewallRuleReorder struct { diff --git a/agent/app/service/firewall.go b/agent/app/service/firewall.go index 35d41555d..b66c90f6c 100644 --- a/agent/app/service/firewall.go +++ b/agent/app/service/firewall.go @@ -782,9 +782,6 @@ func (s *FirewallService) checkUpdate( if err != nil { return dto.FirewallRuleCheckResult{}, err } - if err := s.ensureFirewallRuleIdentityAvailable(ctx, semantic, prepared.Stored.UUID); err != nil { - return dto.FirewallRuleCheckResult{}, err - } return dto.FirewallRuleCheckResult{ Decision: filter.CheckDecisionReady, Classification: filter.CheckClassificationNone, @@ -885,17 +882,30 @@ func (s *FirewallService) Check( state = checkState{snapshot: snapshot, desired: desiredByScope[scopeKey], managedRevision: managedRevision} states[scopeKey] = state } - checked, checkErr := filter.CheckCreate(state.snapshot, rule, state.desired) + var checked filter.RuleCheckResult + var checkErr error + if item.AdoptLocator != nil { + checked, checkErr = filter.CheckAdopt(state.snapshot, rule, state.desired, *item.AdoptLocator) + } else { + checked, checkErr = filter.CheckCreate(state.snapshot, rule, state.desired) + } if checkErr != nil { return dto.FirewallRuleCheckResponse{}, checkErr } - if checked.Decision == filter.CheckDecisionReady { + adopting := checked.Decision == filter.CheckDecisionConfirmationRequired && checked.Classification == filter.CheckClassificationExactExternal + if checked.Decision == filter.CheckDecisionReady || adopting { for _, previous := range pending { if collision := filter.CheckRuleCollision(rule, previous); collision != nil { + if adopting && errors.Is(collision, filter.ErrRuleConflict) { + continue + } if errors.Is(collision, filter.ErrRuleConflict) { checked.Decision, checked.Classification, checked.Reason = filter.CheckDecisionBlocked, filter.CheckClassificationConflict, "exact_rule_conflict" } else if errors.Is(collision, filter.ErrRuleOperation) { checked.Decision, checked.Classification, checked.Reason = filter.CheckDecisionNoChange, filter.CheckClassificationExactManaged, "equivalent_batch_rule" + if adopting { + checked.Decision = filter.CheckDecisionBlocked + } } else { return dto.FirewallRuleCheckResponse{}, collision } @@ -903,7 +913,7 @@ func (s *FirewallService) Check( break } } - if checked.Decision == filter.CheckDecisionReady { + if checked.Decision == filter.CheckDecisionReady || checked.Decision == filter.CheckDecisionConfirmationRequired { pending = append(pending, rule) } } @@ -1132,7 +1142,16 @@ func (s *FirewallService) prepareCreate( sourceKind = constant.FirewallRuleSourceUser } for _, previous := range prepared { + if authorization.Operation == filter.ChangeAdopt || previous.authorization.Operation == filter.ChangeAdopt { + if authorization.Locator != nil && previous.authorization.Locator != nil && + filter.SameLocator(*authorization.Locator, *previous.authorization.Locator) { + return nil, index, filter.ErrRuleOperation + } + } if err := filter.CheckRuleCollision(rule, previous.request.Rule); err != nil { + if errors.Is(err, filter.ErrRuleConflict) && (authorization.Operation == filter.ChangeAdopt || previous.authorization.Operation == filter.ChangeAdopt) { + continue + } return nil, index, err } } @@ -1192,10 +1211,12 @@ func (s *FirewallService) createNativeRuleBatch(ctx context.Context, prepared [] if err != nil { return err } - identities := make(firewallRuleIdentityIndex, len(stored)+len(prepared)) + identities, err := firewallRuleCollisions(stored, runtime.Provider(), "") + if err != nil { + return err + } var maximumSequence int64 for _, record := range stored { - identities.add(record) if record.Sequence != nil && *record.Sequence > maximumSequence { maximumSequence = *record.Sequence } @@ -1213,7 +1234,7 @@ func (s *FirewallService) createNativeRuleBatch(ctx context.Context, prepared [] record.Sequence = &sequence nextSequence += model.FirewallRuleSequenceStep } - if err := identities.check(record); err != nil { + if err := identities.Check(domainRule); err != nil { return s.cleanupFirewallBatchRecords(ctx, created, err) } if err := filter.CheckObservedRuleCollisions(snapshot, domainRule, nil); err != nil { @@ -1222,7 +1243,9 @@ func (s *FirewallService) createNativeRuleBatch(ctx context.Context, prepared [] if recordErr = s.rules.Create(ctx, &record); recordErr != nil { return s.cleanupFirewallBatchRecords(ctx, created, recordErr) } - identities.add(record) + if err := identities.Add(domainRule); err != nil { + return s.cleanupFirewallBatchRecords(ctx, append(created, createdFirewallBatchRule{record: record, rule: domainRule}), err) + } domainRule.UUID = record.UUID created = append(created, createdFirewallBatchRule{record: record, rule: domainRule}) } @@ -1539,15 +1562,43 @@ func (s *FirewallService) restoreDeletedFirewallRecords( func (s *FirewallService) Update(ctx context.Context, clientIP string, request dto.FirewallRuleUpdate) error { firewallRuleMutationMu.Lock() defer firewallRuleMutationMu.Unlock() - rule := request.Rule - rule.UUID = request.UUID - return s.updateRule(ctx, clientIP, request.UUID, rule) + metadata := request.Description != nil || request.OrderIndex != nil || request.Priority != nil + if (request.Rule != nil) == metadata || request.OrderIndex != nil && request.Priority != nil { + return fmt.Errorf("%w: provide a rule or description/ordering fields", filter.ErrInvalidRule) + } + if request.Rule != nil { + rule := *request.Rule + rule.UUID = request.UUID + return s.updateRule(ctx, clientIP, request.UUID, rule) + } + if request.OrderIndex != nil || request.Priority != nil { + return s.updateRuleOrder(ctx, request.UUID, request.OrderIndex, request.Priority, request.Description) + } + return s.updateRuleDescription(ctx, request.UUID, *request.Description) +} + +func (s *FirewallService) updateRuleDescription(ctx context.Context, ruleUUID, description string) error { + stored, err := s.rules.GetByUUID(ctx, ruleUUID) + if err != nil { + return err + } + if isProtectedSystemFirewallRule(stored) { + return filter.ErrProtectedRule + } + if stored.Origin != constant.FirewallRuleOriginCreated && stored.Origin != constant.FirewallRuleOriginAdopted { + return fmt.Errorf("%w: only created or adopted rules can be changed", filter.ErrInvalidRule) + } + description = strings.TrimSpace(description) + if stored.Description == description { + return nil + } + return s.rules.UpdateWithRevision(ctx, stored.UUID, stored.Revision, map[string]interface{}{"description": description}) } func (s *FirewallService) Reorder(ctx context.Context, clientIP string, request dto.FirewallRuleReorder) error { firewallRuleMutationMu.Lock() defer firewallRuleMutationMu.Unlock() - return s.reorderRule(ctx, clientIP, request.UUID, request.TargetPosition, request.Priority) + return s.updateRuleOrder(ctx, request.UUID, request.TargetPosition, request.Priority, nil) } func (s *FirewallService) checkSelectedProvider(ctx context.Context, requested filter.Provider) error { @@ -1569,6 +1620,11 @@ func (s *FirewallService) createRule( authorization firewallRuleCreateAuthorization, ) error { domainRule := request.Rule + if authorization.Operation == filter.ChangeAdopt { + if err := filter.CheckAdoptDuplicates(snapshot, domainRule); err != nil { + return err + } + } if authorization.Operation == filter.ChangeCreate { if err := filter.CheckObservedRuleCollisions(snapshot, domainRule, nil); err != nil { return err @@ -1606,8 +1662,25 @@ func (s *FirewallService) createRule( if err != nil { return err } - if err := s.ensureFirewallRuleIdentityAvailable(ctx, ruleRecord, ""); err != nil { - return err + if authorization.Operation == filter.ChangeCreate { + if err := s.ensureFirewallRuleIdentityAvailable(ctx, domainRule, ""); err != nil { + return err + } + } else if authorization.Operation == filter.ChangeAdopt { + stored, err := s.rules.List(ctx) + if err != nil { + return err + } + identities, err := firewallRuleCollisions(stored, domainRule.Scope.Provider, "") + if err != nil { + return err + } + if err := identities.CheckDuplicate(domainRule); err != nil { + if errors.Is(err, filter.ErrRuleOperation) { + return filter.ErrDuplicateAdoption + } + return err + } } if domainRule.Scope.Provider != filter.ProviderFirewalld { sequence, sequenceErr := s.sequenceForCreatedFirewallRule(ctx, snapshot, domainRule) @@ -1746,11 +1819,43 @@ func managedFirewallRuleMissing(snapshot filter.Snapshot, desired filter.Desired } func (s *FirewallService) updateRule(ctx context.Context, clientIP, ruleUUID string, requestedRule filter.FirewallRule) error { + requestedRule, err := filter.NormalizeRule(requestedRule) + if err != nil { + return err + } + stored, err := s.rules.GetByUUID(ctx, ruleUUID) + if err != nil { + return err + } + previousRules, compileErr := stored.RulesForProvider(requestedRule.Scope.Provider) + if compileErr == nil && len(previousRules) == 1 { + sameContent, err := filter.SameRuleContent(previousRules[0], requestedRule) + if err != nil { + return err + } + if sameContent { + if requestedRule.Scope.Provider == filter.ProviderFirewalld { + beforePriority, afterPriority := 0, 0 + if stored.Priority != nil { + beforePriority = *stored.Priority + } + if requestedRule.Priority != nil { + afterPriority = *requestedRule.Priority + } + if beforePriority != afterPriority { + return s.updateRuleOrder(ctx, ruleUUID, nil, &afterPriority, &requestedRule.Description) + } + } else if requestedRule.OrderIndex != nil { + return s.updateRuleOrder(ctx, ruleUUID, requestedRule.OrderIndex, nil, &requestedRule.Description) + } + return s.updateRuleDescription(ctx, ruleUUID, requestedRule.Description) + } + } prepared, err := s.prepareManagedUpdate(ctx, clientIP, ruleUUID, requestedRule) if err != nil { return err } - metadataOnly, err := isUFWMetadataOnlyUpdate(prepared.Before.Rule, prepared.After, prepared.Observed.Locator) + metadataOnly, err := isFirewallMetadataOnlyUpdate(prepared.Before.Rule, prepared.After, prepared.Observed.Locator) if err != nil { return err } @@ -1769,10 +1874,7 @@ func (s *FirewallService) updateRule(ctx context.Context, clientIP, ruleUUID str }) } -func isUFWMetadataOnlyUpdate(before, after filter.FirewallRule, locator filter.Locator) (bool, error) { - if after.Scope.Provider != filter.ProviderUFW || locator.Position == nil || after.OrderIndex == nil { - return false, nil - } +func isFirewallMetadataOnlyUpdate(before, after filter.FirewallRule, locator filter.Locator) (bool, error) { beforeKey, err := filter.RuleKey(before) if err != nil { return false, err @@ -1781,22 +1883,22 @@ func isUFWMetadataOnlyUpdate(before, after filter.FirewallRule, locator filter.L if err != nil { return false, err } - return beforeKey == afterKey && *after.OrderIndex == int64(*locator.Position), nil + if beforeKey != afterKey { + return false, nil + } + if after.Scope.Provider == filter.ProviderFirewalld { + return true, nil + } + return locator.Position != nil && after.OrderIndex != nil && *after.OrderIndex == int64(*locator.Position), nil } -func (s *FirewallService) reorderRule(ctx context.Context, clientIP, ruleUUID string, targetPosition *int64, priority *int) error { +func (s *FirewallService) updateRuleOrder(ctx context.Context, ruleUUID string, targetPosition *int64, priority *int, description *string) error { + if (targetPosition == nil) == (priority == nil) { + return fmt.Errorf("%w: provide either position or priority", filter.ErrInvalidRule) + } if ruleUUID == "" { return fmt.Errorf("%w: rule UUID is required", repo.ErrFirewallPersistenceInvalid) } - if priority != nil { - stored, err := s.rules.GetByUUID(ctx, ruleUUID) - if err != nil { - return err - } - if stored.Priority == nil { - return fmt.Errorf("%w: rule has no explicit reorderable priority", filter.ErrUnsupportedScope) - } - } stored, before, snapshot, observed, runtime, err := s.loadManagedMutation(ctx, ruleUUID) if err != nil { return err @@ -1808,7 +1910,7 @@ func (s *FirewallService) reorderRule(ctx context.Context, clientIP, ruleUUID st after := before.Rule adapterOperation := filter.ChangeReorder switch { - case capabilities.OwnedChains: + case capabilities.ExplicitPosition || capabilities.OwnedChains: if targetPosition == nil || *targetPosition < 1 { return fmt.Errorf("%w: target position is required", filter.ErrInvalidRule) } @@ -1817,6 +1919,9 @@ func (s *FirewallService) reorderRule(ctx context.Context, clientIP, ruleUUID st } after.OrderIndex = targetPosition case capabilities.ExplicitPriority: + if before.Rule.NativeKind != filter.NativeKindRichRule { + return fmt.Errorf("%w: only rich rules support explicit priority", filter.ErrUnsupportedScope) + } if priority == nil { return fmt.Errorf("%w: priority is required", filter.ErrInvalidRule) } @@ -1825,10 +1930,23 @@ func (s *FirewallService) reorderRule(ctx context.Context, clientIP, ruleUUID st default: return fmt.Errorf("%w: provider does not support rule reordering", filter.ErrUnsupportedScope) } + if description != nil { + after.Description = strings.TrimSpace(*description) + } after, err = runtime.Prepare(after) if err != nil { return err } + if err := runtime.CheckRule(ctx, after); err != nil { + return err + } + metadataOnly, err := isFirewallMetadataOnlyUpdate(before.Rule, after, observed.Locator) + if err != nil { + return err + } + if metadataOnly { + return s.updateRuleDescription(ctx, stored.UUID, after.Description) + } if err := filter.GuardMutation(observed); err != nil { return err } @@ -1909,7 +2027,7 @@ func (s *FirewallService) prepareManagedUpdate( if err := filter.GuardMutation(observed); err != nil { return preparedManagedUpdate{}, err } - if err := filter.CheckObservedRuleCollisions(snapshot, after, &observed.Locator); err != nil { + if err := s.checkManagedMutationCollisions(ctx, before.Rule, after, snapshot, observed.Locator, stored.UUID); err != nil { return preparedManagedUpdate{}, err } return preparedManagedUpdate{ @@ -1990,18 +2108,8 @@ func (s *FirewallService) selectedProviderForStoredRule( func (s *FirewallService) executeManagedMutation(ctx context.Context, request managedMutationRequest) error { before, after := request.Before, request.After - if err := filter.CheckObservedRuleCollisions(request.Snapshot, after, &request.Locator); err != nil { - return err - } - semantic, err := model.FirewallRuleFromDomain(after) - if err != nil { - return err - } - if err := s.ensureFirewallRuleIdentityAvailable(ctx, semantic, request.Stored.UUID); err != nil { - return err - } appendRule, restoreAtEnd := false, false - if after.Scope.Provider == filter.ProviderUFW && request.AdapterOperation == filter.ChangeUpdate { + if after.Scope.Provider == filter.ProviderUFW && (request.AdapterOperation == filter.ChangeUpdate || request.AdapterOperation == filter.ChangeReorder) { maxPosition := maxObservedFirewallPosition(request.Snapshot) appendRule = after.OrderIndex != nil && *after.OrderIndex == maxPosition restoreAtEnd = request.Locator.Position != nil && int64(*request.Locator.Position) == maxPosition @@ -2059,50 +2167,55 @@ func maxObservedFirewallPosition(snapshot filter.Snapshot) int64 { return maximum } -func (s *FirewallService) ensureFirewallRuleIdentityAvailable( +func (s *FirewallService) checkManagedMutationCollisions( ctx context.Context, - requested model.FirewallRule, + before, after filter.FirewallRule, + snapshot filter.Snapshot, + locator filter.Locator, excludedUUID string, ) error { + sameContent, err := filter.SameRuleContent(before, after) + if err != nil { + return err + } + if sameContent { + return nil + } + if err := filter.CheckObservedRuleCollisions(snapshot, after, &locator); err != nil { + return err + } + return s.ensureFirewallRuleIdentityAvailable(ctx, after, excludedUUID) +} + +func (s *FirewallService) ensureFirewallRuleIdentityAvailable(ctx context.Context, requested filter.FirewallRule, excludedUUID string) error { stored, err := s.rules.List(ctx) if err != nil { return err } - identities := make(firewallRuleIdentityIndex, len(stored)) + identities, err := firewallRuleCollisions(stored, requested.Scope.Provider, excludedUUID) + if err != nil { + return err + } + return identities.Check(requested) +} + +func firewallRuleCollisions(stored []model.FirewallRule, provider filter.Provider, excludedUUID string) (filter.RuleCollisionIndex, error) { + identities := make(filter.RuleCollisionIndex, len(stored)) for _, candidate := range stored { - if candidate.UUID != excludedUUID { - identities.add(candidate) + if candidate.UUID == excludedUUID { + continue + } + rules, err := candidate.RulesForProvider(provider) + if err != nil { + continue + } + for _, rule := range rules { + if err := identities.Add(rule); err != nil { + return nil, err + } } } - return identities.check(requested) -} - -type firewallRuleIdentityIndex map[string][]filter.Action - -func firewallRuleComparisonKey(rule model.FirewallRule) string { - rule.Action = "" - priority := 0 - if rule.Priority != nil { - priority = *rule.Priority - } - return rule.PolicyKey() + "/" + strconv.Itoa(priority) -} - -func (index firewallRuleIdentityIndex) add(rule model.FirewallRule) { - key := firewallRuleComparisonKey(rule) - index[key] = append(index[key], filter.Action(rule.Action)) -} - -func (index firewallRuleIdentityIndex) check(rule model.FirewallRule) error { - for _, action := range index[firewallRuleComparisonKey(rule)] { - if action == filter.Action(rule.Action) { - return fmt.Errorf("%w: equivalent managed rule already exists", filter.ErrRuleOperation) - } - if filter.OppositeActions(action, filter.Action(rule.Action)) { - return filter.ErrRuleConflict - } - } - return nil + return identities, nil } func (s *FirewallService) resolveRuntime(ctx context.Context, provider filter.Provider) (*filterruntime.Engine, error) { @@ -2619,6 +2732,9 @@ func refreshCreateAuthorization( if authorization.Operation != filter.ChangeAdopt { return authorization, nil } + if err := filter.CheckAdoptDuplicates(snapshot, prepared.request.Rule); err != nil { + return firewallRuleCreateAuthorization{}, err + } if candidate, err := filter.FindCandidate(snapshot.Rules, prepared.request.AdoptInstanceKey); err == nil { locator := candidate.Locator authorization.Locator = &locator @@ -2644,16 +2760,6 @@ func refreshCreateAuthorization( authorization.Locator = &locator return authorization, nil } - if authorization.Locator != nil { - for _, candidate := range candidates { - if authorization.Locator.Canonical != "" && candidate.Locator.Canonical == authorization.Locator.Canonical || - authorization.Locator.NativeID != "" && candidate.Locator.NativeID == authorization.Locator.NativeID { - locator := candidate.Locator - authorization.Locator = &locator - return authorization, nil - } - } - } return firewallRuleCreateAuthorization{}, filter.ErrRuleStale } diff --git a/agent/app/service/firewall_sync.go b/agent/app/service/firewall_sync.go index e2c803db3..46cdbfa7f 100644 --- a/agent/app/service/firewall_sync.go +++ b/agent/app/service/firewall_sync.go @@ -371,17 +371,12 @@ func planFirewallManagedOrder( desiredMarkers = append(desiredMarkers, entry.desired.Marker) } } - drifted, feasible := firewallsync.ManagedOrderDrift(snapshot, desiredMarkers) + drifted := firewallsync.ManagedOrderDrift(snapshot, desiredMarkers) for _, marker := range desiredMarkers { if _, exists := drifted[marker]; !exists { continue } entry := byMarker[marker] - if !feasible { - entry.item.Status = firewallRuleSyncBlocked - entry.item.Reason = "managed rule order cannot cross external, opaque, or protected rules" - continue - } entry.reorder = true if entry.item.Status == firewallRuleSyncExisting { entry.item.Status = firewallRuleSyncReady diff --git a/agent/utils/firewall/filter/adopt.go b/agent/utils/firewall/filter/adopt.go new file mode 100644 index 000000000..8fe594683 --- /dev/null +++ b/agent/utils/firewall/filter/adopt.go @@ -0,0 +1,97 @@ +package filter + +import "fmt" + +var ErrDuplicateAdoption = fmt.Errorf("%w: duplicate firewall rules prevent adoption; manually delete duplicate rules and retry", ErrRuleOperation) + +func CheckAdoptDuplicates(snapshot Snapshot, requested FirewallRule) error { + count := 0 + for _, observed := range snapshot.Rules { + if observed.ParseStatus != ParseStatusSupported { + continue + } + same, err := SameRuleContent(observed.Rule, requested) + if err != nil { + return err + } + if same { + count++ + if count > 1 { + return ErrDuplicateAdoption + } + } + } + return nil +} + +func CheckAdopt(snapshot Snapshot, requested FirewallRule, desired []DesiredRule, locator Locator) (RuleCheckResult, error) { + normalized, err := NormalizeRule(requested) + if err != nil { + return RuleCheckResult{}, err + } + if normalized.Scope.Key() != snapshot.Scope.Key() { + return RuleCheckResult{}, ErrInvalidScope + } + key, err := RuleKey(normalized) + if err != nil { + return RuleCheckResult{}, err + } + result := RuleCheckResult{RequestedRule: normalized, RequestedRuleKey: key} + var selected *ObservedRule + for index := range snapshot.Rules { + candidate := &snapshot.Rules[index] + if SameLocator(candidate.Locator, locator) { + if selected != nil { + return RuleCheckResult{}, ErrRuleStale + } + selected = candidate + } + } + if selected == nil { + return RuleCheckResult{}, ErrRuleStale + } + selectedKey, err := RuleKey(selected.Rule) + if err != nil || selected.ParseStatus != ParseStatusSupported || selectedKey != key { + return RuleCheckResult{}, fmt.Errorf("%w: selected rule changed or cannot be adopted", ErrRuleStale) + } + if err := CheckAdoptDuplicates(snapshot, normalized); err != nil { + if err != ErrDuplicateAdoption { + return RuleCheckResult{}, err + } + result.Decision, result.Classification, result.Reason = CheckDecisionBlocked, CheckClassificationExactExternal, "duplicate_rules" + return finishCheck(result) + } + items, err := MergeInventory(InventoryMergeInput{Observed: snapshot.Rules, Desired: desired}) + if err != nil { + return RuleCheckResult{}, err + } + for _, item := range items { + if item.Desired != nil && item.Observed != nil && SameLocator(item.Observed.Locator, locator) { + result.Decision, result.Classification, result.Reason = CheckDecisionNoChange, CheckClassificationExactManaged, "equivalent_managed_rule" + result.ExistingRuleUUID = item.Desired.UUID + return result, nil + } + } + for _, owned := range desired { + same, err := SameRuleContent(owned.Rule, normalized) + if err != nil { + return RuleCheckResult{}, err + } + if same { + result.Decision, result.Classification, result.Reason = CheckDecisionBlocked, CheckClassificationExactManaged, "duplicate_rules" + result.ExistingRuleUUID = owned.UUID + return result, nil + } + } + result.Candidates = []ObservedRule{*selected} + switch { + case selected.Protected: + result.Decision, result.Classification, result.Reason = CheckDecisionBlocked, CheckClassificationProtected, "protected_rule" + case containsPersistenceDrift(result.Candidates): + result.Decision, result.Classification, result.Reason = CheckDecisionBlocked, CheckClassificationConflict, "runtime_permanent_mismatch" + default: + result.Decision, result.Classification, result.Reason = CheckDecisionConfirmationRequired, CheckClassificationExactExternal, "equivalent_external_rule" + result.AllowedActions = []CheckAction{CheckActionAdopt, CheckActionCancel} + } + return finishCheck(result) +} diff --git a/agent/utils/firewall/filter/check.go b/agent/utils/firewall/filter/check.go index 175e32d22..75064630f 100644 --- a/agent/utils/firewall/filter/check.go +++ b/agent/utils/firewall/filter/check.go @@ -154,6 +154,13 @@ func CheckCreate( } } + if err := CheckAdoptDuplicates(snapshot, normalized); err != nil { + if err != ErrDuplicateAdoption { + return RuleCheckResult{}, err + } + result.Decision, result.Classification, result.Reason = CheckDecisionBlocked, CheckClassificationExactExternal, "duplicate_rules" + return finishCheck(result) + } switch { case containsPersistenceDrift(exact): result.Decision = CheckDecisionBlocked @@ -171,12 +178,6 @@ func CheckCreate( result.Reason = "equivalent_external_rule" result.Candidates = exact result.AllowedActions = []CheckAction{CheckActionAdopt, CheckActionCancel} - case len(exact) > 1: - result.Decision = CheckDecisionConfirmationRequired - result.Classification = CheckClassificationExactExternal - result.Reason = "multiple_equivalent_external_rules" - result.Candidates = exact - result.AllowedActions = []CheckAction{CheckActionSelectAdopt, CheckActionCancel} case equivalentExternal: result.Decision = CheckDecisionNoChange result.Classification = CheckClassificationExactExternal diff --git a/agent/utils/firewall/filter/identity.go b/agent/utils/firewall/filter/identity.go index e86e55797..398dc8267 100644 --- a/agent/utils/firewall/filter/identity.go +++ b/agent/utils/firewall/filter/identity.go @@ -50,9 +50,7 @@ func RuleMatchKey(rule FirewallRule) (string, error) { return "", err } normalized.Action, normalized.NativeKind, normalized.OrderBucket = "", "", "" - if normalized.Priority != nil && *normalized.Priority == 0 { - normalized.Priority = nil - } + normalized.Priority = nil return normalizedRuleKey(normalized) } @@ -61,6 +59,60 @@ func OppositeActions(left, right Action) bool { right == ActionAccept && (left == ActionDrop || left == ActionReject) } +func SameRuleContent(before, after FirewallRule) (bool, error) { + before, err := NormalizeRule(before) + if err != nil { + return false, err + } + after, err = NormalizeRule(after) + if err != nil { + return false, err + } + previous, err := RuleMatchKey(before) + if err != nil { + return false, err + } + requested, err := RuleMatchKey(after) + return err == nil && previous == requested && before.Action == after.Action, err +} + +type RuleCollisionIndex map[string][]Action + +func (index RuleCollisionIndex) Add(rule FirewallRule) error { + key, err := RuleMatchKey(rule) + if err != nil { + return err + } + index[key] = append(index[key], rule.Action) + return nil +} + +func (index RuleCollisionIndex) CheckDuplicate(rule FirewallRule) error { + key, err := RuleMatchKey(rule) + if err != nil { + return err + } + for _, action := range index[key] { + if action == rule.Action { + return checkCollisionActions(rule.Action, action) + } + } + return nil +} + +func (index RuleCollisionIndex) Check(rule FirewallRule) error { + key, err := RuleMatchKey(rule) + if err != nil { + return err + } + for _, action := range index[key] { + if err := checkCollisionActions(rule.Action, action); err != nil { + return err + } + } + return nil +} + func CheckRuleCollision(requested, existing FirewallRule) error { wanted, err := RuleMatchKey(requested) if err != nil { @@ -73,10 +125,14 @@ func CheckRuleCollision(requested, existing FirewallRule) error { if wanted != actual { return nil } - if requested.Action == existing.Action { + return checkCollisionActions(requested.Action, existing.Action) +} + +func checkCollisionActions(requested, existing Action) error { + if requested == existing { return fmt.Errorf("%w: equivalent rule already exists", ErrRuleOperation) } - if OppositeActions(requested.Action, existing.Action) { + if OppositeActions(requested, existing) { return ErrRuleConflict } return nil diff --git a/agent/utils/firewall/filter/providers/firewalld/adapter.go b/agent/utils/firewall/filter/providers/firewalld/adapter.go index 382ba1e9d..cd8dc34bd 100644 --- a/agent/utils/firewall/filter/providers/firewalld/adapter.go +++ b/agent/utils/firewall/filter/providers/firewalld/adapter.go @@ -199,11 +199,6 @@ func (a *Adapter) Compile(snapshot filter.Snapshot, changes []filter.DesiredChan if len(changes) != 1 { return filter.BackendPlan{}, fmt.Errorf("%w: firewalld plans currently require exactly one change", filter.ErrInvalidRule) } - for _, observed := range snapshot.Rules { - if observed.Persistence != "" && observed.Persistence != filter.PersistenceStatusConverged { - return filter.BackendPlan{}, fmt.Errorf("%w: firewalld runtime and permanent state differ", filter.ErrRuleStale) - } - } rulePlan, err := a.compileChange(snapshot, changes[0]) if err != nil { return filter.BackendPlan{}, err @@ -315,7 +310,7 @@ func (a *Adapter) compileChange(snapshot filter.Snapshot, change filter.DesiredC plan := filter.NativeRulePlan{RuleUUID: normalized.UUID, Operation: change.Operation, Expected: expected} switch change.Operation { case filter.ChangeCreate: - plan.Commands, plan.RollbackCommands = pairedCommands(normalized, "add", "remove") + plan.Commands, plan.RollbackCommands = missingRuleCommands(snapshot, normalized) case filter.ChangeAdopt: target, targetErr := validateMutationTarget(snapshot, change, normalized, false) if targetErr != nil { @@ -330,8 +325,11 @@ func (a *Adapter) compileChange(snapshot filter.Snapshot, change filter.DesiredC return filter.NativeRulePlan{}, targetErr } plan.Previous = &target + if target.Locator.Canonical == expected.Locator.Canonical { + break + } removeCommands, restoreCommands := pairedCommands(target.Rule, "remove", "add") - addCommands, removeNewCommands := pairedCommands(normalized, "add", "remove") + addCommands, removeNewCommands := missingRuleCommands(snapshot, normalized) plan.Commands = append(removeCommands, addCommands...) plan.RollbackCommands = append(restoreCommands, removeNewCommands...) case filter.ChangeDelete: @@ -401,6 +399,26 @@ func nativeCanonical(rule filter.FirewallRule) string { return "rich:" + canonicalRichRule(rule) } +func missingRuleCommands(snapshot filter.Snapshot, rule filter.FirewallRule) ([]filter.NativeCommand, []filter.NativeCommand) { + commands, rollback := pairedCommands(rule, "add", "remove") + var runtimeExists, permanentExists bool + for _, observed := range snapshot.Rules { + if observed.Locator.Canonical != nativeCanonical(rule) { + continue + } + runtimeExists = runtimeExists || observed.Persistence == filter.PersistenceStatusConverged || observed.Persistence == filter.PersistenceStatusRuntimeOnly + permanentExists = permanentExists || observed.Persistence == filter.PersistenceStatusConverged || observed.Persistence == filter.PersistenceStatusPermanentOnly + } + var changes, inverses []filter.NativeCommand + for index, exists := range []bool{runtimeExists, permanentExists} { + if !exists { + changes = append(changes, commands[index]) + inverses = append(inverses, rollback[index]) + } + } + return changes, inverses +} + func pairedCommands(rule filter.FirewallRule, operation, inverse string) ([]filter.NativeCommand, []filter.NativeCommand) { option := nativeOption(rule, operation) rollback := nativeOption(rule, inverse) diff --git a/agent/utils/firewall/filter/providers/iptables/adapter.go b/agent/utils/firewall/filter/providers/iptables/adapter.go index 6564dea8b..ea18ad68b 100644 --- a/agent/utils/firewall/filter/providers/iptables/adapter.go +++ b/agent/utils/firewall/filter/providers/iptables/adapter.go @@ -457,9 +457,6 @@ func compileChange(snapshot filter.Snapshot, change filter.DesiredChange) (filte } targetPosition = int(*normalized.OrderIndex) } - if err := validateReorderPath(snapshot, position, targetPosition); err != nil { - return filter.NativeRulePlan{}, err - } if position != targetPosition { return positionalMutationPlan(snapshot, normalized, target, marker, position, targetPosition, change.Operation), nil } @@ -479,9 +476,6 @@ func compileChange(snapshot filter.Snapshot, change filter.DesiredChange) (filte return filter.NativeRulePlan{}, fmt.Errorf("%w: reorder target is out of range", filter.ErrInvalidRule) } targetPosition := int(*normalized.OrderIndex) - if err := validateReorderPath(snapshot, position, targetPosition); err != nil { - return filter.NativeRulePlan{}, err - } return positionalMutationPlan(snapshot, normalized, target, marker, position, targetPosition, change.Operation), nil default: return filter.NativeRulePlan{}, fmt.Errorf("%w: unsupported operation %s", filter.ErrInvalidRule, change.Operation) @@ -560,26 +554,6 @@ func pointerToObserved(rule filter.ObservedRule, include bool) *filter.ObservedR return &rule } -func validateReorderPath(snapshot filter.Snapshot, from, to int) error { - start, end := from, to - if start > end { - start, end = end, start - } - for position := start; position <= end; position++ { - if position == from { - continue - } - observed := snapshot.Rules[position-1] - if observed.Protected { - return filter.ErrProtectedRule - } - if observed.ParseStatus == filter.ParseStatusOpaque || observed.Marker == "" { - return fmt.Errorf("%w: reorder cannot cross external or opaque rules", filter.ErrUnsupportedScope) - } - } - return nil -} - func compileRuleArgs(rule filter.FirewallRule, marker string) []string { args := make([]string, 0, 24) if rule.Protocol != "all" { diff --git a/agent/utils/firewall/filter/providers/nftables/adapter.go b/agent/utils/firewall/filter/providers/nftables/adapter.go index 6de20649e..f6cd9d91d 100644 --- a/agent/utils/firewall/filter/providers/nftables/adapter.go +++ b/agent/utils/firewall/filter/providers/nftables/adapter.go @@ -273,18 +273,6 @@ func applyChange(snapshot filter.Snapshot, change filter.DesiredChange) ([]filte if target < 1 || target > len(rules)+1 { return nil, filter.ObservedRule{}, nil, fmt.Errorf("%w: target position is out of range", filter.ErrInvalidRule) } - if change.Operation == filter.ChangeReorder || change.Operation == filter.ChangeUpdate { - start, end := position, target - if start > end { - start, end = end, start - } - for index := start; index <= end && index <= len(snapshot.Rules); index++ { - candidate := snapshot.Rules[index-1] - if candidate.Protected || candidate.ParseStatus == filter.ParseStatusOpaque || candidate.Marker == "" { - return nil, filter.ObservedRule{}, nil, fmt.Errorf("%w: reorder cannot cross external or opaque rules", filter.ErrUnsupportedScope) - } - } - } expected := observedRule(normalized, marker, target, strings.Join(compileExpressionArgs(normalized, marker), " ")) rules = append(rules, filter.ObservedRule{}) copy(rules[target:], rules[target-1:]) diff --git a/agent/utils/firewall/filter/providers/ufw/adapter.go b/agent/utils/firewall/filter/providers/ufw/adapter.go index 14e81aea1..b5d499982 100644 --- a/agent/utils/firewall/filter/providers/ufw/adapter.go +++ b/agent/utils/firewall/filter/providers/ufw/adapter.go @@ -236,7 +236,7 @@ func (a *Adapter) failedCommandApplied(ctx context.Context, plan filter.NativeRu return plan.Previous == nil || !containsObservedRule(snapshot, *plan.Previous) } return markerCount > 0 - case filter.ChangeUpdate: + case filter.ChangeUpdate, filter.ChangeReorder: if commandIndex == 0 { return markerCount == 0 } @@ -355,7 +355,7 @@ func compileChange(snapshot filter.Snapshot, change filter.DesiredChange) (filte positionedCommand(position, target.Rule, observedComment(target), restoreAtEnd), deleteRuleCommand(normalized, marker), } - case filter.ChangeUpdate: + case filter.ChangeUpdate, filter.ChangeReorder: target, targetErr := validateMutationTarget(snapshot, change, normalized, marker, true) if targetErr != nil { return filter.NativeRulePlan{}, targetErr @@ -404,8 +404,6 @@ func compileChange(snapshot filter.Snapshot, change filter.DesiredChange) (filte plan.RollbackCommands = []filter.NativeCommand{ positionedCommand(position, target.Rule, observedComment(target), restoreAtEnd), } - case filter.ChangeReorder: - return filter.NativeRulePlan{}, fmt.Errorf("%w: ufw reorder is not supported", filter.ErrUnsupportedScope) default: return filter.NativeRulePlan{}, fmt.Errorf("%w: unsupported operation %s", filter.ErrInvalidRule, change.Operation) } diff --git a/agent/utils/firewall/filter/runtime/runtime.go b/agent/utils/firewall/filter/runtime/runtime.go index e719663f3..dba7adec6 100644 --- a/agent/utils/firewall/filter/runtime/runtime.go +++ b/agent/utils/firewall/filter/runtime/runtime.go @@ -165,6 +165,9 @@ func (e *Engine) ValidatePosition( rule filter.FirewallRule, target int64, ) error { + if target < 1 { + return fmt.Errorf("%w: target position must be positive", filter.ErrInvalidRule) + } if rule.Scope.Provider == filter.ProviderUFW { minimum, maximum := positionBounds(snapshot) if target < minimum || target > maximum { diff --git a/agent/utils/firewall/filter/safety.go b/agent/utils/firewall/filter/safety.go index 59826be3a..e9677f705 100644 --- a/agent/utils/firewall/filter/safety.go +++ b/agent/utils/firewall/filter/safety.go @@ -53,10 +53,19 @@ func SameLocator(left, right Locator) bool { if left.ScopeKey != right.ScopeKey { return false } - if left.Canonical != "" || right.Canonical != "" { - return left.Canonical != "" && left.Canonical == right.Canonical + if left.Provider != "" && right.Provider != "" && left.Provider != right.Provider { + return false } - return left.Position != nil && right.Position != nil && *left.Position == *right.Position + if left.Position != nil || right.Position != nil { + if left.Position == nil || right.Position == nil || *left.Position != *right.Position { + return false + } + if left.NativeID != "" && right.NativeID != "" && left.NativeID != right.NativeID { + return false + } + return left.Canonical == "" || right.Canonical == "" || left.Canonical == right.Canonical + } + return left.Canonical != "" && left.Canonical == right.Canonical } func MatchObservedByRuleKey(observed []ObservedRule, rule FirewallRule) ([]ObservedRule, error) { diff --git a/agent/utils/firewall/sync/order.go b/agent/utils/firewall/sync/order.go index 96e324755..e45b700e0 100644 --- a/agent/utils/firewall/sync/order.go +++ b/agent/utils/firewall/sync/order.go @@ -11,45 +11,30 @@ func SupportsManagedOrder(provider filter.Provider) bool { return provider == filter.ProviderIptables || provider == filter.ProviderNftables || provider == filter.ProviderUFW } -func ManagedOrderDrift(snapshot filter.Snapshot, desiredMarkers []string) (map[string]struct{}, bool) { +func ManagedOrderDrift(snapshot filter.Snapshot, desiredMarkers []string) map[string]struct{} { if !SupportsManagedOrder(snapshot.Scope.Provider) || len(desiredMarkers) < 2 { - return nil, true + return nil } expected := make(map[string]struct{}, len(desiredMarkers)) for _, marker := range desiredMarkers { expected[marker] = struct{}{} } actual := make([]string, 0, len(desiredMarkers)) - segments := make(map[string]int, len(desiredMarkers)) - segment := 0 + present := make(map[string]struct{}, len(desiredMarkers)) for _, observed := range snapshot.Rules { - _, wanted := expected[observed.Marker] - if wanted { + if _, wanted := expected[observed.Marker]; wanted { actual = append(actual, observed.Marker) - if observed.Protected || observed.ParseStatus == filter.ParseStatusOpaque { - segment++ - segments[observed.Marker] = segment - segment++ - } else { - segments[observed.Marker] = segment - } - continue + present[observed.Marker] = struct{}{} } - if strings.HasPrefix(observed.Marker, "1panel-rule:") && - !observed.Protected && observed.ParseStatus != filter.ParseStatusOpaque { - continue - } - segment++ } - desired := make([]string, 0, len(actual)) for _, marker := range desiredMarkers { - if _, exists := segments[marker]; exists { + if _, exists := present[marker]; exists { desired = append(desired, marker) } } if slices.Equal(actual, desired) { - return nil, true + return nil } drifted := make(map[string]struct{}, len(desired)) for index := range desired { @@ -58,15 +43,7 @@ func ManagedOrderDrift(snapshot filter.Snapshot, desiredMarkers []string) (map[s drifted[desired[index]] = struct{}{} } } - feasible, previousSegment := true, -1 - for _, marker := range desired { - if segments[marker] < previousSegment { - feasible = false - break - } - previousSegment = segments[marker] - } - return drifted, feasible + return drifted } func InsertionPosition(snapshot filter.Snapshot, desiredMarkers []string, targetMarker string) (int64, bool) { diff --git a/frontend/src/api/interface/firewall.ts b/frontend/src/api/interface/firewall.ts index 127e55f69..563df0ced 100644 --- a/frontend/src/api/interface/firewall.ts +++ b/frontend/src/api/interface/firewall.ts @@ -247,6 +247,7 @@ export namespace Firewall { export interface CheckItem { uuid?: string; rule: Rule; + adoptLocator?: Locator; } export interface CheckRequest { @@ -359,9 +360,11 @@ export namespace Firewall { error: string; } - export interface UpdateRequest { - rule: Rule; - } + export type UpdateRequest = + | { rule: Rule; description?: never; orderIndex?: never; priority?: never } + | { rule?: never; description: string; orderIndex?: never; priority?: never } + | { rule?: never; description?: string; orderIndex: number; priority?: never } + | { rule?: never; description?: string; orderIndex?: never; priority: number }; export interface DockerGuardBase { name: string; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 46d091cfa..e4467918e 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -4099,9 +4099,10 @@ const message = { ruleTargetRequired: 'Enter at least one IP address or port', batchRuleLimit: 'A maximum of {0} rules can be created at a time', resolution_adopt: 'Take over management', + plan_duplicate_rules: + 'Rules with identical conditions and actions cannot be adopted. Manually delete duplicate rules and retry.', adoptRuleConfirm: 'After takeover, 1Panel can maintain and delete this existing rule. Continue?', - plan_exact_rule_conflict: - 'A rule with identical matching conditions and priority has an opposing allow or deny action.', + plan_exact_rule_conflict: 'A rule with identical matching conditions has an opposing allow or deny action.', allRulesAlreadyExist: 'All {0} checked rules already exist. There are no new rules to create.', ruleCheckResult: 'Rule check results', ruleCheckStatus_creatable: 'Creatable', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index f9e6e21a6..945379558 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -4145,9 +4145,11 @@ const message = { ruleTargetRequired: 'Introduce al menos una dirección IP o un puerto', batchRuleLimit: 'Se pueden crear como máximo {0} reglas a la vez', resolution_adopt: 'Asumir la gestión', + plan_duplicate_rules: + 'No se pueden gestionar reglas duplicadas con las mismas condiciones y acciones. Elimine manualmente los duplicados y vuelva a intentarlo.', adoptRuleConfirm: 'Después de asumirla, 1Panel podrá mantener y eliminar esta regla existente. ¿Continuar?', plan_exact_rule_conflict: - 'Ya existe una regla con las mismas condiciones y prioridad, pero con una acción opuesta de permitir o denegar.', + 'Ya existe una regla con las mismas condiciones, pero con una acción opuesta de permitir o denegar.', allRulesAlreadyExist: 'Las {0} reglas comprobadas ya existen. No hay reglas nuevas que crear.', ruleCheckResult: 'Resultados de la comprobación de reglas', ruleCheckStatus_creatable: 'Se puede crear', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index 0ec0020f9..9bb49414b 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -4058,8 +4058,10 @@ const message = { ruleTargetRequired: 'حداقل یک نشانی IP یا درگاه وارد کنید', batchRuleLimit: 'در هر بار حداکثر می‌توان {0} قانون ایجاد کرد', resolution_adopt: 'واگذاری مدیریت', + plan_duplicate_rules: + 'قوانین تکراری با شرایط و اقدامات یکسان قابل پذیرش برای مدیریت نیستند. قوانین تکراری را به‌صورت دستی حذف کرده و دوباره تلاش کنید.', adoptRuleConfirm: 'پس از واگذاری، 1Panel می‌تواند این قانون موجود را نگهداری و حذف کند. ادامه می‌دهید؟', - plan_exact_rule_conflict: 'قانونی با شرایط تطبیق و اولویت یکسان، اما با اقدام مخالفِ اجازه یا رد وجود دارد.', + plan_exact_rule_conflict: 'قانونی با شرایط تطبیق یکسان، اما با اقدام مخالفِ اجازه یا رد وجود دارد.', allRulesAlreadyExist: 'هر {0} قانون بررسی‌شده از قبل وجود دارند. قانون جدیدی برای ایجاد وجود ندارد.', ruleCheckResult: 'نتایج بررسی قوانین', ruleCheckStatus_creatable: 'قابل ایجاد', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 402041ff9..9de9dd231 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -4083,8 +4083,10 @@ const message = { ruleTargetRequired: 'IP アドレスまたはポートを1つ以上入力してください', batchRuleLimit: '一度に作成できるルールは最大 {0} 件です', resolution_adopt: '管理対象にする', + plan_duplicate_rules: + '条件とアクションが同じルールが重複しているため、管理対象に追加できません。重複ルールを手動で削除してから再試行してください。', adoptRuleConfirm: '管理対象にすると、1Panel がこの既存ルールの保守と削除を行えるようになります。続行しますか?', - plan_exact_rule_conflict: '一致条件と優先度が同じで、許可・拒否の動作が逆のルールが存在します。', + plan_exact_rule_conflict: '一致条件が同じで、許可・拒否の動作が逆のルールが存在します。', allRulesAlreadyExist: '確認した {0} 件のルールはすべて既に存在します。新しく作成するルールはありません。', ruleCheckResult: 'ルール確認結果', ruleCheckStatus_creatable: '作成可能', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 5e5a0325e..766678921 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -4009,9 +4009,11 @@ const message = { ruleTargetRequired: 'IP 주소 또는 포트를 하나 이상 입력하세요', batchRuleLimit: '한 번에 최대 {0}개의 규칙을 생성할 수 있습니다', resolution_adopt: '관리 대상으로 전환', + plan_duplicate_rules: + '조건과 동작이 동일한 중복 규칙은 관리 대상으로 추가할 수 없습니다. 중복 규칙을 수동으로 삭제한 후 다시 시도하세요.', adoptRuleConfirm: '관리 대상으로 전환하면 1Panel이 이 기존 규칙을 유지하고 삭제할 수 있습니다. 계속하시겠습니까?', - plan_exact_rule_conflict: '일치 조건과 우선순위가 같지만 허용 또는 거부 동작이 반대인 규칙이 이미 있습니다.', + plan_exact_rule_conflict: '일치 조건이 같지만 허용 또는 거부 동작이 반대인 규칙이 이미 있습니다.', allRulesAlreadyExist: '확인한 규칙 {0}개가 모두 이미 존재합니다. 새로 생성할 규칙이 없습니다.', ruleCheckResult: '규칙 검사 결과', ruleCheckStatus_creatable: '생성 가능', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index 025f1f44d..cb8bef952 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -3980,8 +3980,10 @@ const message = { ruleTargetRequired: 'ກະລຸນາປ້ອນ IP ຫຼື ພອດຢ່າງໜ້ອຍໜຶ່ງລາຍການ', batchRuleLimit: 'ສາມາດສ້າງກົດໄດ້ສູງສຸດ {0} ລາຍການຕໍ່ຄັ້ງ', resolution_adopt: 'ຮັບເຂົ້າຈັດການ', + plan_duplicate_rules: + 'ບໍ່ສາມາດນຳກົດທີ່ມີເງື່ອນໄຂ ແລະ ການກະທຳຄືກັນເຂົ້າມາຈັດການໄດ້. ກະລຸນາລຶບກົດທີ່ຊ້ຳກັນດ້ວຍຕົນເອງ ແລ້ວລອງອີກຄັ້ງ.', adoptRuleConfirm: 'ຫຼັງຈາກຮັບເຂົ້າແລ້ວ 1Panel ຈະສາມາດຮັກສາແລະລຶບກົດນີ້ໄດ້. ສືບຕໍ່ບໍ?', - plan_exact_rule_conflict: 'ມີກົດທີ່ມີເງື່ອນໄຂແລະລຳດັບຄວາມສຳຄັນຄືກັນ ແຕ່ການອະນຸຍາດຫຼືປະຕິເສດກົງກັນຂ້າມ.', + plan_exact_rule_conflict: 'ມີກົດທີ່ມີເງື່ອນໄຂຄືກັນ ແຕ່ການອະນຸຍາດຫຼືປະຕິເສດກົງກັນຂ້າມ.', allRulesAlreadyExist: 'ກົດທັງໝົດ {0} ລາຍການທີ່ກວດສອບມີຢູ່ແລ້ວ. ບໍ່ມີກົດໃໝ່ທີ່ຕ້ອງສ້າງ.', ruleCheckResult: 'ຜົນການກວດສອບກົດ', ruleCheckStatus_creatable: 'ສາມາດສ້າງໄດ້', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 62048e73a..6ee8bc1cf 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -4165,10 +4165,12 @@ const message = { ruleTargetRequired: 'Masukkan sekurang-kurangnya satu alamat IP atau port', batchRuleLimit: 'Maksimum {0} peraturan boleh dibuat pada satu masa', resolution_adopt: 'Ambil alih pengurusan', + plan_duplicate_rules: + 'Peraturan pendua dengan syarat dan tindakan yang sama tidak boleh diambil alih untuk diurus. Padam peraturan pendua secara manual dan cuba lagi.', adoptRuleConfirm: 'Selepas diambil alih, 1Panel boleh menyelenggara dan memadam peraturan sedia ada ini. Teruskan?', plan_exact_rule_conflict: - 'Peraturan dengan syarat padanan dan keutamaan yang sama mempunyai tindakan benarkan atau sekat yang bertentangan.', + 'Peraturan dengan syarat padanan yang sama mempunyai tindakan benarkan atau sekat yang bertentangan.', allRulesAlreadyExist: 'Kesemua {0} peraturan yang diperiksa sudah wujud. Tiada peraturan baharu untuk dicipta.', ruleCheckResult: 'Keputusan semakan peraturan', ruleCheckStatus_creatable: 'Boleh dicipta', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index d4da71a8c..1de409d91 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -4184,9 +4184,11 @@ const message = { ruleTargetRequired: 'Informe pelo menos um endereço IP ou uma porta', batchRuleLimit: 'É possível criar no máximo {0} regras por vez', resolution_adopt: 'Assumir gerenciamento', + plan_duplicate_rules: + 'Regras duplicadas com condições e ações idênticas não podem ser gerenciadas. Exclua manualmente as regras duplicadas e tente novamente.', adoptRuleConfirm: 'Depois disso, o 1Panel poderá manter e excluir esta regra existente. Continuar?', plan_exact_rule_conflict: - 'Já existe uma regra com as mesmas condições e prioridade, mas com uma ação oposta de permitir ou negar.', + 'Já existe uma regra com as mesmas condições, mas com uma ação oposta de permitir ou negar.', allRulesAlreadyExist: 'Todas as {0} regras verificadas já existem. Não há novas regras para criar.', ruleCheckResult: 'Resultados da verificação de regras', ruleCheckStatus_creatable: 'Pode ser criada', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 08da62e82..28c45205f 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -4151,9 +4151,11 @@ const message = { ruleTargetRequired: 'Укажите хотя бы один IP-адрес или порт', batchRuleLimit: 'За один раз можно создать не более {0} правил', resolution_adopt: 'Взять под управление', + plan_duplicate_rules: + 'Нельзя принять под управление правила с одинаковыми условиями и действиями. Удалите дубликаты вручную и повторите попытку.', adoptRuleConfirm: 'После этого 1Panel сможет обслуживать и удалять существующее правило. Продолжить?', plan_exact_rule_conflict: - 'Уже существует правило с теми же условиями и приоритетом, но с противоположным действием разрешения или запрета.', + 'Уже существует правило с теми же условиями, но с противоположным действием разрешения или запрета.', allRulesAlreadyExist: 'Все проверенные правила ({0}) уже существуют. Новых правил для создания нет.', ruleCheckResult: 'Результаты проверки правил', ruleCheckStatus_creatable: 'Можно создать', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index ac7b81091..85900b85f 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -4169,9 +4169,11 @@ const message = { ruleTargetRequired: 'En az bir IP adresi veya port girin', batchRuleLimit: 'Bir seferde en fazla {0} kural oluşturulabilir', resolution_adopt: 'Yönetimi devral', + plan_duplicate_rules: + 'Koşulları ve eylemleri aynı olan yinelenen kurallar yönetime alınamaz. Yinelenen kuralları elle silip yeniden deneyin.', adoptRuleConfirm: 'Devraldıktan sonra 1Panel bu mevcut kuralı yönetebilir ve silebilir. Devam edilsin mi?', plan_exact_rule_conflict: - 'Aynı eşleşme koşullarına ve önceliğe sahip, ancak izin verme veya reddetme eylemi zıt olan bir kural zaten var.', + 'Aynı eşleşme koşullarına sahip, ancak izin verme veya reddetme eylemi zıt olan bir kural zaten var.', allRulesAlreadyExist: 'Kontrol edilen {0} kuralın tümü zaten mevcut. Oluşturulacak yeni kural yok.', ruleCheckResult: 'Kural kontrol sonuçları', ruleCheckStatus_creatable: 'Oluşturulabilir', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index b23687b15..a0239bc94 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -3829,8 +3829,9 @@ const message = { ruleTargetRequired: '請至少填寫一個 IP 位址或連接埠', batchRuleLimit: '單次最多可建立 {0} 條規則', resolution_adopt: '納管', + plan_duplicate_rules: '存在比對條件和動作相同的重複規則,無法納管。請手動刪除重複規則後重試。', adoptRuleConfirm: '納入管理後,1Panel 將負責維護及刪除這條現有規則,是否繼續?', - plan_exact_rule_conflict: '已存在比對條件和優先級相同、但允許與拒絕動作相反的規則。', + plan_exact_rule_conflict: '已存在比對條件相同、但允許與拒絕動作相反的規則。', allRulesAlreadyExist: '本次檢查的 {0} 條規則均已存在,沒有需要建立的新規則。', ruleCheckResult: '規則檢查結果', ruleCheckStatus_creatable: '可建立', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 965ca74e6..8e1dcc4c5 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -3875,8 +3875,9 @@ const message = { ruleTargetRequired: '请至少填写一个 IP 地址或端口', batchRuleLimit: '单次最多可创建 {0} 条规则', resolution_adopt: '纳管', + plan_duplicate_rules: '存在匹配条件和动作相同的重复规则,无法纳管。请手动删除重复规则后重试。', adoptRuleConfirm: '纳入管理后,1Panel 将负责维护和删除这条现有规则,是否继续?', - plan_exact_rule_conflict: '已存在匹配条件和优先级相同、但允许与拒绝动作相反的规则。', + plan_exact_rule_conflict: '已存在匹配条件相同、但允许与拒绝动作相反的规则。', allRulesAlreadyExist: '本次检查的 {0} 条规则均已存在,没有需要创建的新规则。', ruleCheckResult: '规则检查结果', ruleCheckStatus_creatable: '可创建', diff --git a/frontend/src/views/host/firewall/rule/index.vue b/frontend/src/views/host/firewall/rule/index.vue index 8f6080f5f..f1d8afcaa 100644 --- a/frontend/src/views/host/firewall/rule/index.vue +++ b/frontend/src/views/host/firewall/rule/index.vue @@ -1161,19 +1161,27 @@ const adoptRule = async (row: RuleRow) => { } loading.value = true; try { - const plan = (await checkFirewallRules({ items: [{ rule: row.rule }] })).data.items[0]; + const plan = (await checkFirewallRules({ items: [{ rule: row.rule, adoptLocator: row.observed.locator }] })) + .data.items[0]; + if (plan.decision === 'no_change' && plan.existingRuleUUID) { + MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); + await search(); + return; + } + if (plan.reason === 'duplicate_rules') { + MsgError(i18n.global.t('firewall.plan_duplicate_rules')); + return; + } + if (plan.decision === 'blocked' && plan.classification === 'exact_managed') { + MsgError(i18n.global.t('commons.rule.duplicate')); + return; + } if (plan.decision !== 'confirmation_required' || plan.classification !== 'exact_external') { MsgError(i18n.global.t('firewall.plan_blocked')); return; } - const candidate = plan.candidates?.find( - (item) => - item.locator.position === row.observed?.locator.position && - item.locator.nativeId === row.observed?.locator.nativeId && - item.locator.canonical === row.observed?.locator.canonical, - ); - const resolution: Firewall.ApplicableCheckAction = plan.candidates?.length === 1 ? 'adopt' : 'select_adopt'; - if (resolution === 'select_adopt' && !candidate?.instanceKey) { + const candidate = plan.candidates?.[0]; + if (plan.candidates?.length !== 1 || !candidate?.instanceKey) { MsgError(i18n.global.t('firewall.plan_blocked')); return; } @@ -1182,9 +1190,8 @@ const adoptRule = async (row: RuleRow) => { items: [ { checkFlag: plan.checkFlag, - action: resolution, - adoptInstanceKey: - resolution === 'select_adopt' ? candidate?.instanceKey : plan.candidates?.[0]?.instanceKey, + action: 'adopt', + adoptInstanceKey: candidate.instanceKey, rule: plan.requestedRule, sourceKind: 'user', }, @@ -1204,6 +1211,11 @@ const adoptRule = async (row: RuleRow) => { const removeRule = (row: RuleRow) => removeRules([row]); +const canEditDescription = (row: Firewall.InventoryItem) => + Boolean(row.desired?.uuid) && + (row.desired?.origin === 'created' || row.desired?.origin === 'adopted') && + !row.desired?.protected; + const isEditableManagedRule = (row: Firewall.InventoryItem) => Boolean(row.desired?.uuid) && !row.desired?.expanded && @@ -1238,7 +1250,7 @@ const displayRulePriority = (row: Firewall.InventoryItem) => { }; const openEdit = (row: RuleRow) => { - if (!isEditableManagedRule(row)) return; + if (!isEditableManagedRule(row) && !canEditDescription(row)) return; const currentPosition = row.observed?.locator.position || row.rule.orderIndex || 1; const range = positionRanges.value[row.rule.scope.family] || { min: currentPosition, max: currentPosition }; ruleOperateRef.value?.acceptParams( @@ -1246,6 +1258,7 @@ const openEdit = (row: RuleRow) => { row, provider.value === 'firewalld' ? positionRanges.value : { [row.rule.scope.family]: range }, supportsFirewalldPriority.value, + !isEditableManagedRule(row), ); }; @@ -1268,7 +1281,7 @@ const operationButtons = [ label: i18n.global.t('commons.button.edit'), permission: true, nodeAdmin: true, - show: (row: RuleRow) => isEditableManagedRule(row), + show: (row: RuleRow) => isEditableManagedRule(row) || canEditDescription(row), click: openEdit, }, { diff --git a/frontend/src/views/host/firewall/rule/operate/index.vue b/frontend/src/views/host/firewall/rule/operate/index.vue index 31f5fdf95..53b36c996 100644 --- a/frontend/src/views/host/firewall/rule/operate/index.vue +++ b/frontend/src/views/host/firewall/rule/operate/index.vue @@ -15,7 +15,7 @@ :rules="rules" > - + {{ $t('firewall.accept') }} @@ -25,7 +25,7 @@ - + @@ -43,6 +43,7 @@ ref="sourceAddressRefs" v-model.trim="item.address" class="source-address-select" + :disabled="descriptionOnly" clearable :placeholder="$t('firewall.sourceAddressPlaceholder')" @keyup.enter.prevent="addSourceAddressOnEnter(index)" @@ -63,7 +64,7 @@ v-model.trim="form.destinationPorts[index]" class="destination-port-input" clearable - :disabled="!portProtocol" + :disabled="descriptionOnly || !portProtocol" :placeholder="$t('firewall.destinationPortPlaceholder')" @keyup.enter.prevent="addDestinationPortOnEnter(index)" > @@ -71,24 +72,30 @@ - + {{ $t('commons.button.add') }} - {{ priorityMin }} ~ {{ priorityMax }} + {{ priorityMin }} ~ {{ priorityMax }} @@ -172,7 +179,15 @@ :disabled="loading || (showingPreview && hasBlockingRules)" @click="onCheckOrSubmit" > - {{ $t(showingPreview ? 'commons.button.submit' : 'commons.button.check') }} + {{ + $t( + showingPreview + ? 'commons.button.submit' + : directEdit + ? 'commons.button.save' + : 'commons.button.check', + ) + }} @@ -200,7 +215,9 @@ import { const provider = ref('iptables'); const mode = ref<'create' | 'edit'>('create'); const editingUUID = ref(''); +const descriptionOnly = ref(false); const editingRule = ref(); +const originalFormRule = ref(); const drawerVisible = ref(false); const loading = ref(false); const formRef = ref(); @@ -307,6 +324,7 @@ const priorityMax = computed(() => Math.min(...selectedPositionRanges.value.map( const showingPreview = computed(() => previewVisible.value && previewRules.value.length > 0); const supportedPlanReasons = new Set([ + 'duplicate_rules', 'exact_rule_conflict', 'managed_rule_drifted', 'opaque_rule_in_target_scope', @@ -452,7 +470,9 @@ const resetForm = () => { form.priority = undefined; form.description = ''; editingUUID.value = ''; + descriptionOnly.value = false; editingRule.value = undefined; + originalFormRule.value = undefined; resetBatch(); formRef.value?.clearValidate(); }; @@ -462,14 +482,16 @@ const acceptParams = ( item?: Firewall.InventoryItem, ranges: Partial> = {}, supportsExplicitPriority = true, + onlyDescription = false, ) => { provider.value = value; firewalldPrioritySupported.value = supportsExplicitPriority; positionRanges.value = ranges; mode.value = item?.desired?.uuid ? 'edit' : 'create'; resetForm(); + descriptionOnly.value = mode.value === 'edit' && onlyDescription; if (mode.value === 'edit' && item?.desired?.uuid) { - const rule = item.rule; + const rule = descriptionOnly.value ? item.desired.rule : item.rule; const currentPosition = item.observed?.locator.position || rule.orderIndex; editingUUID.value = item.desired.uuid; editingRule.value = { @@ -492,6 +514,7 @@ const acceptParams = ( form.action = rule.action === 'reject' ? 'drop' : rule.action; form.priority = provider.value === 'firewalld' ? rule.priority : currentPosition || priorityMax.value; form.description = rule.description || ''; + originalFormRule.value = buildRule(); } drawerVisible.value = true; }; @@ -546,9 +569,11 @@ const buildRule = ( ? 'ipv6' : protocol === 'icmp' ? 'ipv4' - : provider.value === 'firewalld' - ? 'inet' - : source.family; + : mode.value === 'edit' + ? editingRule.value?.scope.family || source.family + : provider.value === 'firewalld' + ? 'inet' + : source.family; const action = mode.value === 'edit' && editingRule.value?.action === 'reject' && form.action === 'drop' ? 'reject' @@ -577,7 +602,7 @@ const buildRule = ( chain: 'incoming', direction: 'input', }, - protocol, + protocol: mode.value === 'edit' && provider.value === 'ufw' && protocol === 'tcp/udp' ? 'all' : protocol, sourceAddress: isWildcardAddress(source.family, source.address) ? '' : source.address, sourcePort: form.sourcePort, destinationAddress: form.destinationAddress, @@ -601,10 +626,20 @@ const editableFieldLabels: Array<[keyof Firewall.Rule, string]> = [ ['description', 'commons.table.description'], ]; -const changedFieldLabels = (before: Firewall.Rule, after: Firewall.Rule) => +const changedRuleFields = (before: Firewall.Rule, after: Firewall.Rule) => editableFieldLabels .filter(([field]) => JSON.stringify(before[field] ?? '') !== JSON.stringify(after[field] ?? '')) - .map(([, label]) => i18n.global.t(label)); + .map(([field]) => field); + +const canSaveWithoutCheck = (before: Firewall.Rule, after: Firewall.Rule) => + changedRuleFields(before, after).every((field) => ['description', 'orderIndex', 'priority'].includes(field)); + +const directEdit = computed( + () => + mode.value === 'edit' && + (descriptionOnly.value || + Boolean(originalFormRule.value && canSaveWithoutCheck(originalFormRule.value, buildRule()))), +); const availableResolutions = (result: Firewall.RuleCheckResult) => (result.allowedActions || []).filter((item): item is Firewall.ApplicableCheckAction => item !== 'cancel'); @@ -732,8 +767,38 @@ const prepareRulesFromForm = async () => { }; const checkRules = async () => { + if (descriptionOnly.value && editingUUID.value && originalFormRule.value) { + previewRules.value = [{ ...originalFormRule.value, description: form.description }]; + batchPlans.value = []; + await executeEdit(); + return; + } + if (directEdit.value && editingUUID.value && originalFormRule.value) { + const rule = buildRule(); + const changed = changedRuleFields(originalFormRule.value, rule); + if (changed.some((field) => field === 'priority' || field === 'orderIndex')) { + const value = previewRulePriority(rule); + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < priorityMin.value || + value > priorityMax.value + ) { + MsgError(i18n.global.t('commons.rule.numberRange', [priorityMin.value, priorityMax.value])); + return; + } + } + previewRules.value = [rule]; + batchPlans.value = []; + await executeEdit(); + return; + } if (!(await prepareRulesFromForm())) return; if (mode.value === 'edit' && editingUUID.value) { + if (originalFormRule.value && canSaveWithoutCheck(originalFormRule.value, previewRules.value[0])) { + await executeEdit(); + return; + } const result = ( await checkFirewallRules({ items: [{ uuid: editingUUID.value, rule: previewRules.value[0] }], @@ -755,25 +820,42 @@ const executeEdit = async () => { return; } const updatedRule = previewRules.value[0]; - const changed = editingRule.value ? changedFieldLabels(editingRule.value, updatedRule) : []; + const before = originalFormRule.value || editingRule.value; + const changed = before ? changedRuleFields(before, updatedRule) : []; if (changed.length === 0) { drawerVisible.value = false; return; } - try { - await ElMessageBox.confirm( - i18n.global.t('firewall.editRuleConfirm', [changed.join(', ')]), - i18n.global.t('firewall.edit'), - { - confirmButtonText: i18n.global.t('commons.button.confirm'), - cancelButtonText: i18n.global.t('commons.button.cancel'), - type: 'warning', - }, - ); - } catch { - return; + if (!before || !canSaveWithoutCheck(before, updatedRule)) { + const labels = editableFieldLabels + .filter(([field]) => changed.includes(field)) + .map(([, label]) => i18n.global.t(label)); + try { + await ElMessageBox.confirm( + i18n.global.t('firewall.editRuleConfirm', [labels.join(', ')]), + i18n.global.t('firewall.edit'), + { + confirmButtonText: i18n.global.t('commons.button.confirm'), + cancelButtonText: i18n.global.t('commons.button.cancel'), + type: 'warning', + }, + ); + } catch { + return; + } } - await updateFirewallRule(editingUUID.value, { rule: updatedRule }); + let request: Firewall.UpdateRequest = { rule: updatedRule }; + if (before && canSaveWithoutCheck(before, updatedRule)) { + const description = changed.includes('description') ? updatedRule.description || '' : undefined; + if (changed.includes('priority')) { + request = { priority: updatedRule.priority!, description }; + } else if (changed.includes('orderIndex')) { + request = { orderIndex: updatedRule.orderIndex!, description }; + } else { + request = { description: description || '' }; + } + } + await updateFirewallRule(editingUUID.value, request); MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); emit('search'); drawerVisible.value = false; diff --git a/frontend/src/views/host/firewall/sync/index.vue b/frontend/src/views/host/firewall/sync/index.vue index fe474fce1..ae0acfbc2 100644 --- a/frontend/src/views/host/firewall/sync/index.vue +++ b/frontend/src/views/host/firewall/sync/index.vue @@ -295,7 +295,6 @@ const syncReasonKeys: Record = { 'managed rule order differs from database sequence': 'managedOrderDiffers', 'managed rule exists only in target backend': 'managedOnlyInTarget', 'managed runtime rule cannot be safely removed': 'managedRuntimeCannotRemove', - 'managed rule order cannot cross external, opaque, or protected rules': 'managedOrderBlocked', 'rule is missing from target backend': 'missingFromTarget', 'target rule differs from database policy': 'targetDiffers', 'rule already exists in target backend': 'alreadyExistsInTarget',