fix(firewall): simplify rule editing and reject duplicate adoption (#13779)

This commit is contained in:
ssongliu
2026-09-10 18:11:36 +08:00
committed by GitHub
parent 53a7347bea
commit 4cd77d8ee1
29 changed files with 596 additions and 252 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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" {

View File

@@ -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:])

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'قابل ایجاد',

View File

@@ -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: '作成可能',

View File

@@ -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: '생성 가능',

View File

@@ -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: 'ສາມາດສ້າງໄດ້',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: 'Можно создать',

View File

@@ -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',

View File

@@ -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: '可建立',

View File

@@ -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: '可创建',

View File

@@ -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,
},
{

View File

@@ -15,7 +15,7 @@
:rules="rules"
>
<el-form-item :label="$t('firewall.action')" prop="action">
<el-radio-group v-model="form.action">
<el-radio-group v-model="form.action" :disabled="descriptionOnly">
<el-radio-button value="accept">
{{ $t('firewall.accept') }}
</el-radio-button>
@@ -25,7 +25,7 @@
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('commons.table.protocol')" prop="protocol">
<el-select v-model="form.protocol" class="w-full" @change="changeProtocol">
<el-select v-model="form.protocol" :disabled="descriptionOnly" class="w-full" @change="changeProtocol">
<el-option v-if="mode === 'create' || provider === 'ufw'" label="TCP/UDP" value="tcp/udp" />
<el-option label="TCP" value="tcp" />
<el-option label="UDP" value="udp" />
@@ -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 @@
<el-button
v-if="mode === 'create'"
icon="Delete"
:disabled="!portProtocol"
:disabled="descriptionOnly || !portProtocol"
@click="removeRuleRow(index)"
/>
</template>
</el-input>
</div>
<el-button v-if="mode === 'create'" class="mt-2" :disabled="!portProtocol" @click="addRuleRow">
<el-button
v-if="mode === 'create'"
class="mt-2"
:disabled="descriptionOnly || !portProtocol"
@click="addRuleRow"
>
{{ $t('commons.button.add') }}
</el-button>
</el-form-item>
<el-form-item v-if="showPriorityField" :label="priorityFieldLabel">
<el-input-number
v-model="form.priority"
:disabled="descriptionOnly"
:min="priorityMin"
:max="priorityMax"
controls-position="right"
/>
<span class="priority-range">{{ priorityMin }} ~ {{ priorityMax }}</span>
<span v-if="!descriptionOnly" class="priority-range">{{ priorityMin }} ~ {{ priorityMax }}</span>
</el-form-item>
<el-form-item :label="$t('commons.table.description')">
<el-input v-model.trim="form.description" clearable />
@@ -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',
)
}}
</el-button>
</template>
</DrawerPro>
@@ -200,7 +215,9 @@ import {
const provider = ref<Firewall.Provider>('iptables');
const mode = ref<'create' | 'edit'>('create');
const editingUUID = ref('');
const descriptionOnly = ref(false);
const editingRule = ref<Firewall.Rule>();
const originalFormRule = ref<Firewall.Rule>();
const drawerVisible = ref(false);
const loading = ref(false);
const formRef = ref<FormInstance>();
@@ -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<Record<Firewall.Family, Firewall.PositionRange>> = {},
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;

View File

@@ -295,7 +295,6 @@ const syncReasonKeys: Record<string, string> = {
'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',